From 7cb13202dac2dd9c86b81316b0610484d2d97157 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 7 Jul 2026 23:38:39 +0900 Subject: [PATCH 01/33] =?UTF-8?q?feat(model-v2):=20anchor=20=E2=80=94=20th?= =?UTF-8?q?e=20v2=20node=20model=20workspace=20(textbook=20snapshot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full redesign workbench for node geometry/layout/transform, scoped to the Rust engine (crates/grida) and the format spec (format/grida.fbs): - phase 1-2 research corpus: problems, harnesses, peer study, candidate models (anchor / sheet / bake / wire) — anchor decided by survey - the 114-test Rust lab (model-v2/a/lab): four-phase resolver (Taffy flex, oriented envelopes), typed ops incl. cross-zero flip resize (E-A14), canonical text IR, node-arena + SOA storage (11.5x resolver speedup) - the decision register (a/DECISIONS.md) with DEC-0 LOCKED: visual-only rotation (the CSS framing), CSS-pure sizing (a/dec0-visual-only.md) - peer-compat matrix (COMPAT), ship-readiness census (LIMITS), report - demo pages (a/.preview): proof, model walkthrough, edge cases, DEC-0 fork A/B, free editing - E10: the native skia interactive spike (a/spike-canvas) — feel pass + TEXTBOOK.md, the reference for the legacy migration Isolated: nothing outside model-v2/ is touched; no production code paths. --- model-v2/.gitignore | 1 + model-v2/README.md | 127 + model-v2/a/.preview/edge.html | 4053 ++++++++++ model-v2/a/.preview/fork.html | 6837 +++++++++++++++++ model-v2/a/.preview/free.html | 911 +++ model-v2/a/.preview/index.html | 3876 ++++++++++ model-v2/a/.preview/model.html | 1017 +++ model-v2/a/COMPAT.md | 174 + model-v2/a/DECISIONS.md | 322 + model-v2/a/GROUP.md | 150 + model-v2/a/LIMITS.md | 141 + model-v2/a/MODEL.md | 170 + model-v2/a/README.md | 116 + model-v2/a/REPORT.md | 214 + model-v2/a/dec0-fork/fork.json | 6468 ++++++++++++++++ model-v2/a/dec0-fork/index.html | 6837 +++++++++++++++++ model-v2/a/dec0-visual-only.md | 105 + model-v2/a/e1-rotation-in-flow/README.md | 39 + model-v2/a/e1-rotation-in-flow/demo.html | 2257 ++++++ .../e1-rotation-in-flow/frames/theta_000.svg | 26 + .../e1-rotation-in-flow/frames/theta_015.svg | 26 + .../e1-rotation-in-flow/frames/theta_030.svg | 26 + .../e1-rotation-in-flow/frames/theta_045.svg | 26 + .../e1-rotation-in-flow/frames/theta_060.svg | 26 + .../e1-rotation-in-flow/frames/theta_075.svg | 26 + .../e1-rotation-in-flow/frames/theta_090.svg | 26 + model-v2/a/e1-rotation-in-flow/metrics.csv | 182 + model-v2/a/e1-rotation-in-flow/verdict.md | 62 + model-v2/a/e2-format/README.md | 52 + model-v2/a/e2-format/anchor.fbs | 134 + model-v2/a/e2-format/anchor_v2.fbs | 136 + model-v2/a/e2-format/quartet.json | 140 + model-v2/a/e2-format/quartet_v2.json | 141 + model-v2/a/e2-format/run.sh | 14 + model-v2/a/e3-text-ir/README.md | 41 + model-v2/a/e3-text-ir/grammar.md | 143 + model-v2/a/e3-text-ir/predictions/fable-a.txt | 22 + model-v2/a/e3-text-ir/predictions/fable-b.txt | 22 + model-v2/a/e3-text-ir/predictions/haiku-c.txt | 22 + model-v2/a/e3-text-ir/probes/p1.xml | 7 + model-v2/a/e3-text-ir/probes/p2.xml | 7 + model-v2/a/e3-text-ir/probes/p3.xml | 6 + model-v2/a/e3-text-ir/probes/p4.xml | 5 + model-v2/a/e3-text-ir/probes/p5.xml | 7 + model-v2/a/e3-text-ir/probes/p6.xml | 6 + model-v2/a/e3-text-ir/truth.txt | 22 + model-v2/a/e3-text-ir/verdict.md | 51 + model-v2/a/e4-resolver/README.md | 40 + model-v2/a/e4-resolver/verdict.md | 61 + model-v2/a/e5-svg-corpus/README.md | 47 + model-v2/a/e5-svg-corpus/verdict.md | 49 + model-v2/a/e6-pluggable-layout/SKETCH.md | 72 + model-v2/a/e7-shape-points-scale/README.md | 168 + model-v2/a/edge-cases/frames.json | 3609 +++++++++ model-v2/a/edge-cases/index.html | 4053 ++++++++++ model-v2/a/free-editing/index.html | 911 +++ model-v2/a/lab/Cargo.lock | 130 + model-v2/a/lab/Cargo.toml | 17 + model-v2/a/lab/README.md | 35 + model-v2/a/lab/src/bin/e1.rs | 338 + model-v2/a/lab/src/bin/e3.rs | 118 + model-v2/a/lab/src/bin/e4.rs | 186 + model-v2/a/lab/src/bin/e5scan.rs | 340 + model-v2/a/lab/src/bin/edge.rs | 414 + model-v2/a/lab/src/bin/fork.rs | 333 + model-v2/a/lab/src/lib.rs | 18 + model-v2/a/lab/src/math.rs | 281 + model-v2/a/lab/src/measure.rs | 76 + model-v2/a/lab/src/model.rs | 493 ++ model-v2/a/lab/src/ops.rs | 410 + model-v2/a/lab/src/pick.rs | 59 + model-v2/a/lab/src/resolve.rs | 1089 +++ model-v2/a/lab/src/svgout.rs | 128 + model-v2/a/lab/src/textir.rs | 640 ++ model-v2/a/lab/tests/arena_pick.rs | 216 + model-v2/a/lab/tests/common/mod.rs | 81 + model-v2/a/lab/tests/derived.rs | 166 + model-v2/a/lab/tests/edge_census.rs | 531 ++ model-v2/a/lab/tests/escape.rs | 124 + model-v2/a/lab/tests/flip.rs | 345 + model-v2/a/lab/tests/geometry.rs | 168 + model-v2/a/lab/tests/layout.rs | 307 + model-v2/a/lab/tests/mm_laws.rs | 223 + model-v2/a/lab/tests/ops_suite.rs | 176 + model-v2/a/lab/tests/rotation.rs | 212 + model-v2/a/lab/tests/textir_suite.rs | 95 + model-v2/a/lab/tests/visual_only.rs | 362 + model-v2/a/model-demo.html | 1017 +++ model-v2/a/pedantic-review.md | 209 + model-v2/a/proof.html | 3876 ++++++++++ model-v2/a/spike-canvas/.cargo/config.toml | 5 + model-v2/a/spike-canvas/Cargo.lock | 2686 +++++++ model-v2/a/spike-canvas/Cargo.toml | 40 + model-v2/a/spike-canvas/README.md | 54 + model-v2/a/spike-canvas/SPIKE.md | 99 + model-v2/a/spike-canvas/TEXTBOOK.md | 112 + model-v2/a/spike-canvas/shots/crosszero.png | Bin 0 -> 29914 bytes model-v2/a/spike-canvas/shots/default.png | Bin 0 -> 33268 bytes model-v2/a/spike-canvas/shots/rot45.png | Bin 0 -> 30054 bytes model-v2/a/spike-canvas/shots/ungroup.png | Bin 0 -> 26505 bytes model-v2/a/spike-canvas/src/camera.rs | 62 + model-v2/a/spike-canvas/src/interaction.rs | 249 + model-v2/a/spike-canvas/src/main.rs | 169 + model-v2/a/spike-canvas/src/paint.rs | 128 + model-v2/a/spike-canvas/src/scene.rs | 184 + model-v2/a/spike-canvas/src/shell/app.rs | 895 +++ model-v2/a/spike-canvas/src/shell/hud.rs | 238 + model-v2/a/spike-canvas/src/shell/mod.rs | 13 + model-v2/a/spike-canvas/src/shell/window.rs | 206 + model-v2/axes.md | 140 + model-v2/conformance.md | 265 + model-v2/editor.md | 172 + model-v2/finale.md | 101 + model-v2/harnesses.md | 217 + model-v2/models/a.md | 459 ++ model-v2/models/b.md | 262 + model-v2/models/c.md | 202 + model-v2/models/d.md | 135 + model-v2/paradigm.md | 183 + model-v2/problems.md | 530 ++ model-v2/study.md | 128 + model-v2/survey.md | 191 + model-v2/triage.md | 116 + 123 files changed, 65683 insertions(+) create mode 100644 model-v2/.gitignore create mode 100644 model-v2/README.md create mode 100644 model-v2/a/.preview/edge.html create mode 100644 model-v2/a/.preview/fork.html create mode 100644 model-v2/a/.preview/free.html create mode 100644 model-v2/a/.preview/index.html create mode 100644 model-v2/a/.preview/model.html create mode 100644 model-v2/a/COMPAT.md create mode 100644 model-v2/a/DECISIONS.md create mode 100644 model-v2/a/GROUP.md create mode 100644 model-v2/a/LIMITS.md create mode 100644 model-v2/a/MODEL.md create mode 100644 model-v2/a/README.md create mode 100644 model-v2/a/REPORT.md create mode 100644 model-v2/a/dec0-fork/fork.json create mode 100644 model-v2/a/dec0-fork/index.html create mode 100644 model-v2/a/dec0-visual-only.md create mode 100644 model-v2/a/e1-rotation-in-flow/README.md create mode 100644 model-v2/a/e1-rotation-in-flow/demo.html create mode 100644 model-v2/a/e1-rotation-in-flow/frames/theta_000.svg create mode 100644 model-v2/a/e1-rotation-in-flow/frames/theta_015.svg create mode 100644 model-v2/a/e1-rotation-in-flow/frames/theta_030.svg create mode 100644 model-v2/a/e1-rotation-in-flow/frames/theta_045.svg create mode 100644 model-v2/a/e1-rotation-in-flow/frames/theta_060.svg create mode 100644 model-v2/a/e1-rotation-in-flow/frames/theta_075.svg create mode 100644 model-v2/a/e1-rotation-in-flow/frames/theta_090.svg create mode 100644 model-v2/a/e1-rotation-in-flow/metrics.csv create mode 100644 model-v2/a/e1-rotation-in-flow/verdict.md create mode 100644 model-v2/a/e2-format/README.md create mode 100644 model-v2/a/e2-format/anchor.fbs create mode 100644 model-v2/a/e2-format/anchor_v2.fbs create mode 100644 model-v2/a/e2-format/quartet.json create mode 100644 model-v2/a/e2-format/quartet_v2.json create mode 100755 model-v2/a/e2-format/run.sh create mode 100644 model-v2/a/e3-text-ir/README.md create mode 100644 model-v2/a/e3-text-ir/grammar.md create mode 100644 model-v2/a/e3-text-ir/predictions/fable-a.txt create mode 100644 model-v2/a/e3-text-ir/predictions/fable-b.txt create mode 100644 model-v2/a/e3-text-ir/predictions/haiku-c.txt create mode 100644 model-v2/a/e3-text-ir/probes/p1.xml create mode 100644 model-v2/a/e3-text-ir/probes/p2.xml create mode 100644 model-v2/a/e3-text-ir/probes/p3.xml create mode 100644 model-v2/a/e3-text-ir/probes/p4.xml create mode 100644 model-v2/a/e3-text-ir/probes/p5.xml create mode 100644 model-v2/a/e3-text-ir/probes/p6.xml create mode 100644 model-v2/a/e3-text-ir/truth.txt create mode 100644 model-v2/a/e3-text-ir/verdict.md create mode 100644 model-v2/a/e4-resolver/README.md create mode 100644 model-v2/a/e4-resolver/verdict.md create mode 100644 model-v2/a/e5-svg-corpus/README.md create mode 100644 model-v2/a/e5-svg-corpus/verdict.md create mode 100644 model-v2/a/e6-pluggable-layout/SKETCH.md create mode 100644 model-v2/a/e7-shape-points-scale/README.md create mode 100644 model-v2/a/edge-cases/frames.json create mode 100644 model-v2/a/edge-cases/index.html create mode 100644 model-v2/a/free-editing/index.html create mode 100644 model-v2/a/lab/Cargo.lock create mode 100644 model-v2/a/lab/Cargo.toml create mode 100644 model-v2/a/lab/README.md create mode 100644 model-v2/a/lab/src/bin/e1.rs create mode 100644 model-v2/a/lab/src/bin/e3.rs create mode 100644 model-v2/a/lab/src/bin/e4.rs create mode 100644 model-v2/a/lab/src/bin/e5scan.rs create mode 100644 model-v2/a/lab/src/bin/edge.rs create mode 100644 model-v2/a/lab/src/bin/fork.rs create mode 100644 model-v2/a/lab/src/lib.rs create mode 100644 model-v2/a/lab/src/math.rs create mode 100644 model-v2/a/lab/src/measure.rs create mode 100644 model-v2/a/lab/src/model.rs create mode 100644 model-v2/a/lab/src/ops.rs create mode 100644 model-v2/a/lab/src/pick.rs create mode 100644 model-v2/a/lab/src/resolve.rs create mode 100644 model-v2/a/lab/src/svgout.rs create mode 100644 model-v2/a/lab/src/textir.rs create mode 100644 model-v2/a/lab/tests/arena_pick.rs create mode 100644 model-v2/a/lab/tests/common/mod.rs create mode 100644 model-v2/a/lab/tests/derived.rs create mode 100644 model-v2/a/lab/tests/edge_census.rs create mode 100644 model-v2/a/lab/tests/escape.rs create mode 100644 model-v2/a/lab/tests/flip.rs create mode 100644 model-v2/a/lab/tests/geometry.rs create mode 100644 model-v2/a/lab/tests/layout.rs create mode 100644 model-v2/a/lab/tests/mm_laws.rs create mode 100644 model-v2/a/lab/tests/ops_suite.rs create mode 100644 model-v2/a/lab/tests/rotation.rs create mode 100644 model-v2/a/lab/tests/textir_suite.rs create mode 100644 model-v2/a/lab/tests/visual_only.rs create mode 100644 model-v2/a/model-demo.html create mode 100644 model-v2/a/pedantic-review.md create mode 100644 model-v2/a/proof.html create mode 100644 model-v2/a/spike-canvas/.cargo/config.toml create mode 100644 model-v2/a/spike-canvas/Cargo.lock create mode 100644 model-v2/a/spike-canvas/Cargo.toml create mode 100644 model-v2/a/spike-canvas/README.md create mode 100644 model-v2/a/spike-canvas/SPIKE.md create mode 100644 model-v2/a/spike-canvas/TEXTBOOK.md create mode 100644 model-v2/a/spike-canvas/shots/crosszero.png create mode 100644 model-v2/a/spike-canvas/shots/default.png create mode 100644 model-v2/a/spike-canvas/shots/rot45.png create mode 100644 model-v2/a/spike-canvas/shots/ungroup.png create mode 100644 model-v2/a/spike-canvas/src/camera.rs create mode 100644 model-v2/a/spike-canvas/src/interaction.rs create mode 100644 model-v2/a/spike-canvas/src/main.rs create mode 100644 model-v2/a/spike-canvas/src/paint.rs create mode 100644 model-v2/a/spike-canvas/src/scene.rs create mode 100644 model-v2/a/spike-canvas/src/shell/app.rs create mode 100644 model-v2/a/spike-canvas/src/shell/hud.rs create mode 100644 model-v2/a/spike-canvas/src/shell/mod.rs create mode 100644 model-v2/a/spike-canvas/src/shell/window.rs create mode 100644 model-v2/axes.md create mode 100644 model-v2/conformance.md create mode 100644 model-v2/editor.md create mode 100644 model-v2/finale.md create mode 100644 model-v2/harnesses.md create mode 100644 model-v2/models/a.md create mode 100644 model-v2/models/b.md create mode 100644 model-v2/models/c.md create mode 100644 model-v2/models/d.md create mode 100644 model-v2/paradigm.md create mode 100644 model-v2/problems.md create mode 100644 model-v2/study.md create mode 100644 model-v2/survey.md create mode 100644 model-v2/triage.md diff --git a/model-v2/.gitignore b/model-v2/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /dev/null +++ b/model-v2/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/model-v2/README.md b/model-v2/README.md new file mode 100644 index 0000000000..ad58388663 --- /dev/null +++ b/model-v2/README.md @@ -0,0 +1,127 @@ +# model-v2 — node geometry / layout / transform model redesign + +Workbench for the fundamental redesign of the Grida node model: how a node's +geometry, position, size, rotation/transform, and layout participation are +represented — in the **Rust engine** (`crates/grida`) and the **format spec** +(`format/grida.fbs`). Other seams (TS editor, WASM bindings, importers) follow +after the model lands; they are out of scope here. + +> **Branch note.** This directory lives on the `model-v2-anchor` branch as a +> working snapshot. The plan: finish the feel pass on the spike, then propose +> an RFC with this branch as the textbook, then start the legacy +> migration/rebuild (weeks out). Nothing here ships; no production code is +> touched by this branch. + +## Run + +```sh +# the lab (the model's single source of truth) — 114 tests +cd model-v2/a/lab && cargo test + +# the interactive spike (native skia window on the model) +cd model-v2/a/spike-canvas && cargo run --release + +# the demo pages (proof, model walkthrough, edge cases, DEC-0 fork, free editing) +python3 -m http.server 4173 --directory model-v2/a/.preview +``` + +## Why this exists + +The current system answers the same question three different ways: + +- leaf nodes: baked `AffineTransform` + `size` +- containers: `position` enum + `rotation: f32` scalar + `layout_dimensions` +- format spec (`LayerTrait`): `layout` + `post_layout_transform` (unimplemented, self-flagged provisional) + +reconciled at runtime by a lossy, branchy resolver. This was never reconciled +because the underlying questions were never decided. This directory decides +them — problems first, then candidates, then spec. + +## Phase discipline + +| phase | artifact | status | +| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1. Problems & harnesses | `problems.md`, `harnesses.md`, `study.md` | stable draft | +| 2. Candidate models | `paradigm.md`, `axes.md`, `models/*`, `finale.md`, `triage.md` | **DECIDED — `anchor`** (+5 triage amendments) | +| 3. Spec | normative doc + `grida.fbs` draft | **experiments RUN, model PROVEN** — E1–E10 complete with verdicts; **DEC-0 decided: VISUAL-ONLY rotation (the CSS framing), CSS-pure sizing** ([`a/dec0-visual-only.md`](./a/dec0-visual-only.md)); flips built (E-A14, cross-zero resize); 114-test lab; native interactive spike ([`a/spike-canvas/`](./a/spike-canvas/)); open calls parked in [`a/DECISIONS.md`](./a/DECISIONS.md). Remaining: fold deltas into a normative rewrite of `models/a.md` + WG graduation | +| 4. Runtime | `crates/grida` implementation | not started (the spike's [`TEXTBOOK.md`](./a/spike-canvas/TEXTBOOK.md) is the migration's reference) | + +Ground rules: + +- **Problems before solutions.** When a new unclear part arises, it becomes a + catalog entry — not an inline patch to a proposal. +- **Every claim is evidence-linked** to current code, the format spec, or a + studied peer system. +- **No candidate survives without a harness run.** `harnesses.md` is the test + suite for designs. +- An earlier in-chat probe sketched one candidate (scalars-canonical + per-axis + anchors + layout-visible rotation + a transform-quarantine node). It is + deliberately **not recorded here as a decision** — it re-enters in phase 2 as + one candidate among others, subject to the harnesses. + +## Files + +- [`problems.md`](./problems.md) — the problem catalog (P1–P11): each unclear + part stated precisely, with its tension, option space, and evidence. +- [`harnesses.md`](./harnesses.md) — the constraints (H1–H10) any candidate + must pass, each with a concrete pass/fail probe, plus the tension map + between harnesses. +- [`study.md`](./study.md) — comparative study of peer systems (CSS, SVG, + Flutter, SwiftUI, Figma, tldraw): facts we reason with, not designs we copy. +- [`paradigm.md`](./paradigm.md) — phase-2 **candidate** paradigm + ("one box, one way"): nouns, laws, how each problem lands, trades declared, + falsification criteria. Not ratified. +- [`finale.md`](./finale.md) — the phase-2 finale, **decided: `anchor`**; + preserved with the pre-decision concession bill and deciding question. +- [`survey.md`](./survey.md) — the 32-question instrument itself, saved + clean (no answers, no verdict): administration rules, all questions with + options, and scoring guidance. Reusable for future re-runs or other + respondents. +- [`triage.md`](./triage.md) — the 2026-07-07 run of the survey: answers, + scoring key, verdict, the five amendments, and the one open (tilted) + fork. +- [`editor.md`](./editor.md) — the editor-experience operation catalog: + every gesture as **gesture → writes → effect → ripple** with stable + `OP-*` ids, the six operation laws (incl. the three sanctioned + state→intent bake moments), and per-op FORK marks. +- [`conformance.md`](./conformance.md) — the model-agnostic test corpus: + metamorphic laws, per-area invariants + edge registries, the executable + merge matrix, and the compatibility checklist (CSS/Figma/SVG/current + engine) with Y / N-deviation / spectrum verdicts. FORK rows are the + finale's probes. +- [`axes.md`](./axes.md) — the decision-space factoring: **Axis 1 = semantic + model** (`anchor` vs `bake` — decide first), **Axis 2 = representation & + mutation protocol** (struct vs sheet, key granularity — tunable after, + bounded by the atom rule). Re-scopes `sheet`; source of harnesses H11/H12. +- [`a/`](./a/) — **the winner's workbench**: the experiment ledger E1–E10 + (each with verdicts and lab tests), the decision register + ([`a/DECISIONS.md`](./a/DECISIONS.md)), the DEC-0 normative rules, the + ship-readiness census ([`a/LIMITS.md`](./a/LIMITS.md)), the peer-compat + matrix, the 114-test Rust lab ([`a/lab/`](./a/lab/)), and the native + interactive spike ([`a/spike-canvas/`](./a/spike-canvas/)). +- [`models/`](./models/) — concrete candidate models, one file each, + harness-scored, best-faith. Files keep letter slots (`a.md`, `b.md`, …); + the names are the working identifiers: + - [`models/a.md`](./models/a.md) — **`anchor`** (the anchored box model): + intent-canonical scalars, per-axis bindings, lens quarantine. Proposed + best fit. + - [`models/b.md`](./models/b.md) — **`sheet`** (the property sheet model): + CSS-faithful flat registry, rulebook conflicts, post-layout transforms. + - [`models/c.md`](./models/c.md) — **`bake`** (the materialized matrix + model): Figma-faithful matrix + state canonicalism, edit-time layout. + - [`models/d.md`](./models/d.md) — **`wire`** (the wired geometry model): + relational archetype — referent-general bindings, dataflow DAG, the WG + Level-4 destination. Priced and deferred; `anchor` grows into it + additively. + +## Relationship to existing docs + +- [`docs/wg/feat-layout/index.md`](../docs/wg/feat-layout/index.md) — the + anchor+flex+grid positioning vision (draft, PR #437). It covers positioning + intent only and is **silent on rotation/transform and their layout + coupling** — that gap is a large part of this catalog. When phase 3 produces + a spec, it graduates into `docs/wg/` under WG doctrine (code-agnostic) and + this directory's evidence links stay behind as the working record. +- [`format/grida.fbs`](../format/grida.fbs) — the current archive draft; its + header rules (unset-vs-default, tables-over-structs, additive evolution) are + binding harnesses on whatever phase 3 encodes (see H9). diff --git a/model-v2/a/.preview/edge.html b/model-v2/a/.preview/edge.html new file mode 100644 index 0000000000..698d2d5ae3 --- /dev/null +++ b/model-v2/a/.preview/edge.html @@ -0,0 +1,4053 @@ +anchor — rotation × layout edge cases + + +
+
+
+ model-v2 · anchor · edge cases — 2026-07-07 +
+

Rotation × layout, at the edges.

+

+ Nine scenes where the model’s laws intersect, + resolved by the actual Rust lab (every frame is engine output, not + a mockup). The question per scene: does it read naturally? The + discontinuity meter is the objective proxy — the biggest single-step + jump of any box through the whole sweep. Two scenes fail it; they are now + findings E-A11/E-A12, not footnotes. The ninth scene sweeps a drag, + not an angle — the cross-zero resize decision (DEC-9), three + policies side by side. +

+
+ +
+ +
+
+ θ = 0° + + +
+ +
+ container + biggest single-step box jump this sweep + + +
+
+
+
+ + diff --git a/model-v2/a/.preview/fork.html b/model-v2/a/.preview/fork.html new file mode 100644 index 0000000000..2c2052e5f1 --- /dev/null +++ b/model-v2/a/.preview/fork.html @@ -0,0 +1,6837 @@ +anchor — DEC-0: the framing fork, in action + + +
+
+
+ model-v2 · anchor · DEC-0 — 2026-07-07 +
+

The framing fork, in action.

+

+ The same seven scenes, the same slider, resolved twice by the same lab: + left = layout-visible rotation (anchor as locked by E1; + Figma’s framing) · right = visual-only rotation (the + CSS framing — rotation as post-layout paint transform). Every frame + is real resolver output; the meters are measured on resolved world bounds. + Left tax: the fill×rotation pops (DEC-1). Right tax: overlap, + frozen containment, and broken Figma parity. Watch the meters. +

+
+ +
+ +
+
+ θ = 0° + + +
+
+
+

+ layout-visible — anchor / Figma (E1) +

+ +
+ sibling overlap 0 px² + container 0 + ink escape 0 px +
+
+
+

visual-only — the CSS framing

+ +
+ sibling overlap 0 px² + container 0 + ink escape 0 px +
+
+
+
+
+
+ + diff --git a/model-v2/a/.preview/free.html b/model-v2/a/.preview/free.html new file mode 100644 index 0000000000..8956352a94 --- /dev/null +++ b/model-v2/a/.preview/free.html @@ -0,0 +1,911 @@ +anchor — free context: the editing experience + + +
+
+
+ model-v2 · anchor · free context — 2026-07-07 +
+

Without layout: the base case, hands on.

+

+ A live free-context document — no layout engine runs here; + resolution is bindings + one rotation scalar, mirrored from the + lab’s free-context rules. Drag bodies to move, the corner dot to + resize, the stick to rotate. Watch two things: the + IR stays SVG-shaped, and every gesture is a + counted set of scalar writes (never a matrix). Then drag the + artboard’s right edge: the green badge is + end-pinned and the amber bar is spanned — they respond + with zero writes, because position is a relation. That is what SVG + cannot say, and it is the on-ramp to layout-as-feature. +

+
+ +
+
+

canvas — free frame, five nodes

+ +
+ click = select · drag = move (2 writes) · corner dot = + resize (2) · stick = rotate (1; group = 3 center-feel) · + right-edge grip = artboard resize (0 writes) +
+
+ select a node — reads come from the resolved tier +
+
+
+

the IR — canonical print, live

+

+      

writes — per gesture, typed

+
no gestures yet
+
+
+ +
+ what this shows + Free context is not a degraded mode — it is the model’s ground + floor. + Pin Start is literally svg x/y; rotation is one scalar with + one pivot rule (box center — so rotate is a 1-field write, no x/y + compensation, and resize-after-rotate never drifts: R-4 commutes, + lab-tested). A group is a named set with a coordinate space; dragging + it is 2 writes on the group, its children untouched. What SVG has no words + for: end / center / span bindings — responsiveness without any + layout engine, which is why “svg-first, layout-as-feature” is a + gradient here, not a mode switch: free (bindings) → constrained + (end/center/span) → flex (a frame feature). The lens (general + transform) never appears in the base case — it exists only when you + import one. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
gesturewritesfields
move2x.offset, y.offset (delta-form; works under any anchor)
rotate (boxed)1rotation — center pivot needs no compensation
rotate (group, center-feel)3rotation, x, y — Figma’s trick over legible scalars
resize (corner)2–4w, h (+ x, y from the top-left corner)
resize across zero2–3|extent|, pin, flip toggle (E-A14; out-and-back = identity)
artboard resize0end/center/span children respond in the resolved tier
move a spanned axistyped errorErr(AxisOwnedBySpan) — the wall is visible, never silent
+
+
+ + diff --git a/model-v2/a/.preview/index.html b/model-v2/a/.preview/index.html new file mode 100644 index 0000000000..0e5ce85e1c --- /dev/null +++ b/model-v2/a/.preview/index.html @@ -0,0 +1,3876 @@ +anchor — visual proof + + +
+
+
+ model-v2 · anchor · proof run — 2026-07-07 +
+

The list makes room.

+

+ Every number and pixel on this page comes from the lab in + model-v2/a/ — the resolver that implements the anchor spec, + the sweeps it ran, and the agents that were tested against it. Three kinds + of proof, in order of strength: +

+ +
+
+
runtime
+
it runs, live
+

+ The E1 demo below is the resolver's actual output, scrubbable. On your + machine: + cargo test → + 56/56. +

+
+
+
image
+
it renders, exactly
+

+ SVG frames emitted by the resolver at key angles, magenta dashes = + computed world AABBs. +

+
+
+
record
+
it is written down
+

+ metrics.csv, truth.txt, prediction files, byte-fixpoint binaries + — every claim re-derivable. +

+
+
+ +
+ LAB 56/56 tests + E1 layout-visible locked + E2 byte-fixpoint + E3 LLM 22/22 ×2 + E4 10k → 5.4 ms + E5 1M transforms +
+
+ +
+
exhibit e1 — the deciding experiment, live
+

Rotate the middle card. Watch both worlds.

+

+ Same document, same resolver, one flag. Top: + anchor — the rotated card + participates by its oriented envelope, siblings make room, the hug + container breathes. Bottom: + CSS control — layout is frozen, + rotation is paint-only, and the red wash is sibling overlap that no + document field expresses. +

+ +
+
+
+
anchor — aabb participates
+ +
+
+
css control — visual-only
+ +
+
+
+ θ = 0° + + + + + +
+
+ anchor container 220.0 px + anchor overlap 0 px² + control overlap 0 px² + rotate gesture 1 write / step +
+
+ +
+
+
Container width vs θ
+
+ anchor breathes 220→276.6 px, peak at + θ*=atan(h/w)≈59°; control flat +
+ +
+
+
Sibling overlap area vs θ
+
+ anchor: zero at every angle · control: up to 1,830 px² +
+ +
+
+
Third card position vs θ
+
+ smooth — max step 3.45 px per 2°, inside the + √(w²+h²)·Δθ = 4.07 px bound +
+ +
+
+
Verdict
+
+ conformance R-3 and editor OP-ROT-2: POL → INV +
+

+ Locked layout-visible. Zero overlap by construction and by + measurement; displacement continuous through + 0°/90°/180°; rotation stays a one-field write. + Breathing during a full spin (56.6 px) is a hug-container phenomenon + and is exactly why motion rotation targets a lens channel — the + two-lane rule, now measured rather than argued. +

+
+
+ +
+
+ + + + anchor: AABB participates — θ=0° + + + + + + + + + + + + + + control: visual-only (CSS) — θ=0° + + + + + + + + + + + + +
θ = 0° — resolver SVG output
+
+
+ + + + anchor: AABB participates — θ=45° + + + + + + + + + + + + + + control: visual-only (CSS) — θ=45° + + + + + + + + + + + + +
+ θ = 45° — envelope 113.1×113.1 +
+
+
+ + + + anchor: AABB participates — θ=90° + + + + + + + + + + + + + + control: visual-only (CSS) — θ=90° + + + + + + + + + + + + +
+ θ = 90° — slot is exactly 100×60 +
+
+
+
+ +
+
exhibit e3 — can a model predict it cold?
+

Two fresh agents, a 150-line grammar, 22/22 exact.

+

+ Three agents were handed only the grammar and six documents — no + tools, no resolver — and asked for every resolved box. The hardest + probe is the E1 rule itself (p5, below). Frontier agents matched the + resolver on all 22 values within 0.5 px. +

+ +
+
<!-- probe p5 — rotated card in a hug row -->
+<frame name="list" w="auto" h="140"
+       layout="flex" gap="10" padding="10"
+       cross="center">
+  <shape name="a" kind="rect" w="60" h="100"/>
+  <shape name="b" kind="rect" w="60" h="100"
+         rotation="90"/>
+  <shape name="c" kind="rect" w="60" h="100"/>
+</frame>
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
p5 valueresolverfable-afable-bhaiku-c
list (hug w)260260260260
b box x,y100, 20100, 20100, 2050, 40
b world AABB80, 40, 100×60exactexactx=30
c (pushed) x190190190190
all 6 probes22 values22/2222/2217/22
+
+
+

+ Even the small-model control applied the rotation-in-flow + rule correctly — its misses were an off-by-one character + count and placing a box at slot-start instead of slot-center. Slips, not + structural confusion. Records: + e3-text-ir/truth.txt · predictions/* +

+
+ +
+
exhibit e2 — the file format
+

Encode → decode → encode: byte-identical.

+

+ The quartet document through flatc 25.12: JSON → binary → JSON + → binary produces the same bytes. And "unset" is structural — + read the decoded output: +

+
+
// grow-text node — decoded JSON
+{
+  "id": 5, // no x, no y keys at all
+  "grow": 1.0  // ⇒ Pin{Start,0} by rule
+}
+// flex frame — height IS auto, structurally
+{
+  "width_type": "SizeFixed",
+  "width": { "value": 400.0 },
+  "height_type": "SizeAuto",
+  "height": { }
+}
+
+
+$ ./run.sh
+S-1 FIXPOINT: byte-identical
+M-4 forward:  v1 binary decodes under v2 schema
+M-4 backward: v2 binary decodes under v1 schema
+ max_lines: 0 ≡ unset — now unrepresentable +

+ One policy owed for phase 3: re-encoding through an old schema + drops unknown fields — read-skip is free, read-modify-write + preservation is not. +

+
+
+
+ +
+
exhibit e4 — the resolver, timed
+

10,000 nodes in 5.4 ms, unoptimized.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
scenenodesfull resolvethroughput
flat canvas (⅐ rotated)10,0015.42 ms1,844 nodes/ms
flex cards — nested hug + text measure (worst case)5,78524.4 ms237 nodes/ms
mixed groups + rotated-in-flow10,0038.21 ms1,218 nodes/ms
one card subtree (locality bound per leaf edit)618 µs
+
+

+ Single-threaded, zero caching, and the lab double-runs layout on hug + containers — this is the floor. Scaling is linear (10× nodes + → 9.6× time in the all-flex worst case). What it replaces: the + 26-arm branch forest, the atan2 transform reconstruction, and + MIN_SIZE_DIRTY_HACK. +

+
+ +
+
exhibit e5 — a million transforms
+

The corpus re-ranked the escape hatch.

+

+ 7,138 SVGs, 1,003,787 transforms scanned. The lens was designed for skew + — but genuine shear is under 1%. The real finding is + flip at 26%: single-axis mirrors must be native header bits, or the + "quarantine" fires on a quarter of wild files. +

+ +
+ translate + 9.1% + rotate 1.3% + scale= 19.8% + scale≠ + 42.8% + flip 26.1% → + goes native + shear 0.95% → + lens +
+
+ +
+
reproduce it
+

Runtime proof, on your machine.

+
+
from the repo root
+
open model-v2/a/e1-rotation-in-flow/demo.html   # the offline twin of the demo above
+cd model-v2/a/lab
+cargo test                        # 56/56 conformance-derived tests
+cargo run --bin e1                # regenerate the sweep, frames, metrics
+cargo run --bin e3 -- truth       # ground truth an agent must match
+cargo run --release --bin e4      # the timings above
+
+

+ Full narrative, amendments E-A1…E-A7, and the honest lose column: + model-v2/a/REPORT.md. + Nothing is committed to git — the workbench is yours to review. +

+
+
+ + diff --git a/model-v2/a/.preview/model.html b/model-v2/a/.preview/model.html new file mode 100644 index 0000000000..312fef8f88 --- /dev/null +++ b/model-v2/a/.preview/model.html @@ -0,0 +1,1017 @@ +anchor — how the model works + + +
+
+
+ model-v2 · anchor · model demo — 2026-07-07 +
+

One box. Content realizes into it.

+

+ The consolidated model in three interactive laws. A node is an + anchored box with typed content: the box comes from intent, content + is realized into the box at render, and nothing derivable is ever + stored. Everything below runs the spec’s own formulas (the same ones + the Rust lab asserts in 58 tests and LLMs predicted cold at 100%). +

+
+ +
+
demo a — the two-axis core
+

One box, four realizations.

+

+ Drag the box size. + The document writes are the same two fields every time (w, h) + — what differs per kind is how content re-realizes: parametric + shapes re-derive, vectors re-map from their reference rect, text re-wraps, + image paint re-fits. No points move, no matrix appears, no bake happens. +

+ +
+
+ + + + + + document writes per drag: + 2 fields +
+
+
+
shape · parametric
+
+ Descriptor = f(box). A star has no points — count 5 + + inner ratio 0.45, evaluated at the box. Stroke stays 2px. +
+ +
stored: kind=star points=5 inner=0.45
+
+
+
vector · mapped
+
+ Points live in a 24×24 reference rect (inset, frozen). + Render maps ×(box/ref). Stroke stays 2px — non-scaling + by construction. +
+ +
+ stored: ref=24×24 + 7 vertices (never rewritten) +
+
+
+
text · flowed
+
+ Content re-wraps at the imposed width (greedy, the spec metric). + Font size stays 14 — a style, not geometry. +
+ +
stored: content + size=14
+
+
+
image fill · fitted
+
+ Paint re-fits at the resolved box per fit mode — never + stretched with geometry. + +
+ +
stored: image ref + fit mode
+
+
+
+
+ +
+
demo b — the points law (e-a9)
+

Resize all you want. The blob stays the blob.

+

+ The table is the stored document data — vertices in the + reference rect. Resize writes w/h and the table never changes (Sketch + stores 0–1 points, Figma ships + normalizedSize ≠ size + nodes — same law). Only a vertex-editing gesture writes + points; then the reference bounds re-derive. +

+ +
+
+
+
+ + + + +
+ +
+
+
+
+ stored document data + FROZEN UNDER RESIZE +
+
+ w: 200   h: 200   ref: + 24×24 +
+ + + + + + +
vertexx (ref)y (ref)
+
+ resolved (read tier, materialized like Figma’s plugin API):
+ vertex #4 → +
+
+
+
+
+ +
+
demo c — the two scales
+

Resize is not scale. And scale is two different things.

+

+ One card (dotted image fill, 2px stroke, radius 10, label 12px), one + slider, three semantics. Watch the document under each: resize + writes two fields and keeps every style in px; K is a sanctioned + bake that multiplies the numbers; lens stores one op and touches no + value — the picture semantics (strokes and tiles magnify), + quarantined. +

+ +
+
+ +
+
+
+
resize
+ +
+
+
+
K · parameter scale (bake)
+ +
+
+
+
lens · picture scale (stored op)
+ +
+
+
+
+ Law 5. Styles are px-stable under resize (non-scaling-stroke by + construction). K bakes numbers through the subtree — Figma + rescale(), Sketch K, Grida parametric_scale, already shipping. A + retained picture-scale exists only as a lens op — the + SVG/Graphics-container semantics, opt-in, never a prerequisite. +
+
+
+
+ + diff --git a/model-v2/a/COMPAT.md b/model-v2/a/COMPAT.md new file mode 100644 index 0000000000..9861b21bd0 --- /dev/null +++ b/model-v2/a/COMPAT.md @@ -0,0 +1,174 @@ +# COMPAT — the import maps (css → grida · svg → grida · figma → grida) + +2026-07-07. The three import sources the model must be able to absorb, +mapped primitive-by-primitive into the anchor vocabulary. This document +is also a **standing design harness (H13)**: any change to the model +must state its effect on these maps; a change that silently moves rows +toward `unsupported` is a regression even if every internal test stays +green. + +**Verdicts** — `native` (direct field map) · `surgery` (tree +restructuring, semantics preserved) · `trick` (non-obvious but exact +construction) · `lens` (quarantine wrap) · `paint` (lands in the paint +model, not node geometry) · `degrade` (approximate, loss declared) · +`unsupported` (flatten/rasterize/drop) · `out-of-scope`. + +Full row data (33 CSS + 31 SVG + 30 Figma rows with per-row edge notes) +was produced by the 2026-07-07 mapping run; the tables below are the +complete verdict census with the sharpest edges inlined. + +--- + +## 1. CSS → grida + +**Posture: the positioned/flex core is nearly the native tongue** — +insets literally are the Pin/Span vocabulary (`right:24` = `Pin{End,24}`, +`left+right` = `Span`) and flexbox maps 1:1 minus three declared holes. +The systematic surgeries: block flow → flex column; margins → +gap/padding/spacers; z-index → fractional-index resort. Anything scroll- +or responsiveness-coupled freezes at the import viewport **or stays live +in the htmlcss engine via the `embed` kind — the sanctioned pressure +valve** (Grida ships a real CSS engine; import-to-canvas is a choice, +not the only door). + +| primitive | verdict | map / sharpest edge | +| ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| position:absolute + insets | **native** | Pin/Span per edge; intent survives resize. Edge: CSS containing block ≠ direct parent → re-parent surgery; auto-inset static positions bake | +| display:flex + direction/wrap | **native** | LayoutBehavior. Edge: no `*-reverse` (index-reverse surgery); no align-content; wrap-reverse unrepresentable | +| justify-content | **native** | main_align 1:1. Edge: safe/left/right collapse | +| align-items/self | **native** | cross_align/self_align. Edge: baseline = DEFER (X-CSS-9), bakes stale offsets | +| flex-grow/basis | **native** | grow + SizeIntent. Edge: `flex:1` ⇒ basis 0 ⇒ write Fixed(0)+grow:1, not Auto | +| flex-shrink | **degrade** | X-CSS-2 deviation (shrink:0); bake shrunk sizes at import viewport | +| gap | **native** | direct. % gap bakes | +| block flow / static | **surgery** | → flex column + stretch; margin collapsing must resolve _before_ conversion | +| margin | **surgery** | gap/padding/spacers; negative margins → pins or lens (visibly wrong if flow relied on displacement) | +| margin:auto | **trick** | Pin{Center}/alignment/grow-spacers | +| padding | **native** | EdgeInsets; on non-frames → wrapper | +| border + box-sizing | **paint** | strokes (border-box by construction); heterogeneous per-side borders degrade | +| position:relative | **lens** | flow slot + lens Translate — exact CSS. Edge: containing-block duty doesn't transfer | +| position:fixed | **surgery** | re-parent to viewport root; scroll immunity gone → embed for live | +| position:sticky | **degrade** | freeze at scroll 0; embed for live | +| display:grid | **degrade** | run grid once, bake cells to Pin/Span (mode:grid is the additive future); fr/minmax responsiveness lost; embed for live | +| transform translate/scale | **lens** | ordered ops, exact CSS paint-only semantics; % translate bakes | +| transform rotate (in flow) | **native** | DEC-0 flipped the default to visual-only: header rotation IS CSS rotate semantics now (lens-rotate ≡ header-rotate; X-CSS-5 fork dissolved) | +| skew/matrix | **lens** | the quarantine's founding purpose (wrap, never degrade) | +| 3D + perspective | **lens** | spec-reserved vocabulary; preserve-3d across elements doesn't compose (each lens flattens) | +| transform-origin | **trick** | Alignment enum or Translate-conjugation (costs the one-op-one-channel story) | +| overflow/clip | **native** | clips_content; scroll/auto degrade → embed for live panes | +| z-index/stacking | **surgery** | resort fractional indices; cross-hierarchy interleaving (negative z, cousins) unrepresentable | +| %-/vw/em units | **degrade** | bake to px. Exception: 100% → Span{0,0}/stretch — the one intent-preserving unit | +| min/max/fit-content | **degrade** | X-CSS-3 DEFER; text Auto covers text only; min-content floor absent | +| aspect-ratio + clamps | **native** | direct. Edge: anchor never overrides Fixed (CSS transferred-size cases diverge) | +| display:none | **native** | active:false (excluded from measure — tested) | +| display:contents | **surgery** | splice children up (group is NOT equivalent — it has a derived box) | +| inline formatting (text+atoms) | **degrade** | pure text → text kind; atom interleaving inside wrapped lines is outside the model | +| float/clear | **degrade** | bake positions; wrap-around needs exclusions we don't have | +| writing-mode / rtl | **unsupported** | X-CSS-7 declared N; BiDi is text-internal only | +| order | **surgery** | rewrite fractional indices; a11y/source order distinction erased | +| tables | **degrade** | bake column widths into nested flex; shared column sizing has no home | + +## 2. SVG → grida + +**Posture: mostly native, with the paint model and usvg pre-processing +absorbing what the node model shouldn't.** E5 already measured +transforms (native 73% / flip 26% → E-A2 / true shear 0.95% → lens). +The remaining edges cluster in four families: **viewBox proportional +scaling** (SVG's scaling-stroke default is _our_ exotic case — the exact +inversion of law 5), **instancing** (`use`/`symbol` = copy-on-import, +divergence declared), **text** (baseline math, per-glyph positioning, +textPath), and **paint servers** (`pattern` has no home yet). + +| primitive | verdict | map / sharpest edge | +| --------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| root `` viewBox + pAR | **trick** | frame Fixed×Fixed; uniform viewBox scale → K-bake at import; `pAR="none"` non-uniform **cannot** K-bake strokes → lens or accept px-stable deviation | +| absolute x/y everywhere | **native** | free frames + Start pins (current importer's model already) | +| `` | **native** | group (derived box, E-A1). Edge: `` re-kinds to frame (effects are ignored-by-rule on group) | +| `` (arcs incl.) | **native** | vector (E-A9 reference rect); arcs pre-baked to cubics by usvg (render-exact, not round-trippable) | +| `` | **native** | shape Rect + corner radius. Edge: rx≠ry elliptical corners have no home; **px-stable radius vs SVG's scaling radius under viewBox** | +| `/` | **native** | Ellipse descriptor. Edge: usvg normalizes to path — importer must pattern-match back or accept vector | +| `` | **trick** | shape/Line box construction (length × Fixed(0), rotation) | +| `/` | **native** | vector or normalized Polygon. Edge: axis-aligned lines → zero-extent ref axis (B2 POL) | +| nested `` | **surgery** | frame + clip + viewport transform (K-baked uniform / lens non-uniform) | +| `+` | **degrade** | copy-on-import (usvg); instancing divergence is the declared loss | +| `` | **surgery** | per-use instantiation; different use sizes ⇒ divergent K-bakes | +| `` / `` | **degrade** | first-passing-branch frozen / link metadata dropped | +| % units | **degrade** | baked by usvg; percent pins (a.md §12) recover this later | +| `` chunks/tspan | **surgery** | shipped model: text-import.md (group of measured text chunks); per-glyph x/y lists flatten | +| text-anchor | **trick** | is literally a Pin anchor (middle→Pin{Center}) | +| baselines | **degrade** | baseline→top conversion needs real ascent (current FIXME approximates ascent=font_size) | +| `` | **unsupported** | flatten to vector via usvg; no lens construction exists | +| markers | **surgery** | usvg bake (lossless render) or native stroke markers on open geometry | +| ``/`` | **surgery** | sibling-mask construction (importer ships it); oBB units freeze against import-time bbox | +| `` | **degrade** | blur/drop-shadow → effects; arbitrary fe-graphs unsupported (rasterize or drop) | +| gradients | **paint** | entirely in the paint model (E5 split); shared userSpace gradients denormalize per node | +| `` | **unsupported** | no Pattern paint yet (wg/feat-svg/pattern.md 'not started'); rasterize-tile fallback | +| opacity vs fill-opacity | **native** | header opacity vs paint opacity — semantically distinct, both mapped | +| paint-order | **trick** | split into stacked siblings (edit-coupling cost) | +| vector-effect: non-scaling-stroke | **native** | **the default, by construction** (law 5) — the inversion: SVG's scaling stroke is the exotic import | +| stroke props | **native** | dasharray/cap/join/miter 1:1; dashoffset currently dropped (importer gap) | +| CSS-in-SVG | **degrade** | cascade flattened at parse; @media frozen to one branch | +| SMIL | **unsupported** | static t=0 imports; animateTransform→lens-channel mapping is a future runtime story (H7) | +| `` | **degrade** | embed kind (format:html) — needs pre-usvg extraction; declared h vs measured-h mismatch | + +## 3. Figma → grida + +**Posture: the owner's "almost 100%, workaround exists" is structurally +right — Figma is the closest cousin (several anchor mechanisms are +Figma's own, measured: normalizedSize = E-A9, parametric stars, px-stable +K/rescale split, layout-visible rotation = X-FIG-1).** The honest +residue: one _model_ gap (SCALE constraint → percent pins, deferred; +prevalence unmeasured until **E9**), one _definition_ fork (bool bounds: +op-result vs Figma's operand-union — constructible divergence), and four +_importer_ gaps that are work, not model problems. + +| primitive | verdict | map / sharpest edge | +| --------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| auto-layout frame | **native** | LayoutBehavior 1:1 (spacing/padding/align). Edge: flex emission is currently opt-in (`prefer_auto_layout`); default imports a position snapshot | +| primary/counter align | **native** | main/cross align (anchor superset). Edge: wrap's SPACE_BETWEEN cross-run distribution has no field; BASELINE falls to start | +| FIXED/HUG sizing | **native** | Fixed/Auto SizeIntent | +| FILL sizing | **trick** | grow:1 / self_align:Stretch / Span{0,0} — the three-spellings map. **Importer gap: not yet encoded (bakes size today)** | +| absolute-in-auto-layout | **native** | flow:Absolute + bindings | +| constraints MIN/CENTER/MAX | **native** | Pin Start/Center/End — intent stored. Edge: pins bind the unrotated box, Figma constrains the rotated AABB (§2.1's recorded v1 refinement) | +| constraint STRETCH | **native** | Span{start,end} — exactly Figma's semantics incl. size-ignored | +| constraint SCALE | **degrade** | no v1 percent pins (a.md §12 additive). Prevalence unknown → **E9** | +| **group constraint-transparency** | **surgery → native under E-A13** | children of GROUPs constrain against the _outer frame_ in Figma; anchor binds to the direct parent + E-A5 forbids non-Start under groups. Faithful import = **promote group→frame** or hoist; loses either grouping or responsiveness → **the GROUP.md fork** | +| rotation | **native** | matrix→scalar (pivot translation compensated; CCW→CW negation) | +| rotated-in-auto-layout | **degrade** | DEC-0 (visual-only): Figma makes room, anchor does not — importer bakes these to `flow:Absolute` + pins at resolved position (geometry-exact, flow participation dropped); frequency = E9 | +| flips | **native** | E-A2 flip_x/y, semantics built (E-A14: pivot per kind, `T·R·F` innermost). **Importer gap: currently bakes mirrors into path data (predates E-A2)** | +| resize across zero (drag past the fixed edge) | **native under DEC-9=flip** | Figma flips every kind (one shared render transform). Anchor `resize_drag` re-targets: extent stays \|w\|, axis flip toggles, pin re-anchors; out-and-back = document identity; typed negative W still rejects (NegativeExtent) | +| plugin-authored skew/matrix | **lens** | wrap-never-degrade; frequency unscanned (E9) | +| GROUP | **native** | derived box + origin placement; X-FIG-4 deliberately N (no re-fit writes) | +| BOOLEAN_OPERATION | **native** | op enum 1:1, operands as children. **Fork: anchor box = op-result (D-5), Figma = operand union — divergent bounds constructible** | +| vector networks | **native** | normalizedSize _is_ E-A9 (fixture-proven). Edge: non-zero blob origins need the reference RECT | +| STAR/POLYGON/ELLIPSE arc | **native** | parametric 1:1 (REST fallback loses parametricity to baked paths) | +| corner radius + smoothing | **native** | field-mapped; smoothing needs the renderer formula | +| strokes | **native** | align/dash/per-side mapped. Edge: `strokesIncludedInLayout` has no home (stroke never consumes layout) | +| effects | **native** | 4 effect types mapped. Edge: effects-on-GROUP is ignored-by-rule in anchor → re-kind to frame on import | +| fills/gradients | **paint** | per-paint transforms. Edge: flip-baked nodes keep fills in unbaked frame (importer bug class) | +| image fills | **paint** | scaleMode→BoxFit/Transform/Tile. Edge: TILE px-anchoring — re-tiles on resize (matches anchor law, differs from naive expectation) | +| text autoResize | **native** | (Auto,Auto)/(Fixed,Auto)/(Fixed,Fixed); TRUNCATE→max_lines+ellipsis. **Importer gap: maxLines parsed but never mapped** | +| masks | **surgery** | maskType 1:1 + sibling reorder (scope-start → sibling-order) | +| SECTION/SLICE | **native** | tray / dropped | +| components/instances | **out-of-scope** | geometry-lossless snapshots; override intent lossy (not this map's concern) | +| layout grids | **unsupported** | dropped; becomes visible when snap-to-grid ships | + +--- + +## Cross-cutting obligations this document creates + +1. **H13 (the harness clause).** Every future model/spec change names + its impact on these three tables. Moving a row _down_ the verdict + ladder requires the same sign-off as breaking a conformance INV. +2. **E8/E9 stay blocking** (pedantic §D): E8 = the _outbound_ CSS + projection (this doc is inbound-only by design); E9 = the .fig corpus + scan that turns "almost 100%" into a number (SCALE prevalence, skew + frequency, rotated-in-auto-layout counts). +3. **Importer work queue surfaced by the mapping run** (work, not model + flaws): Figma FILL→grow/stretch encoding; flips→flip_x/y (post-E-A2); + maxLines mapping; SVG stroke-dashoffset; group→frame re-kinding rules + (``, Figma effects-on-group, constraint-carrying group + children — pending the GROUP.md decision). +4. **The two escape valves are load-bearing**: `embed` (live web islands: + sticky/scroll/grid/media-query content) and `lens` (CSS transforms, + plugin matrices, SVG shear). Neither may regress without this + document noticing. diff --git a/model-v2/a/DECISIONS.md b/model-v2/a/DECISIONS.md new file mode 100644 index 0000000000..eb81c33318 --- /dev/null +++ b/model-v2/a/DECISIONS.md @@ -0,0 +1,322 @@ +# DECISIONS — the open register + +2026-07-07. Every red flag that needs an **owner call** lives here, one +entry each: the question, the evidence on file, the options with a +recommendation, and what the answer unblocks. Everything NOT here is +either already locked by evidence (REPORT amendments E-A1…E-A14) or +adopted-by-evidence below (appendix — veto window, not questions). + +Ids are `DEC-#` (the `D-#` prefix belongs to the derived-kind +conformance suite). Status: **OPEN** until the owner answers; answers +get recorded in place and promoted to amendments/spec text. + +--- + +## DEC-0 · the framing fork: is rotation layout-visible at all? — **DECIDED 2026-07-07 (second lock): VISUAL-ONLY (the CSS framing), CSS-pure sizing** + +**Owner framing, locked after an explicit correction.** The first lock +("keep layout-visible") mis-recorded the owner's sentence — "this model +is much cleaner with the behaviour" referred to the CSS framing in the +fork demo, not the anchor arm. Corrected 2026-07-07: rotation is a +**post-layout paint transform**; sizing (flex contributions, hug, +derived unions) NEVER reads rotation or flips; the read tier +(selection, world AABBs, hit-testing) stays oriented. Sub-answer: +**CSS-pure** — sizing ignores transforms everywhere, no hybrid hug. +Normative rule set + the group-box fork decision: +[`dec0-visual-only.md`](./dec0-visual-only.md). Consequences: DEC-1/2/3 +close as n/a; E-A4/E-A7/E-A11/E-A12 retire; COMPAT's Figma +rotated-in-auto-layout row degrades (importer bakes to absolute); the +lens remains the quarantine for shear/matrix (lens-rotate ≡ +header-rotate behaviorally). The flip was gated on a spec review — run +same day, findings in the rule doc. + +**Question (owner, 2026-07-07).** Adopt the CSS framing as the default — +rotation is a _post-layout paint transform_, the box never grows by +child rotation — trading make-room semantics for clean two-lane +semantics? Sub-questions answered below the table: free context, the +general transform, svg-first. + +**Evidence.** Both arms are implemented and tested (`RotationInFlow`; +R-3 runs both) — this is a policy flip, not rework. **See it in +action:** [`dec0-fork/`](./dec0-fork) — the same seven scenes resolved +under both framings side by side, live overlap/containment meters +(measured at θ=45°: CSS arm — stretch 4,131 px² overlap; hug container +frozen while ink escapes 16.6 px; wrap never reflows, 2,661 px²; anchor +arm — 0 px² everywhere except the deliberate lens lane). E1 measured the +arms against each other: anchor arm **0 px² overlap at every angle**; +CSS arm **1,830 px²** (siblings don't make room). X-FIG-1: Figma is +layout-visible — visual-only silently changes imported geometry for +every rotated child in auto-layout (frequency = **E9**'s counter, +unknown). The tax is documented on BOTH sides: Figma's forum asks to +_stop_ envelope growth (pedantic D3); CSS had to add `writing-mode` as +a whole layout feature because paint-rotation cannot put rotated text +in flow — the one rotated-in-flow case everyone needs. The lens is +already the CSS lane inside anchor +(`lens_rotate_is_the_visual_only_twin` reproduces the CSS arm exactly). + +| option | gain | lose | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **keep layout-visible (E1)** ← recommended | containment truth (0 overlap, measured); Figma import parity; rotated-text-in-flow native (demo scene reads naturally); **both lanes reachable** — the lens IS per-node CSS semantics, one structural op | the whole DEC-1/DEC-2 policy surface; E-A7 readout + D3 escapes launch-blocking; envelope math in M/L | +| flip to visual-only (CSS) | DEC-1/DEC-2/DEC-3 close n/a; fill never fights rotation (no pops, no 45° flip); simpler flex path; the D3 ticket class never happens | measured overlap by construction; Figma parity breaks (E9 quantifies); **no path back** — layout-visible becomes per-node unreachable (wrapping in a hug frame doesn't help: hug would read the unrotated box too); a NEW wart appears — hug/group must still union _oriented_ envelopes or selection chrome lies about ink, so the envelope math stays anyway and "auto size" forks meaning by context | + +**The three sub-answers (recorded so the framing question stays sharp):** + +1. _Free context is not "pre-layout" — it is the base case._ Rotation is + ALWAYS applied in phase T, in every context; the fork is only what + phases M/L **read** (oriented envelope vs unrotated box). A + scene-root rectangle rotating has no L to be pre or post of; its + envelope exists only for reads (selection, hug, union). "Pre/post + layout" is CSS vocabulary assuming flow exists everywhere — that's + the legacy framing, not the free canvas. +2. _The general transform is already settled and does not move._ The + general affine lives ONLY in the lens, and the lens is paint-only in + every context — CSS semantics already. If header rotation also went + visual-only, header-rotate and lens-rotate become redundant + everywhere (E-A8's distinction collapses; one of them turns legacy). + Layout-visible keeps them meaningfully distinct: header = the flow + lane, lens = the paint lane. +3. _svg-first + layout-as-feature is already the model's shape_ — free + context is the default world, `Pin{Start, offset}` IS svg x/y, + layout is a frame payload feature that overrides position. + **Hands-on:** [`free-editing/`](./free-editing) — a live free-context + document (no layout engine runs) with the canonical IR and a + per-gesture typed write log; the artboard-resize gesture shows + end/center/span responding with zero writes. What + svg-first must NOT become is matrix-first **storage**: E5 (99% of a + million wild transforms are structured intent), E3 (LLMs 100% on + scalars, matrices notoriously not), E4 (kills the atan2 + reconstruction forest), CRDT 1-field writes — all measured against + the matrix road. + +**The mental model, one sentence.** A scene is a transform tree with +structured intent (SVG's semantics, not SVG's storage); layout is a +container feature that computes some boxes; rotation is always applied +after the box (phase T); the ONLY open dial is whether box computation +_reads_ the rotated envelope — never (CSS) / always (Figma, anchor +today) / per-node via lens (anchor's two-lane). + +**Relation to the register.** If DEC-0 = visual-only: DEC-1/2/3 close +n/a, E-A4/E-A7/E-A11/E-A12 retire, X-FIG-1 flips to a COMPAT degrade +row. If DEC-0 = keep: the clean-semantics instinct is honored at the +actual mess instead — **DEC-1 = inert-when-rotated + DEC-2 = inert** +gives "fill never fights rotation" (most of the CSS win) while keeping +make-room, containment, and Figma parity. E9's rotated-in-auto-layout +count is the missing number either way — run it before locking. + +## DEC-1 · fill intent × rotation (E-A11 + E-A12) — **CLOSED n/a (DEC-0, 2026-07-07)** + +_Closed by the visual-only lock: the configuration this decision +governed no longer exists — sizing never reads rotation, so fill +never fights it (dec0-visual-only.md V-2/V-4). Body kept as the +record of the road not taken._ + +**Question.** What do `grow` (main axis) and `self_align:Stretch` (cross +axis) do on a **rotated** in-flow child? + +**Evidence.** Today the lab pops: 240 px within 3° on grow, 70 px on +stretch (edge scenes `grow`/`stretch`). Figma verification (2026-07-07, +community-evidenced; officially undocumented): fill **stays stored and +active** on rotated children; the engine fills against the **rotated +envelope** in the parent's unrotated axis space; **which child dimension +the fill drives flips at 45°** (observed snap); at exactly 90° at least +one unresolved bug report (text). The aspect-ratio-hack genre depends on +envelope fill (`length = slot/cosθ`). **No continuous-everywhere policy +exists anywhere** — the choice is where the discontinuity lives. + +| option | discontinuity lives at | notes | +| ----------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **envelope-fill + 45° axis-flip** ← recommended | 45° (same place Figma put it) | Figma-parity class; defined at 90°; no pop at 0°; costs a bounded second per-container solve + conformance rows | +| pure inverse-envelope (always drive main dim) | 90° (driven dim → ∞, clamp is our invention) | continuous elsewhere; diverges from Figma near vertical | +| inert-when-rotated (today's lab rule, promoted) | first degree of the rotate gesture | simplest; single-pass trivially safe; breaks Figma parity + the rotated-spacer import genre; editor should clear-and-offer-restore | +| keep basis + declare pop (E-A4 as-is) | 3° step, 240 px | cheapest; reads worst in the demo | + +**Unblocks.** E-A11/E-A12 spec text, two conformance rows, the +`stretch`/`grow` cells of COMPAT's Figma map. + +## DEC-2 · fill intent on DERIVED kinds (group/bool) — **CLOSED n/a (DEC-0, 2026-07-07)** + +_Closed by the visual-only lock: the configuration this decision +governed no longer exists — sizing never reads rotation, so fill +never fights it (dec0-visual-only.md V-2/V-4). Body kept as the +record of the road not taken._ + +**Question.** A group sits in a flex row with `grow`/`stretch` — today +silently inert. Figma's answer: a stretched group **rescales its +children** (groups resize by scaling). + +| option | notes | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **inert-with-report** ← recommended (v1) | derived boxes never take layout-imposed size; report instead of silence; Figma-like rescale stays an explicit editor gesture (K parametric bake); CRDT-clean, reads-never-write preserved | +| resolved-tier visual scale-to-slot | lens-like render-time scale, nothing stored; xywh reads diverge from ink; hit-testing needs the scaled tier | +| K-bake at gesture time | true Figma semantics but a write fan-out across members; reflow-driven slot changes still need one of the above at resolve time | + +## DEC-3 · rotate-gesture escapes: launch-blocking? — **CLOSED n/a (DEC-0, 2026-07-07)** + +_Closed by the visual-only lock: the configuration this decision +governed no longer exists — sizing never reads rotation, so fill +never fights it (dec0-visual-only.md V-2/V-4). Body kept as the +record of the road not taken._ + +**Question.** Are the D3 escapes — rotate-HUD one-click "keep slot +fixed" (wrap/lens re-target) + the E-A7 envelope readout — **v1 ship +requirements** in editor.md, or fast-follows? + +**Evidence.** Figma's forum record shows this exact behavior class +("prevent rotated image growing the bounding box") as a recurring +ticket generator; E1 measured smoothness, not user reaction. +Recommended: **launch-blocking** — the model's boldest choice ships +with its pressure valve. + +## DEC-4 · text determinism posture (blocker B-1) — OPEN + +**Question.** T-3 promises identical measured text geometry +cross-platform within ε — unsatisfiable with real fonts (a 1-ULP +advance difference flips a line count; no ε absorbs a line-height +jump). Pick the posture. + +| option | notes | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **pin a deterministic shaper** ← recommended | one versioned shaping stack, bit-exact per version; T-3 survives as an INV-per-shaper-version; Figma ships its own text stack for exactly this reason; CRDT/multiplayer + server rendering need stable line counts; cost: we own the pipeline, shaper upgrades become format-versioned events | +| per-platform goldens | T-3 re-scoped to (platform, font stack); cross-platform docs may reflow; cheaper now, divergence surfaces in multiplayer | +| defer, quarantine T-3 | honest but leaves the loudest blocker open; dependent specs cite an unresolved invariant | + +## DEC-5 · coordinate budget (blocker B-2) — OPEN + +**Question.** ULP(1e7) = 1 px, so N-1's "error bounded at ±1e7" cannot +hold in a normative-f32 pipeline. Declare the budget. + +| option | notes | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| **declared f32 budget: ±1e6 @ 0.1 px** ← recommended | matches the Skia/engine reality and Figma's accepted-limited-range posture; N-1 rewritten with the honest numbers | +| f64 resolved tier | doubles resolved-tier memory, wasm-boundary cost; Blink-style 1/64 fixed-point is a third path nobody asked for | + +## DEC-6 · bool bounds fork (blocker B-5) — OPEN + +**Question.** `bool` box source: **op-result** bounds (D-5, correct — +subtract-to-empty reports empty) requires render-grade path booleans +inside Phase M (unbuilt, unbudgeted). **Figma reports operand-union.** + +| option | notes | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| **keep D-5 op-result; bool ships when pathops land** ← recommended | no wrong reads ever shipped; bool stays ⛔ until the engine dependency exists | +| operand-union v1 (Figma parity) | cheap now; xywh lies for subtractive ops; migrating later is a breaking read change | +| two named reads (union now, op-result later additive) | honest but two truths for one box — the exact ambiguity a.md §3 exists to kill | + +## DEC-7 · format RMW posture (E-A6 / pedantic C4) — OPEN + +**Question.** FlatBuffers decode→re-encode through an old schema +verifiably **drops unknown fields** (E2), yet conformance M-4 still +claims INV "preserves unknown content through RMW". + +| option | notes | +| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **version-gated writers + M-4 downgraded to POL** ← recommended | writers must be ≥ the document's schema version or refuse; the declared drop-window is zero; simplest honest contract | +| buffer-retention patching | true RMW preservation; substantial format-layer machinery for a lab-stage format | +| leave M-4 as INV | fails as measured — not an option, listed for completeness | + +## DEC-8 · E8 + E9 sequencing — OPEN + +**Question.** Two dealbreaker-class seams were never measured: **E8** +(transpile the E1 document to HTML/CSS, measure the loss class — the +tenant-site web bill) and **E9** (E5-style scanner over the io-figma +corpus: SCALE constraints, rotated-in-auto-layout counts, +`normalizedSize≠size`, boolean nesting, **negative determinants** — +which also quantifies DEC-9's "how often do designs carry mirrors"). + +Recommended: **run both before the phase-3 rewrite** — E9 is cheap +(harness exists), E8 is a day-scale experiment; both feed lock +conditions the rewrite must cite. + +## DEC-10 · the lens's NAME (taxonomy, not spec) — OPEN + +**Question (owner, 2026-07-07).** Doubts about the name `lens`: should +`group` absorb the lens's job, or should it be renamed (`tgroup` / +"transform group")? + +**The genus taxonomy** (why the SPLIT stays regardless of the name): +`frame` OWNS space · `group` IS a set (nothing else — GROUP.md; its +powers: transparent-select, E-A13 pass-through, 3-scalar ungroup bake, +all depend on refusing ops) · `lens` is a VIEW (derived box, ordered +paint ops, quarantine-opaque). A group-with-optional-ops is a two-thing +kind — every consumer forks on "has ops?" and the name gates nothing. +Figma corroborates: they shipped **TransformGroupNode as a separate +node**, not by growing GroupNode. + +**Post-DEC-0 axis check:** "transform group" names the WRONG axis now — +plain groups also transform (header rotation/flip, paint-only). The +lens's real distinction is _structured scalars vs ordered arbitrary +ops_ (shear/matrix/retained-scale/3D-reserved) — the sub-1% quarantine +(E5), rarer still since E-A8 inverted (lens-only-rotate always +redundant). + +| candidate | commits to | verdict | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `lens` (incumbent) | a view: non-destructive, removable, layout-transparent — the metaphor carries the load-bearing semantics; unique, greppable, terse-uniqueness holds | jargon; must be taught once | +| `tgroup` / transform group | group genus — imports FALSE expectations (pass-through? transparent-select? ungroup?); wrong axis post-DEC-0 | fails "refuses the wrong content" | +| `transform` | instantly readable but maximally overloaded — every node colloquially "has a transform"; grep pollution; gates nothing | fails uniqueness | + +**Recommendation:** keep `lens` as the FORMAT name (public commitment, +expensive to change after `.fbs` ships); the UI DISPLAY label is a +separate cheap decision (naming doctrine: public names and surface +labels may diverge — Figma's kiwi names ≠ UI names) and can say +"Transform" if user-testing prefers it. If doubt persists at phase 3, +run the cheap empirical probe: A/B the kind name in the E3 grammar +with cold LLMs and measure misuse (do they expect ungroup? try to give +it layout?). Merging into group is rejected under any name. + +## DEC-9 · resize across zero: wall / slide / flip — OPEN (evidence built) + +**Question.** Drag a resize handle past the fixed edge. The current +model walls (typed reject). Figma flips — uniformly, every node kind, +frames and text included, because it all shares one render transform. +Owner lean (2026-07-07): "actually flip, like SVG — the inner paint +mirrors too — is more correct in 2D graphics; happens quite often, not +critically often." + +**Evidence built today** (the E-A14 arm): `flip_x`/`flip_y` are live in +the lab; the `resize_drag` op re-targets across zero (|extent| + flip +toggle + re-pin; 2–3 writes; **out-and-back = document identity**, +lab-tested); the typed `set_width(−50)` stays a wall +(`NegativeExtent`); flip is layout-invisible for boxed kinds by +construction. 11 tests green ([`lab/tests/flip.rs`](./lab/tests/flip.rs)); +three-policy interactive demo: [`edge-cases/`](./edge-cases) scene +"resize across zero". + +| option | notes | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **flip (true mirror, uniform)** ← recommended, matches owner lean | Figma-parity ("same rt"): paint, children, text, image fills all mirror; the only arm where directional content keeps tracking the hand; already implemented + tested | +| slide (re-pin, never mirror) | rect tracks the hand but arrows/image fills/glyphs stay right-way-round — reads broken mid-gesture on any asymmetric content | +| wall (reject at zero) | the pre-decision model; the hand keeps moving, the box doesn't follow | + +**Sub-decision (only if flip):** uniform mirror includes TEXT (glyphs +render reversed — Figma does this; some tools exempt text). Recommended: +**uniform** — exemptions break "one render transform" and make ungroup +bakes kind-dependent. Import note: mirrored `.fig`/SVG content maps to +the flip bits natively (E-A2); E9 will count how often. + +--- + +## Appendix · adopted-by-evidence (veto window, not questions) + +Recorded as decided because peers agree and/or a test now enforces it — +say the word to reopen any of them: + +1. **B-3 uniform clamp-then-re-measure** — min/max clamps re-measure in + both free and flex contexts (both studied peers re-measure). +2. **Law 9 free-context qualifier** — "never moves siblings" holds in + free context; in flow a union change legitimately reflows (Figma + agrees). +3. **min/max clamps bound the box, never the envelope.** +4. **Flip pivot per kind = rotation's rule** (pedantic B1) — center for + boxed/measured, own origin for derived. Now lab-tested (F-3). +5. **Flip composes innermost** (`T·R·F`) — declared + tested (F-2). +6. **Winding reads the stored rotation scalar** — it _is_ intent + (resolves the law-7 / R-E2 / H5 three-way). +7. **−0.0 canonicalized at the write boundary** (R-E3, tested). +8. **NegativeExtent is a typed op error** — extents non-negative stays + bedrock; only the drag gesture re-targets across zero (E-A14). +9. **Declare-only punch list** (resolver reports, no semantics change): + empty-derived placeholder-at-pins; hidden children skipped from + union (MM-6 written); NaN guard over every lens-op scalar; 3D lens + ops = render-skip + preserve; giant-word ink-bounds ⊃ box declared + for hit/cull; empty group spends two gaps in flex (CSS agrees). diff --git a/model-v2/a/GROUP.md b/model-v2/a/GROUP.md new file mode 100644 index 0000000000..9bdbfa4fb2 --- /dev/null +++ b/model-v2/a/GROUP.md @@ -0,0 +1,150 @@ +# GROUP — what a group _is_ + +2026-07-07. The owner's framing: groups are needed in a graphics tool, +no questions asked — but every system means something different by one. +SVG's `` is a transform + a style-inheritance scope; HTML has no +group at all (a div is a real box that always contributes to layout); +Figma's group "only holds" — and its children's constraints famously +bind to the **non-group parent**. This chapter fixes the definition for +anchor, documents what is already decided and tested, and resolves the +one open fork with verified peer semantics (research run 2026-07-07, +primary sources; quotes below). + +## The definition + +> **A group is a named set with a coordinate space — nothing else.** +> It has no box of its own (derived union), no style, no layout opinion. +> Everything a group appears to "have" is either carried in the uniform +> header (opacity, blend, rotation, placement of its origin) or derived +> from its members. + +## The peer table + +| | box | children's coords | constraints ref | AL/layout pass-through | style inheritance | transform unit | +| ------------------ | ---------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------- | +| SVG `` | none (bbox derived) | group space | n/a | n/a | **yes — fill/stroke cascade** | transform attr | +| HTML div | **real box** | parent space | n/a | it IS layout | CSS cascade | transform (paint-only) | +| Figma group | derived, re-fits (`resizeToFit`) | **stored** group-relative; **presented** frame-relative (plugin API skips groups) | **nearest FRAME — skips groups** (verified) | **no** — layoutAlign/Grow inert on group children; the GROUP is the flex item and carries FIXED/FILL itself (verified) | no | stored on the group-frame; re-fit writes compensate | +| Sketch classic | derived | group space | **the group** (opposite of Figma) | n/a | no | frame + rotation | +| Sketch Athens | derived | _frame_-relative (groups skipped, Figma-like) | frame pins | via Frames | no | | +| **anchor `group`** | derived union of oriented children (D-1) | group space | direct parent today → **E-A13 proposal: nearest non-derived ancestor** | **no** (= Figma, verified parity); the group carries `grow`/`self_align` itself | **no — rejected by construction** (empty payload) | 3 scalars (rotation + origin), never child writes | + +Key verified quotes (Plugin API / help center / best-practices): + +- _"Constraints of this node relative to its containing FrameNode … + Group and BooleanOperation nodes do not have a constraint property + themselves. Instead, resizing a frame applies the constraints on the + children of those nodes."_ +- _"Constraints applied to elements will always be relative to the + closest parent frame — not relative to the bounds of the group."_ +- _"layoutAlign … applicable only on direct children of auto-layout + frames"_ — group children do **not** negotiate with the outer frame; + the user's recollection is half-right: **constraints pass through; + auto-layout child properties do not** (the group itself is the item). +- _"It is **not** relative to its direct parent if the parent is a + group"_ — Figma **stores** group-relative (kiwi: groups are FRAME + nodes, `resizeToFit: true`) and **materializes** frame-relative in the + plugin API. The same stored-vs-presented split as `normalizedSize` + (E7) — Figma keeps validating our reads-materialize doctrine. +- _"a group's position and size will change if you change its content"_ + — Figma pays **re-fit compensation writes** on every child edit + (X-FIG-4), the exact behavior anchor's derived-origin design exists + to avoid (D-3: child edits write nothing to the group — lab-tested). + +## Decided and tested (the closed half) + +| decision | rule | evidence | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| Box | derived = union of **oriented** children; op-result for `bool` | D-1 ✅, `group_union_of_rotated_members` ✅ | +| Placement | bindings place the **origin**, never the union (E-A1) | D-2 ✅ `d2_sibling_stability…`, depth-3 ✅ (census fix) | +| Child edits | never write the group — reported box changes, siblings don't move (free ctx) | D-3 ✅; law 9 with the census's in-flow qualifier | +| Rotation | one scalar, origin pivot; center-feel gesture = 3 writes; ungroup bakes per-kind | ✅ `group_origin_pivot…`, `ungroup_nested_group…` (census fix) | +| Style inheritance | **rejected** — group payload is empty; no fill/stroke can exist on a group, so nothing can cascade (the SVG `` behavior is structurally impossible, which is the strongest form of "not what we want") | model shape (a.md §3) | +| Compositing | group opacity/blend live in the header (isolate-then-fade); **effects ignored-by-rule** — a Figma group with effects re-kinds to frame at import | §8; COMPAT figma row | +| In flow | the group is one flex item via its derived AABB (rotated per E1) | D-6 ✅ `d6_group_in_flex…`, edge demo `group` scene | +| Empty / hidden | empty union = zero rect at the placed origin; hidden children skipped | ✅ census tests ×2 | +| SVG `` import | → group natively; `` re-kinds to frame | COMPAT svg row | + +## The fork, resolved: constraint transparency (E-A13, proposal) + +**Problem.** Anchor today: bindings resolve against the _direct_ parent, +and E-A5 makes End/Center/Span error-by-rule under a derived parent — +so a right-pinned icon inside a group **loses its responsive intent**. +Figma: constraints skip groups to the nearest frame. Faithful import +currently requires group→frame promotion (COMPAT surgery row), trading +away either grouping or responsiveness. + +**Why Figma's answer looked cyclic for us — and isn't.** The naive +pass-through reading ("resolve the child against the outer frame, then +derive the group") threatens a cycle: child position → union → group +box → child position. Figma escapes by _writing_ re-fits back into the +stored group-frame (compensation writes). **Anchor escapes by E-A1**: +the group's origin is _stored intent_ (Start-pin scalars), independent +of the union. So pass-through resolution is single-pass: + +``` +E = extent of the nearest non-derived ancestor A (known, top-down) +x_in_A = binding table against E (Pin{End,o} → E − o − w) +x_in_grp = x_in_A − Σ origins of the derived chain (stored scalars — known) +union = derived afterward, as always +``` + +No re-fit writes, no cycle, no CRDT cost — the derived-origin rule we +adopted for sibling stability turns out to be the _enabling_ condition +for Figma-parity constraint transparency. (This is the run's second +case of one decision paying for another; the first was rotation's +center pivot making Pin{Center} correction-free.) + +**E-A13 (for the phase-3 spec):** + +1. Binding **reference** = the nearest non-derived ancestor's box. + Under a derived parent, non-Start pins resolve through the chain as + above — E-A5's error-by-rule shrinks to the truly underdetermined + cases (derived chain under an Auto-hug axis, and free-standing + groups with no extent-bearing ancestor… which the regularized root + guarantees cannot happen). +2. **v1 restriction:** the derived chain must be unrotated and + unflipped for non-Start pins (mixing a rotated group space with the + ancestor's axes has no honest single answer — Figma is equally + undefined there). Violation = error-by-rule with report, exactly the + E-A5 mechanism, narrowed. +3. **Auto-layout properties do NOT pass through** — verified Figma + parity. `grow`/`self_align` on group children under an outer flex + are inert (§8 gains the explicit cell); the group itself carries + them (with the census caveat that grow/stretch on a derived box + needs its declared rule — the Figma answer is rescale-children, + which for anchor is a K-bake, kept op-layer). +4. **COMPAT upgrade:** the figma "group constraint-transparency" row + moves surgery → native; the group→frame promotion remains only for + effects-carrying and rotated-constrained groups. +5. Conformance: new G-rows (pin-through-group resolution table; + chain-rotated → report) + a D-row (pass-through preserves D-2 + sibling stability — it does, by construction, but it must be locked + by test). + +**Costs, honestly:** resolution must walk the derived chain (bounded, +cheap); the §8 matrix gains a context column ("under derived parent"); +the text IR grammar needs one sentence; and the group-resize _gesture_ +(user drags a group handle) remains what it is everywhere — an op-layer +fan-out (Figma: constraint-apply; Sketch K: scale; anchor: per-child +bake with declared write-set), never a stored mode. + +## The lens corroboration + +Figma is beta-testing **`TransformGroupNode`** — "a group with +`transformModifiers` that transforms its children." That is a +_non-transparent_ group: exactly anchor's `lens`, invented independently +by the peer with the largest corpus. The group/lens split (transparent +set vs transforming quarantine) is not our idiosyncrasy; it is where +the industry is heading. Worth tracking for import (a TransformGroup +imports as a lens, trivially). + +## Residual opens + +- Rotated-chain pass-through (declared error in v1; a future answer + needs a "constraints in rotated spaces" doctrine nobody has). +- `bool` inherits all group placement rules but its operand semantics + and Phase-M path-ops dependency stay in LIMITS §B-5. +- The group-resize gesture write-set (op-layer, editor.md row owed — + Figma's `resize()` vs `resizeWithoutConstraints()` split is the + precedent to mirror). diff --git a/model-v2/a/LIMITS.md b/model-v2/a/LIMITS.md new file mode 100644 index 0000000000..1feccf5889 --- /dev/null +++ b/model-v2/a/LIMITS.md @@ -0,0 +1,141 @@ +# LIMITS — the ship-readiness census + +2026-07-07. Every kind × mechanism either has a passing test, a declared +rule, or a **named** limitation — no silent unknowns. Sources: the lab +suite (**92 tests** after this census + the E-A14 flip arm), the edge demo sweeps, the +three-domain enumeration run (48 structured edges), and the pedantic +review. Legend: ✅ proven (lab test) · 🎬 demo'd (edge-cases page) · +📜 declared, untested · 🔶 SPEC-SILENT (policy owed) · ⛔ known +limitation (not built / can't hold as stated). + +> **DEC-0 second lock (2026-07-07): the default is VISUAL-ONLY** — +> in-flow rotation is paint; sizing is CSS-pure +> ([`dec0-visual-only.md`](./dec0-visual-only.md)). The rotation/flow +> punch-list items below (E-A11/E-A12, grow/stretch-on-derived, law-9 +> qualifier) are CLOSED n/a under the new default; they remain accurate +> for the documented alternative arm. + +## The kind × mechanism matrix + +| kind | box | realization | rotation | flip | in flow | as parent | quarantine | notes | +| ------ | ------------- | --------------------------------- | ----------------- | -------------------------------- | -------------- | ----------- | ---------- | ------------------------------- | +| frame | ✅ | ✅ children/flex | ✅ rigid (🎬) | 📜 (boxed path, F-1 class) | ✅ | ✅ | — | | +| shape | ✅ | ✅ parametric | ✅ | ✅ (F-1/2/4 + 🎬) | ✅ | n/a | — | rect/ellipse/line only in lab | +| text | ✅ | ✅ flowed (lab metric) | ✅ (🎬) | 📜 mirrors uniformly (DEC-9 sub) | ✅ | n/a | — | real shaping = the big ⛔ below | +| vector | ⛔ not in lab | 📜 mapped (E-A9, write side open) | 📜 | 📜 flip bits, points untouched | 📜 | n/a | — | B2 lock set before phase 3 | +| image | ⛔ not in lab | 📜 fitted (engine ships BoxFit) | 📜 | 📜 paint mirrors (DEC-9) | 📜 | n/a | — | low risk: paint seam exists | +| embed | ⛔ not in lab | 📜 flowed | 📜 | 🔶 | 📜 | n/a | — | the live-web valve (COMPAT) | +| group | ✅ | ✅ union (oriented) | ✅ origin pivot | ✅ origin pivot (F-3/F-6) | ✅ (🎬) | ✅ | — | transparency fork → GROUP.md | +| bool | ⛔ not in lab | ⛔ op-result box unproven | 🔶 pivot unstated | ⛔ | 🔶 | 🔶 operands | — | needs path ops in Phase M | +| lens | ✅ | ✅ ops (2D) | ✅ | 🔶 order vs ops | ✅ pre-ops box | ✅ | ✅ | 3D ops = encoding-reserved only | +| tray | ⛔ not in lab | 📜 (≈frame, no clip/layout) | 📜 | ⛔ | 📜 | 📜 | — | root-treatment open (a.md §12) | + +## Fixed during this census (bugs found by construction, now regression-locked) + +1. **Span-fill text never re-wrapped** — `Span{0,0}` (the canonical fill) + ignored as a wrap constraint → single-line fill text. Fixed in the + resolver; `span_fill_text_rewraps` ✅. +2. **Nested derived unions swallowed the inner union offset** — a + group-in-group's content landed 20px off (D-E2 violation). Fixed; + `nested_group_union_offset_exact` ✅. +3. **Hug used the wrong pivot for rotated derived children** + (center-concentric shortcut vs origin pivot). Fixed — hug now + transforms each child's true local AABB; `hug_wraps_rotated_group_exactly` ✅. +4. **Ungroup bake was wrong for derived children** (center formula on an + origin-pivot kind). Fixed with a per-kind bake; + `ungroup_nested_group_preserves_world` ✅. +5. **−0.0 rotation passed the R-E3 guard** — now canonicalized at the + boundary; `negative_zero_rotation_canonicalized` ✅. + +### Closed after the census — the flip arm (E-A14, 2026-07-07) + +The owner-raised cross-zero red flag forced flip from "decided, zero +code" to built: pivot-per-kind (B1) tested, `T·R·F` innermost declared + +- tested, layout-invisibility proven, `resize_drag` re-target with + out-and-back identity, `NegativeExtent` typed wall (a **missing guard + found**: `set_width(−50)` was silently accepted before), flip-aware + ungroup bake (σ conjugation), IR round-trip. 11 tests in + [`lab/tests/flip.rs`](./lab/tests/flip.rs); the wall/slide/flip gesture + policy is [`DECISIONS.md`](./DECISIONS.md) **DEC-9**. + +## The blockers (decide-before-ship; none is quietly deferrable) + +- **B-1 · Text determinism vs real shaping.** T-3 (INV: identical + measured geometry cross-platform within ε) is **unsatisfiable under + tolerance semantics** once real fonts arrive: wrap decisions are + discrete — a 1-ULP advance difference flips a line count and jumps + geometry by a full line-height no ε can absorb. Figma ships its own + text stack for exactly this reason. Decision owed: pin a deterministic + shaper (bit-exact policy) or re-scope T-3 to per-platform goldens. + The lab's exact metric made E1–E4 evidence structurally blind to this. +- **B-2 · f32 at far-canvas.** ULP(1e7) = 1.0px, so N-1's "error bounded + at ±1e7" cannot hold in a normative-f32 pipeline (Blink uses 1/64 + fixed-point; Figma accepts limited range). Decision owed: declared + coordinate budget (e.g. ±1e6 @ 0.1px) or f64 resolved tier. +- **B-3 · min/max × measured re-wrap.** Free context clamps _without_ + re-measure (spec-faithful, peer-less: ⅔ of ink can leave the box); + flex context re-measures via the layout engine. One node, two heights + by parent mode. Both peers re-measure. Decision owed: clamp-then- + re-measure as the uniform rule (amend "clamp last"). +- **B-4 · E-A9 write side** (pedantic B2, unchanged): stationarity, + ref-rect intent status, zero-extent axes, bounds operator ("network + bounds" = vertex hull vs curve extrema vs control hull — undefined; + SVG getBBox says tight-geometry, control points excluded). +- **B-5 · bool in Phase M.** Op-result bounds require render-grade path + booleans inside _measure_ — an engine-architecture dependency (pathops + in the resolver), unbuilt and unbudgeted. Plus the COMPAT fork: + Figma reports operand-union bounds, D-5 says op-result. + +## Policy owed (🔶 SPEC-SILENT, enumerated — the phase-3 punch list) + +Rotation/flow: rotated×stretch & re-measure (E-A12) · grow×rotation +continuity (E-A11) · grow/stretch on derived kinds (silently inert +today; Figma's answer is rescale-children = K-bake) · law 9's +"never moves siblings" needs the free-context qualifier (in flow, a +union change legitimately reflows — Figma agrees) · min/max clamps +bound the box, never the envelope (declare). +Text: vertical alignment of clipped/overflowing content (T-E2 cites +math that doesn't exist) · auto-width basis in definite flex +(wraps-at-available vs Chromium's max-content — same deviation family +as the Taffy guards) · grow on the measured axis (spec example (c) is +currently a no-op and its annotation is wrong — fix the example) · +`max_lines: 0` semantics + max_lines→natural-size rule · font_size +domain (reject ≤0) · whitespace-only non-monotonicity · giant-word ink +escapes the world AABB (hit/cull miss — declare ink-bounds ⊃ box). +Transform: ~~flip×rotation composition order~~ (CLOSED E-A14: `T·R·F`, +innermost, tested) · flip on text (Figma mirrors; DEC-9 sub-decision, +uniform recommended) · flip on lens (outside-ops vs inside-ops) · +negative-determinant lens Matrix += second home for mirror (extend the E-A8 lint) · winding read source +(rotation reads the stored scalar — it _is_ intent; resolves the law-7 / +R-E2 / H5 three-way) · "spec-reserved" 3D ops need a declared v1 +runtime posture (render-skip + preserve ≠ silent drop) · singular +resolved matrices (CSS: not displayed — adopt?) · resolved-tier +overflow (finite doc → Inf via 2e38 sizes; N-2 guards writes only — +adopt Blink-style saturation?) · lens op origin's reference rect + +nested lens pre/post visibility · lens read tier (xywh = pre-ops box; +declare the post-ops envelope as a second named read) · NaN via lens op +params (op-layer guard must cover every op scalar). +Derived: empty-derived placement (box snaps to origin — declare +placeholder-at-pins) · empty group in flex spends two gaps (CSS agrees; +declare) · MM-6's "declared policy" for hidden children of derived +parents (write it: skipped from union). + +## Not built (the honest ⛔ ledger) + +`bool` (needs pathops), `vector` (E-A9 lock first), `image`/`embed`/ +`tray` payloads, ~~`flip_x/flip_y`~~ (BUILT — E-A14, 11 tests; only the +gesture policy DEC-9 is open), 3D lens ops (encoding reserved only), grid mode, +attributed text runs, `max_lines`/`ellipsis` fields, percent pins, +anchor-to-node (`wire`), incremental invalidator, real text shaper, +stable IR ids. Each is _named_ in a.md §12, the REPORT lose column, or +this census — nothing in this list is silent. + +## Cost note (found by the census, engine-relevant) + +The lab's hug-chain measurement is exponential in nesting depth +(2^depth re-measures — each hug parent re-enters children twice) and +duplicates resolver reports on the re-entry. Fine for the lab's scenes; +phase 4's single-tree layout removes it structurally. Recorded so the +per-container architecture is never shipped as-is. diff --git a/model-v2/a/MODEL.md b/model-v2/a/MODEL.md new file mode 100644 index 0000000000..7bbfa739b6 --- /dev/null +++ b/model-v2/a/MODEL.md @@ -0,0 +1,170 @@ +# The anchor geometry model — consolidated statement + +Status: **proposal, post-experiments, pedantic-reviewed** (2026-07-07). +The model as it stands after E1–E7. Carries the _load-bearing_ +amendments inline (E-A1/2/3/9/10 and, by reference, E-A5); the full +amendment set and the review's lock conditions live in +[`REPORT.md`](./REPORT.md) and [`pedantic-review.md`](./pedantic-review.md). +Seed of the phase-3 normative rewrite; a.md remains the detailed draft +it supersedes clause by clause. + +## One sentence + +A node is an **anchored box with typed content**: the box comes from +intent, content is _realized into_ the box at render, and nothing +derivable is ever stored. + +## The two-axis core + +The E7 study crystallized what the kind table was reaching for. Every +kind answers two **independent** questions: + +1. **Where does the box come from?** — `declared` (intent) · + `measured` (content, one-way) · `derived` (children union) +2. **How does content fill the box?** — realization, at render, never + written back + +| kind | box | content realization | +| -------- | --------------------------------------------------------------- | ------------------------------------------------------------------ | +| `frame` | declared (Auto = hug) | children — bindings or flex | +| `shape` | declared | **parametric**: descriptor = f(box); no points exist | +| `vector` | declared (Auto = reference bounds) | **mapped**: points in a reference rect, scaled `box/ref` at render | +| `text` | measured under intent constraints | **flowed**: re-wraps at the imposed box | +| `image` | declared | **fitted**: paint re-covers per fit mode | +| `embed` | declared w × measured h | flowed | +| `group` | derived (union of oriented children) | children in origin space | +| `bool` | derived (**op-result** bounds — D-5; subtract-to-empty ⇒ empty) | operands in origin space | +| `lens` | derived | children ∘ ops — **paint only** | + +Why this shape is right, per the evidence: + +- _Parametric_ is Figma's own answer for primitives (star = count + + ratio; corner radius px-stable under non-uniform resize) and our + engine's current answer (`to_shape()` from size; fbs + `CanonicalLayerShape` size-free). XYWH-vs-shape dissolves: the box is + the shape's _input_, so layout and shape definition cannot fight. +- _Mapped_ is the Sketch/Figma/SVG convergence (normalized 0–1 points / + `normalizedSize` blob / viewBox): **resize never rewrites points** — + the blob stays the blob. Reference is a **rect** (observed non-zero + blob origins), not a bare size. Our io-figma already implements the + read side. +- _Flowed_ and _fitted_ are the standard measure-function and BoxFit + seams, already shipping. + +## The laws + +1. **Intent only in the file.** Derivable ⇒ not encodable. The resolved + tier (boxes, matrices, bounds, materialized points, baked outlines) + is engine output. (Figma's file, notably, agrees on the intent side — + its normalized blob — while also shipping the bake; we ship only + intent.) +2. **No stored matrices outside `lens`.** Rotation is one scalar + (degrees), flips are two booleans (E-A2, corpus-forced), pivot is the + box center for boxed/measured kinds and the own-origin for derived + kinds. +3. **The box is the only negotiation surface with layout.** Realization + never feeds back into layout; measurement feeds forward only. + Acyclicity is _guarded_, not free: E-A5 makes End/Center/Span + bindings error-by-rule under parents with no resolvable extent + (derived boxes, Auto-hug axes) — remove that rule and Span ← hug ← + children is a real cycle. Locality: a leaf edit re-resolves up the + measure chain to the nearest fixed-extent ancestor, then its subtree + (18 µs per card subtree measured _under clean parent boxes_; + incremental invalidation itself is phase-4 work). +4. **Rotation is a post-layout paint transform** (DEC-0 second lock, + owner framing — supersedes the E1 default): sizing (flex + contributions, hug, derived unions) never reads rotation or flips; + the read tier (world AABBs, selection, hit) stays oriented. The + normative rule set incl. the V-4 group-box fork: + [`a/dec0-visual-only.md`](./a/dec0-visual-only.md). The E1 arm + (oriented-AABB participation) remains implemented and tested as the + documented alternative. + +5. **Styles are px-stable under resize** — strokes, radii, font sizes, + effects never scale with the box (non-scaling-stroke by + construction; every studied tool refuses SVG's default here). + The **two scales** are distinct operations, never a stored mode: + - **K / parameter scale** — an op-layer _bake_: multiplies style + scalars + sizes through the subtree (Grida already ships + `parametric_scale`; = Figma `rescale()`, Sketch K). + - **picture scale** — a stored `lens scale()` op: everything scales + at render, strokes and image fills included; the quarantined + SVG/Framer-Graphic semantics. No `` container is ever + _required_ — the default world is the layout world; the + proportional world is the opt-in lens. +6. **Points law** (E-A9): vector vertices live in the reference rect and + are written **only by vertex-editing gestures**. Resize, layout + stretch, grow — all are box writes; the mapping happens at render. + (Retires the current editor's per-resize vertex bake.) _Read side + research-grounded; the write side is **open until locked**: the ref + rect is free intent (exempt from law 1, like Figma's normalizedSize); + no renormalization as a gesture side effect; vertex-edit = an atomic + declared write-set with a stationarity guarantee; zero-extent ref + axis maps translation-only. See pedantic-review.md §B2._ +7. **Reads materialize, writes re-target.** `x/y/w/h/rotation` and + vertex coordinates always read from the resolved tier in real + coordinates (as Figma's Plugin API does over its normalized storage); + writes re-target the stored intent in delta form. The **text IR + materializes** — an agent always sees and writes real numbers, and + predicted geometry cold at 22/22 (E3 — n=3 models over 6 documents; + the probe protocol, not the score, is what phase 3 inherits: re-run + per grammar change). _Read half proven; the IR write half is open + pending stable ids (an agent editing-in-place has only positional + identity today)._ +8. **Strict states; writes in two declared regimes.** Invalid documents + are unrepresentable (unions, nullable boxes, no sentinels — + E2-proven encodable and byte-fixpoint). Writes are either + **re-targeted** (the coerce set: e.g. an x-write on an End pin + rewrites the offset; always reported, never silent) or **rejected + with a typed error** (the M-2 locked list: AxisOwnedBySpan, + OwnedByLayout, BoxDerived, InvalidNumber — a rejected op leaves the + document byte-identical, M-6). Which writes belong to which set is + enumerated spec, not implementer choice. +9. **Derived boxes: bindings place the origin, never the union** + (E-A1) — child edits change a group's _reported_ box but never move + siblings in world space. Reported box = origin + union. + +## Resolution — one pure function + +`resolve(document, fonts, resources, viewport) → Resolved`, four phases: +**measure** (bottom-up naturals) → **layout** (flex over contributions / +bindings; contributions abstract over rotation AABBs, union boxes, +mapped bounds — the accidental plugin seam of E6) → **transform** +(`from_box_center`; origin pivot for derived; lens ops appended) → +**bounds** (oriented corners → world AABBs). Measured floor: 10k nodes +in 5.4 ms unoptimized, linear; subtree resolve cost bounded at 18 µs +(clean-parent precondition — see law 3); the incremental invalidator +itself is phase-4 work. + +## The lifecycle, concretely + +A flex card list; a card holds an icon (`vector`) and a title (`text`): + +- **stretch the card** (cross-axis fill): one intent state + (`self_align: stretch`); at render the icon's points re-map to the + new box, the title re-wraps, the image fill re-covers. Document + writes: **zero** (layout did it). +- **resize the icon by hand**: two field writes (w, h). Points + untouched. +- **rotate the card**: one field write; paint-only — the list does NOT reflow (DEC-0), measured + smooth; a spinning-motion variant targets a lens channel instead. +- **K-scale the card**: sanctioned bake — sizes, radii, stroke widths, + font size multiply; still no matrix anywhere. +- **edit an icon vertex**: vertex write in reference space; the icon's + Auto box re-derives; bindings re-resolve. Sibling cards never move. + +## What this retires / what stays open + +Retired: per-node transforms, position enums, per-resize vertex bakes, +`MIN_SIZE`-style sentinels, the ICB special regime, the mandatory +graphics container. Open (unchanged from a.md §12 + run findings): +`bool` operand semantics, grid mode, attributed text runs, percent pins, +anchor-to-node (`wire`), M-4 RMW policy, stable ids in the text IR. + +## Suggested next step + +Phase-3 proper: rewrite `models/a.md` against this statement as the +normative spec (every conformance `POL` locked, E-A1…E-A10 folded, +applicability matrix regenerated with `flip_x/flip_y` and the vector +reference rect), regenerate the fbs draft (header + `VectorPayload +{ ref: Rect, network }`), then WG graduation. diff --git a/model-v2/a/README.md b/model-v2/a/README.md new file mode 100644 index 0000000000..37946c3207 --- /dev/null +++ b/model-v2/a/README.md @@ -0,0 +1,116 @@ +# `anchor` — phase-3 kick-off & experiments workbench + +This directory hosts the experiments, spikes, and spec drafts for +**`anchor`**, the winning model of the model-v2 redesign +(decided 2026-07-07 via the owner triage). This README is written to be +**self-sufficient for session re-entry after context compaction** — read it +top to bottom and you have working state. + +> **2026-07-07 — the ledger has been RUN.** E1–E7 are complete with +> verdicts; the lab implementation lives in [`lab/`](./lab) (**114** conformance-derived tests green). Start at **[`REPORT.md`](./REPORT.md)** +> — win/lose/lessons-learnt and the fourteen spec deltas (E-A1…E-A14 + Taffy +> guards). The consolidated model statement — the phase-3 seed — is +> **[`MODEL.md`](./MODEL.md)**. + +## State of decisions (compressed) + +| decision | state | where | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| Main model | **`anchor`** — intent-canonical anchored box model | [`../models/a.md`](../models/a.md) (the model spec draft) | +| How decided | 32-question model-blind owner triage; deciders: canvas-truth (#1) + "the CSS path already exists as the `htmlcss` engine; the new taxonomy exists to be a new editor-first standard" (#6) | [`../triage.md`](../triage.md), instrument: [`../survey.md`](../survey.md) | +| Retired | `sheet` (adopt-CSS — its domain is the existing htmlcss engine), `bake` (post-T1), `wire` (deferred; re-enters additively via `Pin.to`) | [`../finale.md`](../finale.md), [`../models/`](../models/) | +| ~~Open fork~~ | rotation-in-flow — **DECIDED: layout-visible** (E1 measured prototype, 2026-07-07); R-3/OP-ROT-2 → INV | [`e1-rotation-in-flow/verdict.md`](./e1-rotation-in-flow/verdict.md) | + +### The five triage amendments (binding on everything here) + +1. **No file-carried switch-memory** — toggle-back restoration is editor + session state; the file holds only current truth. +2. **Strict states, lenient writes** — invalid documents unrepresentable; + the write/op layer never hard-walls (coerce/redirect, always reported, + never silent). H12 applies at the op layer as + "effective-or-coerced-with-report". +3. **Agent text IR is a first-class surface** — an XML-ish textual + projection that round-trips with the binary format; audience = LLMs + (must be able to write it and _predict geometry mentally_). H1's + audience is agents, not human file-readers. +4. **Familiar vocabulary** — `x`, `width`, `rotation`, `flex`, `gap`, + `padding` wherever semantics coincide; new terms only where the + correction is the point. +5. **SVG import ≈100%** — hard requirement; exotic-transform carrying + mechanism open (lens-quarantine default, declared degradation + acceptable), decided by corpus measurement (E5). + +## The model in ten lines (recap of `../models/a.md`) + +- Node = **uniform header + typed payload**. Header: `x`/`y` as + `AxisBinding = Pin{start|center|end, offset} | Span{start, end}`; + `width`/`height` as `SizeIntent = Fixed | Auto` (no Fill — growth via + `grow`, stretch via `self_align`/`Span`); `rotation: f32` degrees; + `flow`, `grow`, `self_align`; layer props. No stored matrices anywhere. +- Kinds: `frame, tray, shape, image, text, embed, vector, group, bool, +lens`. Shape descriptors are size-free, evaluated at the resolved box. +- Box sources: declared / measured (text, vector — intent = constraints) / + derived (group, bool, lens — never store size). +- Rotation pivot: box center (boxed/measured); own origin + gesture + compensation (derived). `lens` = sole home for skew/matrix/3D ops. +- Resolution: measure → layout (flex over rotated AABBs — pending E1) → + transforms (`from_box_center`) → bounds. One-way; resolved tier never + serializes ("derivable ⇒ not encodable"). + +## Experiment ledger + +One subdirectory per experiment (`e1-…/`), each with its own README +(question, method, decides-what) and a `verdict.md` when concluded. +Code: small standalone crates/scripts inside the experiment dir, **not** +wired into the workspace; promote into `crates/`/`format/` only at phase 4. + +| id | experiment | status (2026-07-07) | +| --------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **E1** | rotation-in-flow prototype | **DONE — layout-visible locked** (0 overlap vs 1,830 px²; smooth within analytic bound; breathing confirms two-lane) → [verdict](./e1-rotation-in-flow/verdict.md), [demo.html](./e1-rotation-in-flow/demo.html) | +| **E2** | fbs schema draft + codec round-trip | **DONE — pass** (S-1 byte-fixpoint, H11 structural unset witnessed, M-4 both directions; RMW policy owed) → [verdict](./e2-format/README.md) | +| **E3** | agent text IR | **DONE — validated** (frontier 22/22 cold prediction ×2; grammar + probes + scorer reusable) → [verdict](./e3-text-ir/verdict.md) | +| **E4** | resolver spike | **DONE — viable** (10k/5.4 ms; linear; 18 µs locality bound; 2 Taffy findings) → [verdict](./e4-resolver/verdict.md) | +| **E5** | SVG import corpus measurement | **DONE — quarantine + native-flip amendment** (1M transforms: shear 0.95%, flip 26.1%) → [verdict](./e5-svg-corpus/verdict.md) | +| **E6** | pluggable-layout sketch | **DONE — non-binding** (the contributions→slots seam exists; keep core at two modes) → [sketch](./e6-pluggable-layout/SKETCH.md) | +| **E7** | shape-vs-layout / points / two scales (owner question) | **DONE — research verdict** (XYWH-vs-shape solved by design; E-A9 vector reference-space; E-A10 two-scales rule; Graphics container rejected-as-requirement) → [findings](./e7-shape-points-scale/README.md) | +| **E-rev** | pedantic panel (bedrock/UX/eng/market) + edge sweep | **DONE** — core survives; 7 MODEL.md corrections applied; E-A11/E-A12 minted (grow/stretch × rotation pops, machine-verified); E8 (CSS projection) + E9 (Figma corpus) opened → [pedantic-review.md](./pedantic-review.md), [edge-cases/](./edge-cases) | +| **GROUP** | what a group is (owner question) | **DONE** — definition locked (“a named set with a coordinate space — nothing else”); E-A13 proposed (constraint pass-through, acyclic via E-A1); Figma semantics verified from primary sources → [`GROUP.md`](./GROUP.md) | +| **E-census** | edge census across all kinds + import maps | **DONE** — [`LIMITS.md`](./LIMITS.md) (kind×mechanism matrix, 5 bugs found+fixed, 5 blockers, the 🔶 punch list); [`COMPAT.md`](./COMPAT.md) (css/svg/figma → grida maps + harness clause H13); lab at **81 tests** (92 after the flip arm) | +| **E-flip** | cross-zero resize + flip semantics (owner red flag) | **BUILT** — E-A14: flip live in the lab (pivot per kind, `T·R·F` innermost, layout-invisible, σ-exact ungroup bake, NegativeExtent wall, `resize_drag` out-and-back identity); 11 tests; three-policy demo scene in [`edge-cases/`](./edge-cases); gesture policy = DEC-9 | +| **DECISIONS** | the open owner-call register | **DEC-0 DECIDED: visual-only (owner framing; second lock)** — [`DECISIONS.md`](./DECISIONS.md) + [`dec0-visual-only.md`](./dec0-visual-only.md); DEC-1/2/3 closed n/a by it · DEC-4 text determinism · DEC-5 f32 budget · DEC-6 bool bounds · DEC-7 format RMW · DEC-8 E8/E9 · DEC-9 wall/slide/flip · DEC-10 lens naming — answer in one pass; adopted-by-evidence appendix carries the veto window | +| **DEC-0 fork demo** | see the framing fork, not guess it | **BUILT** — [`dec0-fork/`](./dec0-fork): seven scenes × two framings side by side (`lab/src/bin/fork.rs`, real resolver both arms), live overlap / container-breathing / ink-escape meters; the two-lane asymmetry scene (header-rot vs lens-rot) | +| **DEC-0 flip** | the second lock: visual-only default (CSS framing, CSS-pure sizing) | **DONE** — [`dec0-visual-only.md`](./dec0-visual-only.md) (rules V-1…V-10; the V-4 group-box fork was real UB, decided sizing-tier); lab default flipped, hug/union CSS-pure behind the flag; `tests/visual_only.rs` (+14, suite **114**); E-A4/7/8/11/12 retired; spike re-cut (ink-bounds chrome, shots regenerated) | +| **free-editing demo** | the base case, hands on (owner question: "without layout, how does it work?") | **BUILT** — [`free-editing/`](./free-editing): live free-context mini-editor (JS mirror of the free rules — no Taffy, which is the point); live canonical IR, per-gesture write counts incl. typed errors, 0-write artboard responsiveness | +| **E10** | the feel spike: does the model drive a REAL editor? (owner: feel it + textbook) | **BUILT** — [`spike-canvas/`](./spike-canvas): native winit+Skia app on the lab (`cargo run --release`); resolve-per-frame thesis MEASURED (starter frame 0.17 ms; 10k nodes paint-bound at 9 ms); arena+SOA storage evolution in the lab (**up to 11.5× resolver speedup**, 100 tests); interaction FSM, HUD w/ E-A7 readout, cross-zero flip gesture, undo, live+editable IR panel, reports-as-badges; [`TEXTBOOK.md`](./spike-canvas/TEXTBOOK.md) + [`SPIKE.md`](./spike-canvas/SPIKE.md) | + +All verdicts + the spec deltas roll up in [`REPORT.md`](./REPORT.md). +Implementation: [`lab/`](./lab) — standalone crate, `cargo test` (92 +green), bins `e1`, `e3`, `e4`, `e5scan`, `edge`. + +## Phase-3 definition of done + +1. E1 verdict folded in → R-3/OP-ROT-2 become `INV`, not `POL`. +2. Normative spec: `../models/a.md` rewritten as spec (amendments folded, + every `POL` in [`../conformance.md`](../conformance.md) answered and + locked, applicability matrix final). +3. `grida.fbs` draft (E2) + text-IR grammar (E3) as spec appendices. +4. Conformance corpus stubbed with stable IDs (`covered_by` linkage). +5. WG graduation: code-agnostic write-up into `docs/wg/` (docs-wg + doctrine), with model-v2 remaining the working record. + +## Re-entry protocol (post-compaction) + +Read in this order — ~10 minutes to full context: + +1. This file (state + amendments + ledger). +2. [`../models/a.md`](../models/a.md) — the model itself. +3. [`../triage.md`](../triage.md) — why, in the owner's own words. +4. [`../conformance.md`](../conformance.md) §FORK rows + + [`../editor.md`](../editor.md) doctrine — the behavioral contracts. +5. Skim [`../problems.md`](../problems.md) / [`../harnesses.md`](../harnesses.md) + only when a design argument needs its source. + +Also standing: nothing in `model-v2/` is committed to git yet (untracked +working state); the session convention is grounding-first, problems before +solutions, and no scope beyond **Rust engine + format spec** — other seams +(TS editor, WASM bindings) follow after the model lands. diff --git a/model-v2/a/REPORT.md b/model-v2/a/REPORT.md new file mode 100644 index 0000000000..ff204b7129 --- /dev/null +++ b/model-v2/a/REPORT.md @@ -0,0 +1,214 @@ +# REPORT — prove `anchor` + +Autonomous run, 2026-07-07. Goal: **prove Model A** (`anchor`, +[`../models/a.md`](../models/a.md)) by building it and running the +experiment ledger ([`README.md`](./README.md)), ending in +win / lose / lessons-learnt. + +## Verdict: WIN — with five earned amendments and an honest loss column + +The model was implemented end-to-end in a standalone lab crate +([`lab/`](./lab), ~2,300 lines incl. tests, not wired into the repo +workspace), and every core claim of the spec survived contact with +execution — but not unchanged: implementation forced five concrete spec +corrections that document review had not found. That is what "proven" +means here: the model works, and we now know _where its paper version +was wrong_. + +## Scoreboard + +| exp | question | verdict | +| ----------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Lab core** | does the model implement? | **56/56 conformance-derived tests green** — MM-1/2/3/4/5/6/7/9, G-1/2/4/5/E1/E2/E3, R-1/2/3(both arms)/4/5/E1, L-3/4/5/7/E1/E3, T-1, D-1/2/3/6/E1, lens transparency, ops write-counts, typed errors, M-6, NaN boundary, IR round-trip | +| **E1** rotation-in-flow | AABB-participates vs visual-only | **DECIDED: layout-visible** — 0 px² overlap at every angle vs 1,830 px² in the CSS arm; displacement smooth (≤3.45 px/2°, inside the √(w²+h²) analytic bound); rotate stays a 1-field write; container breathing (56.6 px full-spin) _confirms_ the two-lane motion rule instead of weakening the decision. R-3/OP-ROT-2 → INV. [verdict](./e1-rotation-in-flow/verdict.md), [demo.html](./e1-rotation-in-flow/demo.html) | +| **E2** format | encodes under fbs header rules? | **PASS** — schema compiles; JSON→bin→JSON→bin **byte-identical** (S-1); structural unset witnessed in output (H11); additive evolution both directions (M-4). One policy owed: RMW-preservation of unknown fields. [verdict](./e2-format/README.md) | +| **E3** agent text IR | can an LLM predict geometry cold? | **VALIDATED** — two frontier agents **22/22 (100%)** from a 150-line grammar alone, including wrap arithmetic, grow, hug, rotated-in-flow, group origin rule; small-model control 17/22 with slip-class errors only. [verdict](./e3-text-ir/verdict.md) | +| **E4** resolver spike | architecture viable? | **VIABLE** — 10k free nodes / 5.4 ms, all-flex worst case 237 nodes/ms linear, 18 µs subtree locality bound; replaces the 26-arm branch forest + `atan2` reconstruction + `MIN_SIZE_DIRTY_HACK` with ~700 uniform lines. Two Taffy dependency findings. [verdict](./e4-resolver/verdict.md) | +| **E5** SVG corpus | quarantine vs degrade? | **MEASURED: quarantine + one amendment** — 1,003,787 transforms / 7,138 files: true shear is 0.95% (lens is right); **flip is 26.1% → must be native** (`flip_x`/`flip_y` header bools); paint transforms (39%) never touch node geometry. [verdict](./e5-svg-corpus/verdict.md) | +| **E6** pluggable layout | the #28 curiosity | sketched, non-binding: the `(contributions → slots)` seam already exists because E1's rotated AABBs forced it; keep core at two modes, E3 is the veto. [sketch](./e6-pluggable-layout/SKETCH.md) | + +## What implementation forced into the spec (the run's real yield) + +These are the deltas `models/a.md` must absorb in its phase-3 rewrite — +each one was _discovered by a failing test or a measurement_, not by +argument: + +1. **E-A1 — derived-box bindings place the origin, not the union box.** + The naive reading of §2.1 (bindings position the box) makes D-2 fail: + moving child A shifts the union, which re-places the box, which moves + sibling B in world space — the exact P6 instability the model exists + to kill. §8's phrase "places the _space_" must be promoted to a + normative rule: pins bind the group-space origin; the reported box is + `origin + union`; reads report the box; writes re-target by delta. + (Found by `d2_sibling_stability_under_rotated_group` failing.) +2. **E-A2 — native flip.** Two header booleans (`flip_x`/`flip_y`), + center-applied. Measured justification: 159,872 single-axis mirrors + in the wild corpus — a "quarantine" firing on 24% of files is not a + quarantine. Answers R-E5. (E5) +3. **E-A3 — the two stretches are different rules and must be headlined:** + container `cross:"stretch"` affects only auto-cross children; child + `align:"stretch"` means _fill_ and overrides Fixed (it is the format's + only cross-axis fill, per §2.2's no-Fill design). The first grammar + draft under-specified this and the truth run caught it. (E3) +4. **E-A4 — grow × rotation declared:** grow expands the _slot_; a + rotated child's box keeps its basis, centered in the grown slot. + (Lab policy that needs spec text; found writing the flex path.) +5. **E-A5 — underdetermined-binding rules:** End/Center/Span pins under + a derived-box parent or an Auto-hug free frame are `error-by-rule` + (reported, resolved as Start) — the spec's applicability matrix has + no row for these; now it needs one. +6. **E-A6 — M-4 needs a policy, not a hope:** FlatBuffers read-skips + unknown fields but decode→re-encode through an old schema _drops_ + them (verified). Choose: buffer-retention patching, or writers + version-gated to newest schema. (E2) +7. **E-A7 — editor note:** the rotated envelope peaks at + `θ* = atan(h/w)`, not 90° — HUD affordances should surface the + envelope or mid-turn width reads as a bug. (E1) +8. **E-A8 — the lens-only-rotate lint must be context-scoped** (owner + question, post-run): a.md §3.3 flags a lens containing only `Rotate` + as "use header rotation" — but under the E1-locked semantics, + lens-rotate **in flow** is the legitimate, structurally-distinct + escape to paint-only rotation (proven: `lens_rotate_is_the_visual_only_twin` + reproduces the CSS arm exactly under the default flag). The lint + applies only in _free_ context, where header rotation is equivalent. +9. **E-A9 — vector adopts the reference-space mapping** (owner question + → E7 research, 2026-07-07): `vector` changes from "measured box, no + size intent" to points-in-reference-space + ordinary size intent, + mapped by `resolved_size / reference_size` at render — the structure + Sketch (normalized 0–1 points, no transform in format) and Figma + (`normalizedSize`) both converged on. Kills the current editor's + per-resize vertex bake (verified outlier), defines vectors under + grow/stretch, keeps resize a 1–2 field write. Refinement from our own + io-figma notes: the reference space is a **rect, not a size** + (observed non-zero blob origins) — store a reference rect or + normalize origin at write. Reference implementation already in-repo: + `scaleVectorNetworkFromNormalizedSize` (io-figma lib.ts) + the + fixture test proving Figma ships `normalizedSize != size` nodes. + See [`e7-shape-points-scale/`](./e7-shape-points-scale/README.md). +10. **E-A10 — the two-scales rule, locked** (E7): plain resize = + geometry re-evaluation with px-stable styles (non-scaling-stroke by + construction); parameter scale (K) stays an op-layer bake (Grida + already ships `parametric_scale`, = Figma `rescale()`); retained + picture-scale (strokes + image fills scale) exists only as a lens + `Scale` op. Image fills always re-fit at the resolved box per fit + mode — never baked, never rasterized with the shape. The + Framer/Sketch `` container is rejected as a requirement + (evidence: its mandatoriness is their documented pain), kept as the + lens escape. +11. **E-A11 — grow × rotation continuity** (pedantic pass + edge sweep, + 2026-07-07): E-A4 as declared is discontinuous — a `grow:1` card + snaps from filled (300 px) to basis (60 px) at the first degree of + rotation (measured 240 px/3° jump, `edge-cases/` scene `grow`). + Fix direction: inverse-envelope fill (box takes the largest extent + whose rotated envelope fits the grown slot — continuous at 0°). +12. **E-A12 — stretch/re-measure × rotation declared** (same pass): + the lab silently skips stretch and text re-measure for rotated + in-flow children — the unstated rule that makes "zero overlap" and + "single-pass" true. Promote to spec text (ignored-by-rule **with + report** for θ≠0), same continuity treatment as E-A11 (measured + 170→100 px pop, scene `stretch`). +13. **E-A13 — group constraint transparency** (owner question + verified + Figma research, 2026-07-07): binding reference = nearest non-derived + ancestor (Figma-parity pass-through), acyclic _because of_ E-A1's + stored origins — no re-fit writes needed; AL child-props do NOT pass + through (verified Figma parity); v1 restricts non-Start pins to + unrotated derived chains. Full chapter: [`GROUP.md`](./GROUP.md). +14. **E-A14 — flip semantics built** (owner-raised cross-zero red flag, + 2026-07-07): the four evidence-forced facts — pivot per kind exactly as + rotation (pedantic B1, now lab-tested: D-2 holds under a flipped + group); composition **innermost** (`T·R·F`, mirror first, then turn); + flip is **layout-invisible by construction** for boxed kinds + (center-applied ⇒ AABB unchanged — the one transform that never pops + layout); extents stay non-negative bedrock (`set_width(−50)` = + typed `NegativeExtent`, doc untouched) while the DRAG re-targets + (|extent| + flip toggle + re-pin, out-and-back = document identity). + Ungroup bakes flips exactly via mirror conjugation + (`F·R(θ) = R(σθ)·F`, σ = −1 iff single-axis). 11 tests + ([`lab/tests/flip.rs`](./lab/tests/flip.rs)); which gesture POLICY + ships (wall/slide/flip) is open — [`DECISIONS.md`](./DECISIONS.md) + DEC-9, demo'd in [`edge-cases/`](./edge-cases). +15. **Pedantic review (2026-07-07)** — full findings, lock conditions, + and two new ledger experiments (**E8** CSS-projection measurement; + **E9** Figma-corpus scan): [`pedantic-review.md`](./pedantic-review.md). + MODEL.md surgically corrected same day (bool cell, law 8 two + regimes, E-A5 acyclicity guard stated, locality qualified, E3 + caveat, law 6 marked open-pending-lock). +16. **DEC-0 second lock — the default is VISUAL-ONLY (owner framing, + 2026-07-07):** rotation is a post-layout paint transform; sizing is + CSS-pure (never reads rotation/flips — flex, hug, derived unions); + reads stay oriented. Supersedes E1's default; the E1 arm remains + implemented + tested as the documented alternative. The gated spec + review found and closed one real UB (the V-4 group-box fork — a + derived box's four consumers could read two different values; + decided: sizing-tier union). Normative rules: + [`dec0-visual-only.md`](./dec0-visual-only.md); conformance: + `lab/tests/visual_only.rs` (suite 114). Retires E-A4/E-A7/E-A8/ + E-A11/E-A12; closes DEC-1/2/3 n/a. +17. **Dependency guards (phase 4):** Taffy rounds unless disabled (L-7); + Taffy's intrinsic pass inflates growable-item contributions by the + container's own padding, deviating from L-3 _and_ Chromium — guard by + stripping grow in indefinite-main intrinsic runs, and pin L-3's + conformance test to Chromium, never to the layout library. (E4) + +## Lose column — what this run did not prove + +- **Payload coverage**: `tray`, `image`, `embed`, `vector`, `bool` were + not implemented (no new geometry mechanism among them — but "no new + mechanism" is itself an unproven claim for `bool`'s op-result box). +- **Wrap** got minimal exercise; **grid** none (additive future by + design, untested by construction). +- **CRDT**: C-1/C-5 were demonstrated as field-level merges in tests, + but no real replicated backend ran the C-matrix. +- **No incremental resolver**: locality was _bounded_ (18 µs/subtree), + not implemented; N-4's invalidation claim rests on phase-order + structure plus that bound. +- **"Feel" by proxy**: E1 measured overlap/continuity/breathing and + ships an interactive demo, but no human ran the scrubber yet — the + owner should open `e1-rotation-in-flow/demo.html` and disagree if the + numbers lied. +- **E3 n=3**: two frontier + one small model, six documents. Convincing + signal, small sample; the probe protocol is repeatable on demand. +- **Text IR carries no node ids** — authoring round-trips; _editing_ + identity does not yet (recorded in the E3 verdict). +- The lab's per-container Taffy runs and hug double-pass mean E4's flex + numbers are a floor, not an engine benchmark. + +## Lessons learnt + +1. **Building found what reviewing could not.** Five of the eight spec + deltas came from failing tests or corpus counts, none from re-reading + the documents. The triage's "challenge the ideal empirically" posture + (#5) was the single highest-yield decision of the whole model-v2 + effort. +2. **The best correction came from the model's own test.** D-2 + (sibling stability) is _in the conformance corpus because the paper + analysis said groups were solved_. The test disagreed with the + implementation of the paper's own words — "places the space" turned + out to be load-bearing prose nobody had cashed out. Write the test + corpus before believing the prose. +3. **Dependencies are spec surface.** Two Taffy behaviors (rounding, + intrinsic grow inflation) would have silently become "the model" had + the lab pinned tests to the library instead of to declared rules. + Conformance oracles must outrank implementation convenience. +4. **Sentinel-freedom is cheap at birth, expensive to retrofit** — the + whole H11 discipline cost one afternoon in a fresh schema (flatc + proved unset-vs-default structurally in the decoded JSON), versus the + `max_lines: 0` / `(0,0)-aspect` hacks the current format carries. +5. **LLM-predictability works as a design _instrument_, not just a + requirement**: prediction failures localize spec ambiguity (the + small model's slot-center slip and the stretch gap were both wording + defects surfaced as wrong numbers). Re-run the E3 probe after every + grammar change; treat sub-100% frontier scores as spec bugs. +6. **Measure the corpus before designing the escape hatch.** The lens + was designed for skew; the corpus said the real problem was flips — + a class nobody had ranked. One scanner afternoon re-ranked the + priorities of the capability model. + +## What's next (phase 3, unchanged in shape, now unblocked) + +Rewrite `models/a.md` as the normative spec with E-A1…E-A7 folded in and +every conformance `POL` locked (R-3 now INV); graduate the WG write-up +per docs-wg doctrine; promote `anchor.fbs` + the text-IR grammar to spec +appendices; then phase 4 against the real engine with the two Taffy +guards. Nothing in this run is committed to git — the whole workbench +remains untracked working state for the owner to review. diff --git a/model-v2/a/dec0-fork/fork.json b/model-v2/a/dec0-fork/fork.json new file mode 100644 index 0000000000..173a8b3b62 --- /dev/null +++ b/model-v2/a/dec0-fork/fork.json @@ -0,0 +1,6468 @@ +{ + "step": 3, + "scenes": [ + { + "id": "grow", + "name": "grow x rotation", + "labels": ["container", "a", "grow+rot", "c"], + "anchor": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 55.0, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 222.66, 53.5, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 225.39, 52.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 228.19, 50.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 231.05, 49.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 233.96, 48.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 236.92, 48.18, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 239.91, 47.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 242.93, 47.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 245.97, 46.83, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 249.02, 46.7, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 252.07, 46.73, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 255.12, 46.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 258.15, 47.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 261.16, 47.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 264.14, 48.43, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 267.08, 49.25, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 269.98, 50.22, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 272.82, 51.34, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 275.59, 52.61, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 278.3, 54.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 280.93, 55.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 283.48, 57.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 285.93, 59.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 288.28, 61.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 290.53, 63.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 292.67, 65.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 294.69, 67.55, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 296.59, 69.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 298.36, 72.42, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 300.0, 75.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 301.5, 77.66, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 302.86, 80.39, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 304.08, 83.19, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 305.14, 86.05, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 306.06, 88.96, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 306.82, 91.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 307.43, 94.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 307.88, 97.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 308.17, 100.97, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 308.3, 104.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 308.27, 107.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 308.08, 110.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 307.74, 113.15, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 307.23, 116.16, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 306.57, 119.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 305.75, 122.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 304.78, 124.98, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 303.66, 127.82, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 302.39, 130.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 300.98, 133.3, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 299.43, 135.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 297.74, 138.48, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 295.93, 140.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 293.98, 143.28, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 291.92, 145.53, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 289.74, 147.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 287.45, 149.69, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 285.06, 151.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 282.58, 153.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 280.0, 155.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0] + ] + }, + "css": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 55.0, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 102.82, 47.22, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 106.05, 39.59, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 109.67, 32.15, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 113.67, 24.91, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 118.05, 17.88, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 122.79, 11.09, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 127.88, 4.57, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 133.31, -1.69, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 139.05, -7.65, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 145.1, -13.3, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 151.43, -18.63, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 158.04, -23.62, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 164.89, -28.26, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 171.98, -32.53, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 179.29, -36.42, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 186.79, -39.93, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 194.46, -43.04, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 202.28, -45.74, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 210.24, -48.03, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 218.3, -49.9, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 226.45, -51.35, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 234.67, -52.37, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 242.92, -52.96, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 251.2, -53.11, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 259.47, -52.83, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 267.72, -52.12, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 275.92, -50.97, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 284.05, -49.4, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 292.08, -47.41, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 300.0, -45.0, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 307.78, -42.18, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 315.41, -38.95, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 322.85, -35.33, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 330.09, -31.33, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 337.12, -26.95, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 343.91, -22.21, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 350.43, -17.12, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 356.69, -11.69, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 362.65, -5.95, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 368.3, 0.1, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 373.63, 6.43, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 378.62, 13.04, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 383.26, 19.89, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 387.53, 26.98, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 391.42, 34.29, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 394.93, 41.79, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 398.04, 49.46, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 400.74, 57.28, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 403.03, 65.24, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 404.9, 73.3, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 406.35, 81.45, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 407.37, 89.67, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 407.96, 97.92, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 408.11, 106.2, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 407.83, 114.47, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 407.12, 122.72, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 405.97, 130.92, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 404.4, 139.05, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 402.41, 147.08, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 400.0, 155.0, 300.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 2.1], + [0.0, 460.0, 170.0, 8.9], + [0.0, 460.0, 170.0, 15.4], + [0.0, 460.0, 170.0, 21.7], + [0.0, 460.0, 170.0, 27.6], + [0.0, 460.0, 170.0, 33.3], + [0.0, 460.0, 170.0, 38.6], + [0.0, 460.0, 170.0, 43.6], + [0.0, 460.0, 170.0, 48.3], + [0.0, 460.0, 170.0, 52.5], + [0.0, 460.0, 170.0, 56.4], + [0.0, 460.0, 170.0, 59.9], + [0.0, 460.0, 170.0, 63.0], + [0.0, 460.0, 170.0, 65.7], + [0.0, 460.0, 170.0, 68.0], + [0.0, 460.0, 170.0, 69.9], + [0.0, 460.0, 170.0, 71.4], + [0.0, 460.0, 170.0, 72.4], + [0.0, 460.0, 170.0, 73.0], + [0.0, 460.0, 170.0, 73.1], + [0.0, 460.0, 170.0, 72.8], + [0.0, 460.0, 170.0, 72.1], + [0.0, 460.0, 170.0, 71.0], + [0.0, 460.0, 170.0, 69.4], + [0.0, 460.0, 170.0, 67.4], + [0.0, 460.0, 170.0, 65.0], + [0.0, 460.0, 170.0, 67.4], + [0.0, 460.0, 170.0, 69.4], + [0.0, 460.0, 170.0, 71.0], + [0.0, 460.0, 170.0, 72.1], + [0.0, 460.0, 170.0, 72.8], + [0.0, 460.0, 170.0, 73.1], + [0.0, 460.0, 170.0, 73.0], + [0.0, 460.0, 170.0, 72.4], + [0.0, 460.0, 170.0, 71.4], + [0.0, 460.0, 170.0, 69.9], + [0.0, 460.0, 170.0, 68.0], + [0.0, 460.0, 170.0, 65.7], + [0.0, 460.0, 170.0, 63.0], + [0.0, 460.0, 170.0, 59.9], + [0.0, 460.0, 170.0, 56.4], + [0.0, 460.0, 170.0, 52.5], + [0.0, 460.0, 170.0, 48.3], + [0.0, 460.0, 170.0, 43.6], + [0.0, 460.0, 170.0, 38.6], + [0.0, 460.0, 170.0, 33.3], + [0.0, 460.0, 170.0, 27.6], + [0.0, 460.0, 170.0, 21.7], + [0.0, 460.0, 170.0, 15.4], + [0.0, 460.0, 170.0, 8.9], + [0.0, 460.0, 170.0, 2.1], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0] + ] + } + }, + { + "id": "stretch", + "name": "stretch x rotation", + "labels": ["container", "a", "stretch+rot", "c"], + "anchor": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 30.0, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 105.23, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 175.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 110.45, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 180.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 115.64, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 184.9, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 120.79, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 189.48, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 125.88, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 193.84, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 130.9, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 197.97, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 135.84, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 201.85, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 140.67, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 205.49, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 145.4, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 208.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 150.0, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 211.96, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 154.46, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 214.78, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 158.78, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 217.32, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 162.93, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 219.56, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 166.91, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 221.5, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 170.71, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.14, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 174.31, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.46, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 177.71, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.47, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 180.9, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.17, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 183.87, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.55, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 186.6, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.6, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 189.1, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.34, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 191.35, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.76, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 193.36, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 195.11, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.65, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 196.59, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 222.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 197.81, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 220.29, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 198.77, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 218.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 199.45, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 215.72, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 199.86, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 213.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 200.0, 30.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 210.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 203.0, 35.23, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 213.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 205.72, 40.45, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 215.72, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 208.15, 45.64, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 218.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 210.29, 50.79, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 220.29, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 212.12, 55.88, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 222.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 213.65, 60.9, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.65, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 214.86, 65.84, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 215.76, 70.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.76, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 216.34, 75.4, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.34, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 216.6, 80.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.6, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 216.55, 84.46, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.55, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 216.17, 88.78, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.17, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 215.47, 92.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.47, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 214.46, 96.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.46, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 213.14, 100.71, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.14, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 211.5, 104.31, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 221.5, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 209.56, 107.71, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 219.56, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 207.32, 110.9, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 217.32, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 204.78, 113.87, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 214.78, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 201.96, 116.6, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 211.96, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 198.86, 119.1, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 208.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 195.49, 121.35, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 205.49, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 191.85, 123.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 201.85, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 187.97, 125.11, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 197.97, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 183.84, 126.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 193.84, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 179.48, 127.81, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 189.48, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 174.9, 128.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 184.9, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 170.12, 129.45, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 180.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 165.15, 129.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 175.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 160.0, 130.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0] + ] + }, + "css": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 30.0, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 104.49, 28.55, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 109.05, 27.33, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 113.67, 26.35, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 118.33, 25.62, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 123.02, 25.13, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 127.73, 24.89, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 132.45, 24.89, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 137.17, 25.15, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 141.86, 25.64, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 146.52, 26.39, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 151.13, 27.37, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 155.69, 28.6, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 160.18, 30.06, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 164.58, 31.76, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 168.89, 33.68, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 173.09, 35.83, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 177.18, 38.19, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 181.13, 40.77, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 184.95, 43.55, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 188.61, 46.52, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 192.12, 49.68, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 195.45, 53.02, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 198.6, 56.53, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 201.57, 60.2, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 204.34, 64.02, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 206.91, 67.98, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 209.26, 72.07, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 211.4, 76.28, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 213.31, 80.59, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 215.0, 85.0, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 216.45, 89.49, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 217.67, 94.05, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 218.65, 98.67, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 219.38, 103.33, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 219.87, 108.02, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 220.11, 112.73, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 220.11, 117.45, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 219.85, 122.17, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 219.36, 126.86, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 218.61, 131.52, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 217.63, 136.13, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 216.4, 140.69, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 214.94, 145.18, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 213.24, 149.58, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 211.32, 153.89, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 209.17, 158.09, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 206.81, 162.18, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 204.23, 166.13, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 201.45, 169.95, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 198.48, 173.61, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 195.32, 177.12, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 191.98, 180.45, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 188.47, 183.6, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 184.8, 186.57, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 180.98, 189.34, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 177.02, 191.91, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 172.93, 194.26, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 168.72, 196.4, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 164.41, 198.31, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 160.0, 200.0, 60.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [292.8, 460.0, 190.0, 0.0], + [701.7, 460.0, 190.0, 0.0], + [1097.7, 460.0, 190.0, 0.0], + [1479.8, 460.0, 190.0, 0.0], + [1846.9, 460.0, 190.0, 0.0], + [2197.9, 460.0, 190.0, 0.0], + [2531.9, 460.0, 190.0, 0.0], + [2848.1, 460.0, 190.0, 0.0], + [3145.4, 460.0, 190.0, 0.0], + [3423.2, 460.0, 190.0, 0.0], + [3680.7, 460.0, 190.0, 0.0], + [3917.0, 460.0, 190.0, 0.0], + [4131.7, 460.0, 190.0, 0.0], + [4324.1, 460.0, 190.0, 0.0], + [4493.7, 460.0, 190.0, 0.0], + [4640.0, 460.0, 190.0, 0.0], + [4762.6, 460.0, 190.0, 0.0], + [4861.2, 460.0, 190.0, 0.0], + [4935.5, 460.0, 190.0, 0.0], + [4985.3, 460.0, 190.0, 0.0], + [5010.5, 460.0, 190.0, 0.0], + [5011.0, 460.0, 190.0, 0.0], + [4986.8, 460.0, 190.0, 0.0], + [4643.4, 460.0, 190.0, 0.0], + [4176.6, 460.0, 190.0, 0.0], + [3691.6, 460.0, 190.0, 0.0], + [3196.7, 460.0, 190.0, 0.0], + [2700.0, 460.0, 190.0, 0.0], + [3196.7, 460.0, 190.0, 0.0], + [3691.6, 460.0, 190.0, 0.0], + [4176.6, 460.0, 190.0, 0.0], + [4643.4, 460.0, 190.0, 0.0], + [4986.8, 460.0, 190.0, 0.0], + [5011.0, 460.0, 190.0, 0.0], + [5010.5, 460.0, 190.0, 0.0], + [4985.3, 460.0, 190.0, 0.0], + [4935.5, 460.0, 190.0, 0.0], + [4861.2, 460.0, 190.0, 0.0], + [4762.6, 460.0, 190.0, 0.0], + [4640.0, 460.0, 190.0, 0.0], + [4493.7, 460.0, 190.0, 0.0], + [4324.1, 460.0, 190.0, 0.0], + [4131.7, 460.0, 190.0, 0.0], + [3917.0, 460.0, 190.0, 0.0], + [3680.7, 460.0, 190.0, 0.0], + [3423.2, 460.0, 190.0, 0.0], + [3145.4, 460.0, 190.0, 0.0], + [2848.1, 460.0, 190.0, 0.0], + [2531.9, 460.0, 190.0, 0.0], + [2197.9, 460.0, 190.0, 0.0], + [1846.9, 460.0, 190.0, 0.0], + [1479.8, 460.0, 190.0, 0.0], + [1097.7, 460.0, 190.0, 0.0], + [701.7, 460.0, 190.0, 0.0], + [292.8, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0], + [0.0, 460.0, 190.0, 0.0] + ] + } + }, + { + "id": "text", + "name": "rotated text in a row", + "labels": ["container", "a", "text+rot", "c"], + "anchor": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 94.2, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 236.63, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 101.13, 92.24, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 186.63, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 237.44, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 102.26, 90.31, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 187.44, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.05, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 103.38, 88.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.05, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.44, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 104.49, 86.58, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.44, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.61, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 105.59, 84.78, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.61, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.57, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 106.67, 83.05, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.57, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.32, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 107.74, 81.37, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.32, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 237.85, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 108.79, 79.76, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 187.85, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 237.17, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 109.81, 78.22, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 187.17, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 236.27, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 110.8, 76.75, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 186.27, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.17, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 111.76, 75.36, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.17, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 233.86, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 112.7, 74.04, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 183.86, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 232.35, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 113.59, 72.82, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 182.35, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 230.63, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 114.45, 71.68, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 180.63, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 228.73, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 115.27, 70.63, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 178.73, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 226.64, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 116.05, 69.68, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 176.64, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 224.36, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 116.79, 68.83, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 174.36, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 221.91, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 117.47, 68.07, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 171.91, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 219.29, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 118.12, 67.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 169.29, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 216.51, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 118.71, 66.86, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 166.51, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 213.57, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 119.25, 66.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 163.57, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 210.48, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 119.73, 66.08, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 160.48, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 207.26, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 120.17, 65.84, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 157.26, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 203.9, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 120.54, 65.71, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 153.9, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 200.43, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 120.86, 65.69, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 150.43, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 196.85, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 121.13, 65.78, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 146.85, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 193.16, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 121.33, 65.98, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 143.16, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 189.38, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 121.48, 66.28, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 139.38, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 185.53, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 121.57, 66.69, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 135.53, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 181.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 121.6, 67.2, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 131.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 185.53, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 125.53, 67.82, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 135.53, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 189.38, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 129.38, 68.54, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 139.38, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 193.16, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 133.16, 69.35, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 143.16, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 196.85, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 136.85, 70.27, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 146.85, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 200.43, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 140.43, 71.28, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 150.43, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 203.9, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 143.9, 72.39, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 153.9, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 207.26, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 147.26, 73.58, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 157.26, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 210.48, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 150.48, 74.86, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 160.48, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 213.57, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 153.57, 76.22, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 163.57, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 216.51, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 156.51, 77.66, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 166.51, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 219.29, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 159.29, 79.18, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 169.29, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 221.91, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 161.91, 80.77, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 171.91, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 224.36, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 164.36, 82.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 174.36, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 226.64, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 166.64, 84.14, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 176.64, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 228.73, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 168.73, 85.91, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 178.73, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 230.63, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 170.63, 87.73, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 180.63, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 232.35, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 172.35, 89.6, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 182.35, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 233.86, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 173.86, 91.52, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 183.86, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.17, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 175.17, 93.47, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.17, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 236.27, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 176.27, 95.45, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 186.27, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 237.17, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 177.17, 97.46, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 187.17, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 237.85, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 177.85, 99.49, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 187.85, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.32, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 178.32, 101.54, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.32, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.57, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 178.57, 103.59, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.57, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.61, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 178.61, 105.65, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.61, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.44, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 178.44, 107.7, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.44, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 238.05, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 178.05, 109.75, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 188.05, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 237.44, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 177.44, 111.79, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 187.44, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 236.63, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 176.63, 113.81, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 186.63, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 175.6, 115.8, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 235.6, 170.0, 0.0], + [0.0, 236.6, 170.0, 0.0], + [0.0, 237.4, 170.0, 0.0], + [0.0, 238.0, 170.0, 0.0], + [0.0, 238.4, 170.0, 0.0], + [0.0, 238.6, 170.0, 0.0], + [0.0, 238.6, 170.0, 0.0], + [0.0, 238.3, 170.0, 0.0], + [0.0, 237.8, 170.0, 0.0], + [0.0, 237.2, 170.0, 0.0], + [0.0, 236.3, 170.0, 0.0], + [0.0, 235.2, 170.0, 0.0], + [0.0, 233.9, 170.0, 0.0], + [0.0, 232.3, 170.0, 0.0], + [0.0, 230.6, 170.0, 0.0], + [0.0, 228.7, 170.0, 0.0], + [0.0, 226.6, 170.0, 0.0], + [0.0, 224.4, 170.0, 0.0], + [0.0, 221.9, 170.0, 0.0], + [0.0, 219.3, 170.0, 0.0], + [0.0, 216.5, 170.0, 0.0], + [0.0, 213.6, 170.0, 0.0], + [0.0, 210.5, 170.0, 0.0], + [0.0, 207.3, 170.0, 0.0], + [0.0, 203.9, 170.0, 0.0], + [0.0, 200.4, 170.0, 0.0], + [0.0, 196.8, 170.0, 0.0], + [0.0, 193.2, 170.0, 0.0], + [0.0, 189.4, 170.0, 0.0], + [0.0, 185.5, 170.0, 0.0], + [0.0, 181.6, 170.0, 0.0], + [0.0, 185.5, 170.0, 0.0], + [0.0, 189.4, 170.0, 0.0], + [0.0, 193.2, 170.0, 0.0], + [0.0, 196.8, 170.0, 0.0], + [0.0, 200.4, 170.0, 0.0], + [0.0, 203.9, 170.0, 0.0], + [0.0, 207.3, 170.0, 0.0], + [0.0, 210.5, 170.0, 0.0], + [0.0, 213.6, 170.0, 0.0], + [0.0, 216.5, 170.0, 0.0], + [0.0, 219.3, 170.0, 0.0], + [0.0, 221.9, 170.0, 0.0], + [0.0, 224.4, 170.0, 0.0], + [0.0, 226.6, 170.0, 0.0], + [0.0, 228.7, 170.0, 0.0], + [0.0, 230.6, 170.0, 0.0], + [0.0, 232.3, 170.0, 0.0], + [0.0, 233.9, 170.0, 0.0], + [0.0, 235.2, 170.0, 0.0], + [0.0, 236.3, 170.0, 0.0], + [0.0, 237.2, 170.0, 0.0], + [0.0, 237.8, 170.0, 0.0], + [0.0, 238.3, 170.0, 0.0], + [0.0, 238.6, 170.0, 0.0], + [0.0, 238.6, 170.0, 0.0], + [0.0, 238.4, 170.0, 0.0], + [0.0, 238.0, 170.0, 0.0], + [0.0, 237.4, 170.0, 0.0], + [0.0, 236.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0] + ] + }, + "css": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 94.2, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 100.62, 92.24, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 101.34, 90.31, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 102.15, 88.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 103.07, 86.58, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 104.08, 84.78, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 105.19, 83.05, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 106.38, 81.37, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 107.66, 79.76, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 109.02, 78.22, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 110.46, 76.75, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 111.98, 75.36, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 113.57, 74.04, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 115.22, 72.82, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 116.94, 71.68, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 118.71, 70.63, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 120.53, 69.68, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 122.4, 68.83, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 124.32, 68.07, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 126.27, 67.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 128.25, 66.86, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 130.26, 66.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 132.29, 66.08, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 134.34, 65.84, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 136.39, 65.71, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 138.45, 65.69, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 140.5, 65.78, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 142.55, 65.98, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 144.59, 66.28, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 146.61, 66.69, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 148.6, 67.2, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 150.56, 67.82, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 152.49, 68.54, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 154.38, 69.35, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 156.22, 70.27, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 158.02, 71.28, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 159.75, 72.39, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 161.43, 73.58, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 163.04, 74.86, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 164.58, 76.22, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 166.05, 77.66, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 167.45, 79.18, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 168.76, 80.77, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 169.98, 82.42, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 171.12, 84.14, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 172.17, 85.91, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 173.12, 87.73, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 173.97, 89.6, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 174.73, 91.52, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 175.38, 93.47, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 175.94, 95.45, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 176.38, 97.46, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 176.72, 99.49, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 176.96, 101.54, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 177.09, 103.59, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 177.11, 105.65, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 177.02, 107.7, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 176.82, 109.75, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 176.52, 111.79, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 176.11, 113.81, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 235.6, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 175.6, 115.8, 75.6, 21.6], + [1.0, 0.0, 0.0, 1.0, 185.6, 55.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0], + [0.0, 235.6, 170.0, 0.0] + ] + } + }, + { + "id": "hug", + "name": "hug containment", + "labels": ["container (hug)", "a", "b", "rot"], + "anchor": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 225.15, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 175.23, 63.5, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 230.12, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 180.45, 62.14, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 234.9, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 185.64, 60.92, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 239.48, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 190.79, 59.86, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 243.84, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 195.88, 58.94, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 247.97, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 200.9, 58.18, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 251.85, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 205.84, 57.57, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 255.49, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 210.67, 57.12, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 258.86, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 215.4, 56.83, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 261.96, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 220.0, 56.7, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 264.78, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 224.46, 56.73, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 267.32, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 228.78, 56.92, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 269.56, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 232.93, 57.26, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 271.5, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 236.91, 57.77, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 273.14, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 240.71, 58.43, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.46, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 244.31, 59.25, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 275.47, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 247.71, 60.22, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.17, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 250.9, 61.34, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.55, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 253.87, 62.61, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.6, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 256.6, 64.02, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.34, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 259.1, 65.57, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 275.76, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 261.35, 67.26, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.86, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 263.36, 69.07, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 273.65, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 265.11, 71.02, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 272.12, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 266.59, 73.08, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 270.29, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 267.81, 75.26, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 268.15, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 268.77, 77.55, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 265.72, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 269.45, 79.94, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 263.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 269.86, 82.42, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 260.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 270.0, 85.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 263.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 273.0, 87.66, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 265.72, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 275.72, 90.39, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 268.15, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 278.15, 93.19, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 270.29, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 280.29, 96.05, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 272.12, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 282.12, 98.96, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 273.65, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 283.65, 101.92, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.86, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 284.86, 104.91, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 275.76, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 285.76, 107.93, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.34, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 286.34, 110.97, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.6, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 286.6, 114.02, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.55, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 286.55, 117.07, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.17, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 286.17, 120.12, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 275.47, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 285.47, 123.15, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.46, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 284.46, 126.16, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 273.14, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 283.14, 129.14, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 271.5, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 281.5, 132.08, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 269.56, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 279.56, 134.98, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 267.32, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 277.32, 137.82, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 264.78, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 274.78, 140.59, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 261.96, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 271.96, 143.3, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 258.86, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 268.86, 145.93, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 255.49, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 265.49, 148.48, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 251.85, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 261.85, 150.93, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 247.97, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 257.97, 153.28, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 243.84, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 253.84, 155.53, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 239.48, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 249.48, 157.67, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 234.9, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 244.9, 159.69, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 230.12, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 240.12, 161.59, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 225.15, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 235.15, 163.36, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 230.0, 165.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 220.0, 190.0, 0.0], + [0.0, 225.2, 190.0, 0.0], + [0.0, 230.1, 190.0, 0.0], + [0.0, 234.9, 190.0, 0.0], + [0.0, 239.5, 190.0, 0.0], + [0.0, 243.8, 190.0, 0.0], + [0.0, 248.0, 190.0, 0.0], + [0.0, 251.9, 190.0, 0.0], + [0.0, 255.5, 190.0, 0.0], + [0.0, 258.9, 190.0, 0.0], + [0.0, 262.0, 190.0, 0.0], + [0.0, 264.8, 190.0, 0.0], + [0.0, 267.3, 190.0, 0.0], + [0.0, 269.6, 190.0, 0.0], + [0.0, 271.5, 190.0, 0.0], + [0.0, 273.1, 190.0, 0.0], + [0.0, 274.5, 190.0, 0.0], + [0.0, 275.5, 190.0, 0.0], + [0.0, 276.2, 190.0, 0.0], + [0.0, 276.5, 190.0, 0.0], + [0.0, 276.6, 190.0, 0.0], + [0.0, 276.3, 190.0, 0.0], + [0.0, 275.8, 190.0, 0.0], + [0.0, 274.9, 190.0, 0.0], + [0.0, 273.6, 190.0, 0.0], + [0.0, 272.1, 190.0, 0.0], + [0.0, 270.3, 190.0, 0.0], + [0.0, 268.2, 190.0, 0.0], + [0.0, 265.7, 190.0, 0.0], + [0.0, 263.0, 190.0, 0.0], + [0.0, 260.0, 190.0, 0.0], + [0.0, 263.0, 190.0, 0.0], + [0.0, 265.7, 190.0, 0.0], + [0.0, 268.2, 190.0, 0.0], + [0.0, 270.3, 190.0, 0.0], + [0.0, 272.1, 190.0, 0.0], + [0.0, 273.6, 190.0, 0.0], + [0.0, 274.9, 190.0, 0.0], + [0.0, 275.8, 190.0, 0.0], + [0.0, 276.3, 190.0, 0.0], + [0.0, 276.6, 190.0, 0.0], + [0.0, 276.5, 190.0, 0.0], + [0.0, 276.2, 190.0, 0.0], + [0.0, 275.5, 190.0, 0.0], + [0.0, 274.5, 190.0, 0.0], + [0.0, 273.1, 190.0, 0.0], + [0.0, 271.5, 190.0, 0.0], + [0.0, 269.6, 190.0, 0.0], + [0.0, 267.3, 190.0, 0.0], + [0.0, 264.8, 190.0, 0.0], + [0.0, 262.0, 190.0, 0.0], + [0.0, 258.9, 190.0, 0.0], + [0.0, 255.5, 190.0, 0.0], + [0.0, 251.9, 190.0, 0.0], + [0.0, 248.0, 190.0, 0.0], + [0.0, 243.8, 190.0, 0.0], + [0.0, 239.5, 190.0, 0.0], + [0.0, 234.9, 190.0, 0.0], + [0.0, 230.1, 190.0, 0.0], + [0.0, 225.2, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0] + ] + }, + "css": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 172.66, 63.5, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 175.39, 62.14, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 178.19, 60.92, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 181.05, 59.86, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 183.96, 58.94, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 186.92, 58.18, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 189.91, 57.57, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 192.93, 57.12, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 195.97, 56.83, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 199.02, 56.7, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 202.07, 56.73, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 205.12, 56.92, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 208.15, 57.26, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 211.16, 57.77, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 214.14, 58.43, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 217.08, 59.25, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 219.98, 60.22, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 222.82, 61.34, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 225.59, 62.61, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 228.3, 64.02, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 230.93, 65.57, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 233.48, 67.26, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 235.93, 69.07, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 238.28, 71.02, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 240.53, 73.08, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 242.67, 75.26, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 244.69, 77.55, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 246.59, 79.94, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 248.36, 82.42, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 250.0, 85.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 251.5, 87.66, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 252.86, 90.39, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 254.08, 93.19, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 255.14, 96.05, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 256.06, 98.96, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 256.82, 101.92, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 257.43, 104.91, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 257.88, 107.93, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 258.17, 110.97, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 258.3, 114.02, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 258.27, 117.07, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 258.08, 120.12, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 257.74, 123.15, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 257.23, 126.16, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 256.57, 129.14, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 255.75, 132.08, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 254.78, 134.98, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 253.66, 137.82, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 252.39, 140.59, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 250.98, 143.3, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 249.43, 145.93, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 247.74, 148.48, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 245.93, 150.93, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 243.98, 153.28, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 241.92, 155.53, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 239.74, 157.67, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 237.45, 159.69, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 235.06, 161.59, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 232.58, 163.36, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 230.0, 165.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0], + [191.9, 220.0, 190.0, 1.9], + [398.3, 220.0, 190.0, 4.0], + [592.6, 220.0, 190.0, 5.9], + [774.3, 220.0, 190.0, 7.7], + [943.0, 220.0, 190.0, 9.4], + [1098.1, 220.0, 190.0, 11.0], + [1239.2, 220.0, 190.0, 12.4], + [1366.0, 220.0, 190.0, 13.7], + [1478.0, 220.0, 190.0, 14.8], + [1575.1, 220.0, 190.0, 15.8], + [1656.9, 220.0, 190.0, 16.6], + [1723.1, 220.0, 190.0, 17.2], + [1773.7, 220.0, 190.0, 17.7], + [1808.4, 220.0, 190.0, 18.1], + [1827.3, 220.0, 190.0, 18.3], + [1830.1, 220.0, 190.0, 18.3], + [1796.3, 220.0, 190.0, 18.2], + [1707.2, 220.0, 190.0, 17.9], + [1601.0, 220.0, 190.0, 17.4], + [1479.9, 220.0, 190.0, 16.8], + [1346.5, 220.0, 190.0, 16.1], + [1203.7, 220.0, 190.0, 15.1], + [1054.5, 220.0, 190.0, 14.1], + [901.9, 220.0, 190.0, 12.9], + [749.3, 220.0, 190.0, 11.5], + [600.0, 220.0, 190.0, 10.0], + [749.3, 220.0, 190.0, 11.5], + [901.9, 220.0, 190.0, 12.9], + [1054.5, 220.0, 190.0, 14.1], + [1203.7, 220.0, 190.0, 15.1], + [1346.5, 220.0, 190.0, 16.1], + [1479.9, 220.0, 190.0, 16.8], + [1601.0, 220.0, 190.0, 17.4], + [1707.2, 220.0, 190.0, 17.9], + [1796.3, 220.0, 190.0, 18.2], + [1830.1, 220.0, 190.0, 18.3], + [1827.3, 220.0, 190.0, 18.3], + [1808.4, 220.0, 190.0, 18.1], + [1773.7, 220.0, 190.0, 17.7], + [1723.1, 220.0, 190.0, 17.2], + [1656.9, 220.0, 190.0, 16.6], + [1575.1, 220.0, 190.0, 15.8], + [1478.0, 220.0, 190.0, 14.8], + [1366.0, 220.0, 190.0, 13.7], + [1239.2, 220.0, 190.0, 12.4], + [1098.1, 220.0, 190.0, 11.0], + [943.0, 220.0, 190.0, 9.4], + [774.3, 220.0, 190.0, 7.7], + [592.6, 220.0, 190.0, 5.9], + [398.3, 220.0, 190.0, 4.0], + [191.9, 220.0, 190.0, 1.9], + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0], + [0.0, 220.0, 190.0, 0.0] + ] + } + }, + { + "id": "wrap", + "name": "wrap reflow", + "labels": ["container", "a", "rot", "c", "d"], + "anchor": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 110.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 143.5], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [1.0, 0.052, -0.052, 0.999, 116.28, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 196.18, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 276.18, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 146.66], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.99, 0.105, -0.105, 0.995, 122.54, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 202.16, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 282.16, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 149.47], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.99, 0.156, -0.156, 0.988, 128.77, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 207.91, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 287.91, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 151.93], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.98, 0.208, -0.208, 0.978, 134.95, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 213.42, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 293.42, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 154.03], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.97, 0.259, -0.259, 0.966, 141.06, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 218.67, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 298.67, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 155.76], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.95, 0.309, -0.309, 0.951, 147.08, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 223.66, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 303.66, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 157.12], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.93, 0.358, -0.358, 0.934, 153.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 228.35, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 308.35, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.1], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.91, 0.407, -0.407, 0.914, 158.81, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 232.76, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 312.76, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.7], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.89, 0.454, -0.454, 0.891, 164.48, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 236.85, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 316.85, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.92], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.87, 0.5, -0.5, 0.866, 170.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 240.62, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.92, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.77], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.84, 0.545, -0.545, 0.839, 175.36, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 244.06, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.77, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.23], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.81, 0.588, -0.588, 0.809, 180.53, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 247.17, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.23, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 287.31], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.78, 0.629, -0.629, 0.777, 185.52, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 249.92, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 177.31, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 286.02], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.74, 0.669, -0.669, 0.743, 190.3, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 252.32, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 176.02, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 284.35], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.71, 0.707, -0.707, 0.707, 194.85, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 254.35, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 174.35, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 282.32], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.67, 0.743, -0.743, 0.669, 199.18, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 256.02, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 172.32, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 279.92], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.63, 0.777, -0.777, 0.629, 203.26, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 257.31, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 169.92, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 277.17], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.59, 0.809, -0.809, 0.588, 207.08, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.23, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 167.17, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 274.06], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.54, 0.839, -0.839, 0.545, 210.64, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.77, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 164.06, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.62], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.5, 0.866, -0.866, 0.5, 213.92, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.92, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.62, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.45, 0.891, -0.891, 0.454, 216.92, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.7, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.41, 0.914, -0.914, 0.407, 219.63, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.1, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.36, 0.934, -0.934, 0.358, 222.03, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 257.12, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.31, 0.951, -0.951, 0.309, 224.13, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 255.76, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.26, 0.966, -0.966, 0.259, 225.91, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 254.03, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.21, 0.978, -0.978, 0.208, 227.38, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 251.93, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.16, 0.988, -0.988, 0.156, 228.52, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 249.47, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.1, 0.995, -0.995, 0.105, 229.34, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 246.66, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.05, 0.999, -0.999, 0.052, 229.84, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 243.5, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.0, 1.0, -1.0, 0.0, 230.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 240.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 320.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.05, 0.999, -0.999, -0.052, 233.5, 36.28, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 243.5, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.1, 0.995, -0.995, -0.105, 236.66, 42.54, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 246.66, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.16, 0.988, -0.988, -0.156, 239.47, 48.77, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 249.47, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.21, 0.978, -0.978, -0.208, 241.93, 54.95, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 251.93, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.26, 0.966, -0.966, -0.259, 244.03, 61.06, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 254.03, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.31, 0.951, -0.951, -0.309, 245.76, 67.08, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 255.76, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.36, 0.934, -0.934, -0.358, 247.12, 73.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 257.12, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.41, 0.914, -0.914, -0.407, 248.1, 78.81, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.1, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.45, 0.891, -0.891, -0.454, 248.7, 84.48, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.7, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.62], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.5, 0.866, -0.866, -0.5, 248.92, 90.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.92, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.62, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 274.06], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.54, 0.839, -0.839, -0.545, 248.77, 95.36, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.77, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 164.06, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 277.17], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.59, 0.809, -0.809, -0.588, 248.23, 100.53, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 258.23, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 167.17, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 279.92], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.63, 0.777, -0.777, -0.629, 247.31, 105.52, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 257.31, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 169.92, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 282.32], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.67, 0.743, -0.743, -0.669, 246.02, 110.3, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 256.02, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 172.32, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 284.35], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.71, 0.707, -0.707, -0.707, 244.35, 114.85, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 254.35, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 174.35, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 286.02], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.74, 0.669, -0.669, -0.743, 242.32, 119.18, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 252.32, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 176.02, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 287.31], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.78, 0.629, -0.629, -0.777, 239.92, 123.26, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 249.92, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 177.31, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.23], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.81, 0.588, -0.588, -0.809, 237.17, 127.08, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 247.17, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.23, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.77], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.84, 0.545, -0.545, -0.839, 234.06, 130.64, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 244.06, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.77, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.92], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.87, 0.5, -0.5, -0.866, 230.62, 133.92, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 240.62, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.92, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.7], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.89, 0.454, -0.454, -0.891, 226.85, 136.92, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 236.85, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 316.85, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.1], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.91, 0.407, -0.407, -0.914, 222.76, 139.63, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 232.76, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 312.76, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 157.12], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.93, 0.358, -0.358, -0.934, 218.35, 142.03, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 228.35, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 308.35, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 155.76], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.95, 0.309, -0.309, -0.951, 213.66, 144.13, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 223.66, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 303.66, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 154.03], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.97, 0.259, -0.259, -0.966, 208.67, 145.91, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 218.67, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 298.67, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 151.93], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.98, 0.208, -0.208, -0.978, 203.42, 147.38, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 213.42, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 293.42, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 149.47], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.99, 0.156, -0.156, -0.988, 197.91, 148.52, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 207.91, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 287.91, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 146.66], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.99, 0.105, -0.105, -0.995, 192.16, 149.34, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 202.16, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 282.16, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 143.5], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-1.0, 0.052, -0.052, -0.999, 186.18, 149.84, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 196.18, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 276.18, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-1.0, 0.0, -0.0, -1.0, 180.0, 150.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ] + ], + "m": [ + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 143.5, 0.0], + [0.0, 380.0, 146.7, 0.0], + [0.0, 380.0, 149.5, 0.0], + [0.0, 380.0, 151.9, 0.0], + [0.0, 380.0, 154.0, 0.0], + [0.0, 380.0, 155.8, 0.0], + [0.0, 380.0, 157.1, 0.0], + [0.0, 380.0, 158.1, 0.0], + [0.0, 380.0, 158.7, 0.0], + [0.0, 380.0, 288.9, 0.0], + [0.0, 380.0, 288.8, 0.0], + [0.0, 380.0, 288.2, 0.0], + [0.0, 380.0, 287.3, 0.0], + [0.0, 380.0, 286.0, 0.0], + [0.0, 380.0, 284.4, 0.0], + [0.0, 380.0, 282.3, 0.0], + [0.0, 380.0, 279.9, 0.0], + [0.0, 380.0, 277.2, 0.0], + [0.0, 380.0, 274.1, 0.0], + [0.0, 380.0, 270.6, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.0, 0.0], + [0.0, 380.0, 270.6, 0.0], + [0.0, 380.0, 274.1, 0.0], + [0.0, 380.0, 277.2, 0.0], + [0.0, 380.0, 279.9, 0.0], + [0.0, 380.0, 282.3, 0.0], + [0.0, 380.0, 284.4, 0.0], + [0.0, 380.0, 286.0, 0.0], + [0.0, 380.0, 287.3, 0.0], + [0.0, 380.0, 288.2, 0.0], + [0.0, 380.0, 288.8, 0.0], + [0.0, 380.0, 288.9, 0.0], + [0.0, 380.0, 158.7, 0.0], + [0.0, 380.0, 158.1, 0.0], + [0.0, 380.0, 157.1, 0.0], + [0.0, 380.0, 155.8, 0.0], + [0.0, 380.0, 154.0, 0.0], + [0.0, 380.0, 151.9, 0.0], + [0.0, 380.0, 149.5, 0.0], + [0.0, 380.0, 146.7, 0.0], + [0.0, 380.0, 143.5, 0.0], + [0.0, 380.0, 140.0, 0.0] + ] + }, + "css": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 110.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [1.0, 0.052, -0.052, 0.999, 113.19, 28.25, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.99, 0.105, -0.105, 0.995, 116.46, 26.67, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.99, 0.156, -0.156, 0.988, 119.82, 25.26, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.98, 0.208, -0.208, 0.978, 123.24, 24.03, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.97, 0.259, -0.259, 0.966, 126.72, 22.99, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.95, 0.309, -0.309, 0.951, 130.25, 22.12, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.93, 0.358, -0.358, 0.934, 133.83, 21.44, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.91, 0.407, -0.407, 0.914, 137.43, 20.95, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.89, 0.454, -0.454, 0.891, 141.05, 20.65, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.87, 0.5, -0.5, 0.866, 144.69, 20.54, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.84, 0.545, -0.545, 0.839, 148.32, 20.62, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.81, 0.588, -0.588, 0.809, 151.95, 20.89, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.78, 0.629, -0.629, 0.777, 155.56, 21.35, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.74, 0.669, -0.669, 0.743, 159.14, 21.99, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.71, 0.707, -0.707, 0.707, 162.68, 22.82, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.67, 0.743, -0.743, 0.669, 166.17, 23.84, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.63, 0.777, -0.777, 0.629, 169.6, 25.04, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.59, 0.809, -0.809, 0.588, 172.97, 26.42, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.54, 0.839, -0.839, 0.545, 176.26, 27.97, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.5, 0.866, -0.866, 0.5, 179.46, 29.69, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.45, 0.891, -0.891, 0.454, 182.57, 31.58, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.41, 0.914, -0.914, 0.407, 185.58, 33.62, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.36, 0.934, -0.934, 0.358, 188.47, 35.82, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.31, 0.951, -0.951, 0.309, 191.25, 38.17, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.26, 0.966, -0.966, 0.259, 193.9, 40.66, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.21, 0.978, -0.978, 0.208, 196.41, 43.29, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.16, 0.988, -0.988, 0.156, 198.79, 46.04, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.1, 0.995, -0.995, 0.105, 201.01, 48.92, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.05, 0.999, -0.999, 0.052, 203.09, 51.91, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [0.0, 1.0, -1.0, 0.0, 205.0, 55.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.05, 0.999, -0.999, -0.052, 206.75, 58.19, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.1, 0.995, -0.995, -0.105, 208.33, 61.46, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.16, 0.988, -0.988, -0.156, 209.74, 64.82, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.21, 0.978, -0.978, -0.208, 210.97, 68.24, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.26, 0.966, -0.966, -0.259, 212.01, 71.72, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.31, 0.951, -0.951, -0.309, 212.88, 75.25, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.36, 0.934, -0.934, -0.358, 213.56, 78.83, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.41, 0.914, -0.914, -0.407, 214.05, 82.43, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.45, 0.891, -0.891, -0.454, 214.35, 86.05, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.5, 0.866, -0.866, -0.5, 214.46, 89.69, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.54, 0.839, -0.839, -0.545, 214.38, 93.32, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.59, 0.809, -0.809, -0.588, 214.11, 96.95, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.63, 0.777, -0.777, -0.629, 213.65, 100.56, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.67, 0.743, -0.743, -0.669, 213.01, 104.14, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.71, 0.707, -0.707, -0.707, 212.18, 107.68, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.74, 0.669, -0.669, -0.743, 211.16, 111.17, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.78, 0.629, -0.629, -0.777, 209.96, 114.6, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.81, 0.588, -0.588, -0.809, 208.58, 117.97, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.84, 0.545, -0.545, -0.839, 207.03, 121.26, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.87, 0.5, -0.5, -0.866, 205.31, 124.46, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.89, 0.454, -0.454, -0.891, 203.42, 127.57, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.91, 0.407, -0.407, -0.914, 201.38, 130.58, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.93, 0.358, -0.358, -0.934, 199.18, 133.47, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.95, 0.309, -0.309, -0.951, 196.83, 136.25, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.97, 0.259, -0.259, -0.966, 194.34, 138.9, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.98, 0.208, -0.208, -0.978, 191.71, 141.41, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.99, 0.156, -0.156, -0.988, 188.96, 143.79, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-0.99, 0.105, -0.105, -0.995, 186.08, 146.01, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-1.0, 0.052, -0.052, -0.999, 183.09, 148.09, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0], + [-1.0, 0.0, -0.0, -1.0, 180.0, 150.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0] + ] + ], + "m": [ + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 140.0, 0.0], + [205.2, 380.0, 140.0, 0.0], + [520.4, 380.0, 140.0, 0.0], + [819.4, 380.0, 140.0, 0.0], + [1101.3, 380.0, 140.0, 0.0], + [1365.4, 380.0, 140.0, 0.0], + [1611.0, 380.0, 140.0, 0.0], + [1837.3, 380.0, 140.0, 0.0], + [2043.8, 380.0, 140.0, 0.0], + [2229.9, 380.0, 140.0, 0.0], + [2395.1, 380.0, 140.0, 0.0], + [2539.0, 380.0, 140.0, 0.0], + [2661.0, 380.0, 140.0, 0.0], + [2761.0, 380.0, 140.0, 0.0], + [2838.6, 380.0, 140.0, 0.0], + [2893.6, 380.0, 140.0, 0.0], + [2925.9, 380.0, 140.0, 0.0], + [2935.4, 380.0, 140.0, 0.0], + [2845.3, 380.0, 140.0, 0.0], + [2711.6, 380.0, 140.0, 0.0], + [2552.6, 380.0, 140.0, 0.0], + [2371.5, 380.0, 140.0, 0.0], + [2172.2, 380.0, 140.0, 0.0], + [1958.6, 380.0, 140.0, 0.0], + [1735.0, 380.0, 140.0, 0.0], + [1506.0, 380.0, 140.0, 0.0], + [1276.1, 380.0, 140.0, 0.0], + [1050.0, 380.0, 140.0, 0.0], + [1276.1, 380.0, 140.0, 0.0], + [1506.0, 380.0, 140.0, 0.0], + [1735.0, 380.0, 140.0, 0.0], + [1958.6, 380.0, 140.0, 0.0], + [2172.2, 380.0, 140.0, 0.0], + [2371.5, 380.0, 140.0, 0.0], + [2552.6, 380.0, 140.0, 0.0], + [2711.6, 380.0, 140.0, 0.0], + [2845.3, 380.0, 140.0, 0.0], + [2935.4, 380.0, 140.0, 0.0], + [2925.9, 380.0, 140.0, 0.0], + [2893.6, 380.0, 140.0, 0.0], + [2838.6, 380.0, 140.0, 0.0], + [2761.0, 380.0, 140.0, 0.0], + [2661.0, 380.0, 140.0, 0.0], + [2538.9, 380.0, 140.0, 0.0], + [2395.1, 380.0, 140.0, 0.0], + [2229.9, 380.0, 140.0, 0.0], + [2043.8, 380.0, 140.0, 0.0], + [1837.3, 380.0, 140.0, 0.0], + [1611.0, 380.0, 140.0, 0.0], + [1365.4, 380.0, 140.0, 0.0], + [1101.3, 380.0, 140.0, 0.0], + [819.4, 380.0, 140.0, 0.0], + [520.4, 380.0, 140.0, 0.0], + [205.2, 380.0, 140.0, 0.0], + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 140.0, 0.0], + [0.0, 380.0, 140.0, 0.0] + ] + } + }, + { + "id": "between", + "name": "space-between", + "labels": ["container", "a", "rot", "c"], + "anchor": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 220.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 222.66, 53.5, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 225.39, 52.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 228.19, 50.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 231.05, 49.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 233.96, 48.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 236.92, 48.18, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 239.91, 47.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 242.93, 47.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 245.97, 46.83, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 249.02, 46.7, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 252.07, 46.73, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 255.12, 46.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 258.15, 47.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 261.16, 47.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 264.14, 48.43, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 267.08, 49.25, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 269.98, 50.22, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 272.82, 51.34, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 275.59, 52.61, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 278.3, 54.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 280.93, 55.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 283.48, 57.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 285.93, 59.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 288.28, 61.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 290.53, 63.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 292.67, 65.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 294.69, 67.55, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 296.59, 69.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 298.36, 72.42, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 300.0, 75.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 301.5, 77.66, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 302.86, 80.39, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 304.08, 83.19, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 305.14, 86.05, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 306.06, 88.96, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 306.82, 91.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 307.43, 94.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 307.88, 97.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 308.17, 100.97, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 308.3, 104.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 308.27, 107.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 308.08, 110.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 307.74, 113.15, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 307.23, 116.16, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 306.57, 119.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 305.75, 122.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 304.78, 124.98, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 303.66, 127.82, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 302.39, 130.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 300.98, 133.3, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 299.43, 135.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 297.74, 138.48, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 295.93, 140.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 293.98, 143.28, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 291.92, 145.53, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 289.74, 147.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 287.45, 149.69, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 285.06, 151.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 282.58, 153.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 280.0, 155.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0] + ] + }, + "css": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 220.0, 55.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 222.66, 53.5, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 225.39, 52.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 228.19, 50.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 231.05, 49.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 233.96, 48.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 236.92, 48.18, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 239.91, 47.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 242.93, 47.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 245.97, 46.83, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 249.02, 46.7, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 252.07, 46.73, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 255.12, 46.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 258.15, 47.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 261.16, 47.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 264.14, 48.43, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 267.08, 49.25, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 269.98, 50.22, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 272.82, 51.34, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 275.59, 52.61, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 278.3, 54.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 280.93, 55.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 283.48, 57.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 285.93, 59.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 288.28, 61.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 290.53, 63.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 292.67, 65.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 294.69, 67.55, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 296.59, 69.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 298.36, 72.42, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 300.0, 75.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 301.5, 77.66, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 302.86, 80.39, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 304.08, 83.19, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 305.14, 86.05, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 306.06, 88.96, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 306.82, 91.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 307.43, 94.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 307.88, 97.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 308.17, 100.97, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 308.3, 104.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 308.27, 107.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 308.08, 110.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 307.74, 113.15, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 307.23, 116.16, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 306.57, 119.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 305.75, 122.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 304.78, 124.98, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 303.66, 127.82, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 302.39, 130.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 300.98, 133.3, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 299.43, 135.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 297.74, 138.48, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 295.93, 140.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 293.98, 143.28, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 291.92, 145.53, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 289.74, 147.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 287.45, 149.69, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 285.06, 151.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 282.58, 153.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 280.0, 155.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0], + [0.0, 460.0, 170.0, 0.0] + ] + } + }, + { + "id": "lens", + "name": "two lanes in one row", + "labels": ["container", "a", "header-rot", "b", "lens-rot", "c"], + "anchor": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 240.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 105.23, 63.5, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 175.15, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 247.81, 63.5, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 315.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 110.45, 62.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 180.12, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 255.51, 62.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 320.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 115.64, 60.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 184.9, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 263.1, 60.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 324.9, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 120.79, 59.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 189.48, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 270.53, 59.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 329.48, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 125.88, 58.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 193.84, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 277.8, 58.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 333.84, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 130.9, 58.18, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 197.97, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 284.88, 58.18, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 337.97, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 135.84, 57.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 201.85, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 291.76, 57.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 341.85, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 140.67, 57.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 205.49, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 298.42, 57.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 345.49, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 145.4, 56.83, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 208.86, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 304.83, 56.83, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 348.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 150.0, 56.7, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 211.96, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 310.98, 56.7, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 351.96, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 154.46, 56.73, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 214.78, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 316.86, 56.73, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 354.78, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 158.78, 56.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 217.32, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 322.44, 56.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 357.32, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 162.93, 57.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 219.56, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 327.71, 57.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 359.56, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 166.91, 57.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 221.5, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 332.66, 57.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 361.5, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 170.71, 58.43, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.14, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 337.28, 58.43, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 363.14, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 174.31, 59.25, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.46, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 341.55, 59.25, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 364.46, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 177.71, 60.22, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.47, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 345.45, 60.22, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 365.47, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 180.9, 61.34, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.17, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 348.99, 61.34, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.17, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 183.87, 62.61, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.55, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 352.14, 62.61, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.55, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 186.6, 64.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.6, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 354.9, 64.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.6, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 189.1, 65.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.34, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 357.27, 65.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.34, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 191.35, 67.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.76, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 359.23, 67.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 365.76, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 193.36, 69.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.86, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 360.79, 69.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 364.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 195.11, 71.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.65, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 361.93, 71.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 363.65, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 196.59, 73.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 222.12, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 362.65, 73.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 362.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 197.81, 75.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 220.29, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 362.96, 75.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 360.29, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 198.77, 77.55, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 218.15, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 362.85, 77.55, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 358.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 199.45, 79.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 215.72, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 362.31, 79.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 355.72, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 199.86, 82.42, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 213.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 361.36, 82.42, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 353.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 200.0, 85.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 210.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 360.0, 85.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 350.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 203.0, 87.66, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 213.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 364.5, 87.66, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 353.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 205.72, 90.39, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 215.72, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 368.59, 90.39, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 355.72, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 208.15, 93.19, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 218.15, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 372.23, 93.19, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 358.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 210.29, 96.05, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 220.29, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 375.43, 96.05, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 360.29, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 212.12, 98.96, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 222.12, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 378.18, 98.96, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 362.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 213.65, 101.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.65, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 380.47, 101.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 363.65, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 214.86, 104.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.86, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 382.29, 104.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 364.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 215.76, 107.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.76, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 383.64, 107.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 365.76, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 216.34, 110.97, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.34, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 384.51, 110.97, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.34, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 216.6, 114.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.6, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 384.9, 114.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.6, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 216.55, 117.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.55, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 384.82, 117.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.55, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 216.17, 120.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 226.17, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 384.25, 120.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 366.17, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 215.47, 123.15, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 225.47, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 383.21, 123.15, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 365.47, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 214.46, 126.16, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 224.46, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 381.69, 126.16, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 364.46, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 213.14, 129.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 223.14, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 379.71, 129.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 363.14, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 211.5, 132.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 221.5, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 377.25, 132.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 361.5, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 209.56, 134.98, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 219.56, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 374.34, 134.98, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 359.56, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 207.32, 137.82, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 217.32, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 370.98, 137.82, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 357.32, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 204.78, 140.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 214.78, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 367.18, 140.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 354.78, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 201.96, 143.3, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 211.96, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 362.94, 143.3, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 351.96, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 198.86, 145.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 208.86, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 358.29, 145.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 348.86, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 195.49, 148.48, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 205.49, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 353.23, 148.48, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 345.49, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 191.85, 150.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 201.85, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 347.78, 150.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 341.85, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 187.97, 153.28, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 197.97, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 341.95, 153.28, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 337.97, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 183.84, 155.53, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 193.84, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 335.76, 155.53, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 333.84, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 179.48, 157.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 189.48, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 329.22, 157.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 329.48, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 174.9, 159.69, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 184.9, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 322.36, 159.69, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 324.9, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 170.12, 161.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 180.12, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 315.19, 161.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 320.12, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 165.15, 163.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 175.15, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 307.73, 163.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 315.15, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 160.0, 165.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 300.0, 165.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [191.9, 520.0, 190.0, 0.0], + [398.3, 520.0, 190.0, 0.0], + [592.6, 520.0, 190.0, 0.0], + [774.3, 520.0, 190.0, 0.0], + [943.0, 520.0, 190.0, 0.0], + [1098.1, 520.0, 190.0, 0.0], + [1239.2, 520.0, 190.0, 0.0], + [1366.0, 520.0, 190.0, 0.0], + [1478.0, 520.0, 190.0, 0.0], + [1575.1, 520.0, 190.0, 0.0], + [1656.9, 520.0, 190.0, 0.0], + [1723.1, 520.0, 190.0, 0.0], + [1773.7, 520.0, 190.0, 0.0], + [1808.4, 520.0, 190.0, 0.0], + [1827.3, 520.0, 190.0, 0.0], + [1830.1, 520.0, 190.0, 0.0], + [1796.3, 520.0, 190.0, 0.0], + [1707.2, 520.0, 190.0, 0.0], + [1601.0, 520.0, 190.0, 0.0], + [1479.9, 520.0, 190.0, 0.0], + [1346.5, 520.0, 190.0, 0.0], + [1203.7, 520.0, 190.0, 0.0], + [1054.5, 520.0, 190.0, 0.0], + [901.9, 520.0, 190.0, 0.0], + [749.3, 520.0, 190.0, 0.0], + [600.0, 520.0, 190.0, 0.0], + [749.3, 520.0, 190.0, 0.0], + [901.9, 520.0, 190.0, 0.0], + [1054.5, 520.0, 190.0, 0.0], + [1203.7, 520.0, 190.0, 0.0], + [1346.5, 520.0, 190.0, 0.0], + [1479.9, 520.0, 190.0, 0.0], + [1601.0, 520.0, 190.0, 0.0], + [1707.2, 520.0, 190.0, 0.0], + [1796.3, 520.0, 190.0, 0.0], + [1830.1, 520.0, 190.0, 0.0], + [1827.3, 520.0, 190.0, 0.0], + [1808.4, 520.0, 190.0, 0.0], + [1773.7, 520.0, 190.0, 0.0], + [1723.1, 520.0, 190.0, 0.0], + [1656.9, 520.0, 190.0, 0.0], + [1575.1, 520.0, 190.0, 0.0], + [1478.0, 520.0, 190.0, 0.0], + [1366.0, 520.0, 190.0, 0.0], + [1239.2, 520.0, 190.0, 0.0], + [1098.1, 520.0, 190.0, 0.0], + [943.0, 520.0, 190.0, 0.0], + [774.3, 520.0, 190.0, 0.0], + [592.6, 520.0, 190.0, 0.0], + [398.3, 520.0, 190.0, 0.0], + [191.9, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0] + ] + }, + "css": { + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 240.0, 65.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 102.66, 63.5, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [1.0, 0.052, -0.052, 0.999, 242.66, 63.5, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 105.39, 62.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.99, 0.105, -0.105, 0.995, 245.39, 62.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 108.19, 60.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.99, 0.156, -0.156, 0.988, 248.19, 60.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 111.05, 59.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.98, 0.208, -0.208, 0.978, 251.05, 59.86, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 113.96, 58.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.97, 0.259, -0.259, 0.966, 253.96, 58.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 116.92, 58.18, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.95, 0.309, -0.309, 0.951, 256.92, 58.18, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 119.91, 57.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.93, 0.358, -0.358, 0.934, 259.91, 57.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 122.93, 57.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.91, 0.407, -0.407, 0.914, 262.93, 57.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 125.97, 56.83, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.89, 0.454, -0.454, 0.891, 265.97, 56.83, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 129.02, 56.7, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.87, 0.5, -0.5, 0.866, 269.02, 56.7, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 132.07, 56.73, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.84, 0.545, -0.545, 0.839, 272.07, 56.73, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 135.12, 56.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.81, 0.588, -0.588, 0.809, 275.12, 56.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 138.15, 57.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.78, 0.629, -0.629, 0.777, 278.15, 57.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 141.16, 57.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.74, 0.669, -0.669, 0.743, 281.16, 57.77, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 144.14, 58.43, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.71, 0.707, -0.707, 0.707, 284.14, 58.43, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 147.08, 59.25, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.67, 0.743, -0.743, 0.669, 287.08, 59.25, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 149.98, 60.22, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.63, 0.777, -0.777, 0.629, 289.98, 60.22, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 152.82, 61.34, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.59, 0.809, -0.809, 0.588, 292.82, 61.34, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 155.59, 62.61, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.54, 0.839, -0.839, 0.545, 295.59, 62.61, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 158.3, 64.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.5, 0.866, -0.866, 0.5, 298.3, 64.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 160.93, 65.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.45, 0.891, -0.891, 0.454, 300.93, 65.57, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 163.48, 67.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.41, 0.914, -0.914, 0.407, 303.48, 67.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 165.93, 69.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.36, 0.934, -0.934, 0.358, 305.93, 69.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 168.28, 71.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.31, 0.951, -0.951, 0.309, 308.28, 71.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 170.53, 73.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.26, 0.966, -0.966, 0.259, 310.53, 73.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 172.67, 75.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.21, 0.978, -0.978, 0.208, 312.67, 75.26, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 174.69, 77.55, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.16, 0.988, -0.988, 0.156, 314.69, 77.55, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 176.59, 79.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.1, 0.995, -0.995, 0.105, 316.59, 79.94, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 178.36, 82.42, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.05, 0.999, -0.999, 0.052, 318.36, 82.42, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 180.0, 85.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [0.0, 1.0, -1.0, 0.0, 320.0, 85.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 181.5, 87.66, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.05, 0.999, -0.999, -0.052, 321.5, 87.66, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 182.86, 90.39, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.1, 0.995, -0.995, -0.105, 322.86, 90.39, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 184.08, 93.19, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.16, 0.988, -0.988, -0.156, 324.08, 93.19, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 185.14, 96.05, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.21, 0.978, -0.978, -0.208, 325.14, 96.05, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 186.06, 98.96, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.26, 0.966, -0.966, -0.259, 326.06, 98.96, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 186.82, 101.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.31, 0.951, -0.951, -0.309, 326.82, 101.92, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 187.43, 104.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.36, 0.934, -0.934, -0.358, 327.43, 104.91, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 187.88, 107.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.41, 0.914, -0.914, -0.407, 327.88, 107.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 188.17, 110.97, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.45, 0.891, -0.891, -0.454, 328.17, 110.97, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 188.3, 114.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.5, 0.866, -0.866, -0.5, 328.3, 114.02, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 188.27, 117.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.54, 0.839, -0.839, -0.545, 328.27, 117.07, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 188.08, 120.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.59, 0.809, -0.809, -0.588, 328.08, 120.12, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 187.74, 123.15, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.63, 0.777, -0.777, -0.629, 327.74, 123.15, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 187.23, 126.16, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.67, 0.743, -0.743, -0.669, 327.23, 126.16, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 186.57, 129.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.71, 0.707, -0.707, -0.707, 326.57, 129.14, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 185.75, 132.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.74, 0.669, -0.669, -0.743, 325.75, 132.08, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 184.78, 134.98, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.78, 0.629, -0.629, -0.777, 324.78, 134.98, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 183.66, 137.82, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.81, 0.588, -0.588, -0.809, 323.66, 137.82, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 182.39, 140.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.84, 0.545, -0.545, -0.839, 322.39, 140.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 180.98, 143.3, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.87, 0.5, -0.5, -0.866, 320.98, 143.3, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 179.43, 145.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.89, 0.454, -0.454, -0.891, 319.43, 145.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 177.74, 148.48, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.91, 0.407, -0.407, -0.914, 317.74, 148.48, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 175.93, 150.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.93, 0.358, -0.358, -0.934, 315.93, 150.93, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 173.98, 153.28, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.95, 0.309, -0.309, -0.951, 313.98, 153.28, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 171.92, 155.53, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.97, 0.259, -0.259, -0.966, 311.92, 155.53, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 169.74, 157.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.98, 0.208, -0.208, -0.978, 309.74, 157.67, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 167.45, 159.69, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.99, 0.156, -0.156, -0.988, 307.45, 159.69, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 165.06, 161.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-0.99, 0.105, -0.105, -0.995, 305.06, 161.59, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 162.58, 163.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-1.0, 0.052, -0.052, -0.999, 302.58, 163.36, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 520.0, 190.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 160.0, 165.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0], + [-1.0, 0.0, -0.0, -1.0, 300.0, 165.0, 60.0, 100.0], + [1.0, 0.0, 0.0, 1.0, 310.0, 65.0, 60.0, 100.0] + ] + ], + "m": [ + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [191.9, 520.0, 190.0, 0.0], + [398.3, 520.0, 190.0, 0.0], + [592.6, 520.0, 190.0, 0.0], + [774.3, 520.0, 190.0, 0.0], + [943.0, 520.0, 190.0, 0.0], + [1098.1, 520.0, 190.0, 0.0], + [1239.2, 520.0, 190.0, 0.0], + [1366.0, 520.0, 190.0, 0.0], + [1478.0, 520.0, 190.0, 0.0], + [1575.1, 520.0, 190.0, 0.0], + [1656.9, 520.0, 190.0, 0.0], + [1723.1, 520.0, 190.0, 0.0], + [1773.7, 520.0, 190.0, 0.0], + [1808.4, 520.0, 190.0, 0.0], + [1827.3, 520.0, 190.0, 0.0], + [1830.1, 520.0, 190.0, 0.0], + [1796.3, 520.0, 190.0, 0.0], + [1707.2, 520.0, 190.0, 0.0], + [1601.0, 520.0, 190.0, 0.0], + [1479.9, 520.0, 190.0, 0.0], + [1346.5, 520.0, 190.0, 0.0], + [1203.7, 520.0, 190.0, 0.0], + [1054.5, 520.0, 190.0, 0.0], + [901.9, 520.0, 190.0, 0.0], + [749.3, 520.0, 190.0, 0.0], + [600.0, 520.0, 190.0, 0.0], + [749.3, 520.0, 190.0, 0.0], + [901.9, 520.0, 190.0, 0.0], + [1054.5, 520.0, 190.0, 0.0], + [1203.7, 520.0, 190.0, 0.0], + [1346.5, 520.0, 190.0, 0.0], + [1479.9, 520.0, 190.0, 0.0], + [1601.0, 520.0, 190.0, 0.0], + [1707.2, 520.0, 190.0, 0.0], + [1796.3, 520.0, 190.0, 0.0], + [1830.1, 520.0, 190.0, 0.0], + [1827.3, 520.0, 190.0, 0.0], + [1808.4, 520.0, 190.0, 0.0], + [1773.7, 520.0, 190.0, 0.0], + [1723.1, 520.0, 190.0, 0.0], + [1656.9, 520.0, 190.0, 0.0], + [1575.1, 520.0, 190.0, 0.0], + [1478.0, 520.0, 190.0, 0.0], + [1366.0, 520.0, 190.0, 0.0], + [1239.2, 520.0, 190.0, 0.0], + [1098.1, 520.0, 190.0, 0.0], + [943.0, 520.0, 190.0, 0.0], + [774.3, 520.0, 190.0, 0.0], + [592.6, 520.0, 190.0, 0.0], + [398.3, 520.0, 190.0, 0.0], + [191.9, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0], + [0.0, 520.0, 190.0, 0.0] + ] + } + } + ] +} diff --git a/model-v2/a/dec0-fork/index.html b/model-v2/a/dec0-fork/index.html new file mode 100644 index 0000000000..2c2052e5f1 --- /dev/null +++ b/model-v2/a/dec0-fork/index.html @@ -0,0 +1,6837 @@ +anchor — DEC-0: the framing fork, in action + + +
+
+
+ model-v2 · anchor · DEC-0 — 2026-07-07 +
+

The framing fork, in action.

+

+ The same seven scenes, the same slider, resolved twice by the same lab: + left = layout-visible rotation (anchor as locked by E1; + Figma’s framing) · right = visual-only rotation (the + CSS framing — rotation as post-layout paint transform). Every frame + is real resolver output; the meters are measured on resolved world bounds. + Left tax: the fill×rotation pops (DEC-1). Right tax: overlap, + frozen containment, and broken Figma parity. Watch the meters. +

+
+ +
+ +
+
+ θ = 0° + + +
+
+
+

+ layout-visible — anchor / Figma (E1) +

+ +
+ sibling overlap 0 px² + container 0 + ink escape 0 px +
+
+
+

visual-only — the CSS framing

+ +
+ sibling overlap 0 px² + container 0 + ink escape 0 px +
+
+
+
+
+
+ + diff --git a/model-v2/a/dec0-visual-only.md b/model-v2/a/dec0-visual-only.md new file mode 100644 index 0000000000..f477d83500 --- /dev/null +++ b/model-v2/a/dec0-visual-only.md @@ -0,0 +1,105 @@ +# DEC-0 · visual-only, normative + +2026-07-07. The rule set for the flipped default — **rotation (and every +header transform) is a post-layout paint transform; sizing never reads +it; reads stay oriented** — written as the spec review DEC-0's flip was +gated on. Each rule names its consequence and its guarding test +(`lab/tests/visual_only.rs` unless noted). + +## The two tiers (the one sentence) + +- **Sizing tier** (phase M measure, phase L layout: flex contributions, + hug, spans, derived unions): reads the **untransformed box** — + rotation and flips do not exist here. CSS-pure: no exceptions, no + hybrid hug. +- **Read tier** (phase B and everything downstream: `world_aabb`, + selection chrome, hit-testing, culling): **oriented, always** — even + CSS reports post-transform bounds for reads. + +`xywh` (the box read) belongs to the sizing tier: it reports the stable +basis, not the ink. Ink bounds are `world_aabb`. Two named reads, two +meanings, both spec'd — never one value doing both jobs. + +## Rules + +- **V-1 · flex contribution** — a rotated in-flow child contributes its + unrotated box; slot = box; rotation paints about the box center. + Siblings do not make room; overlap is correct behavior. + (`r3` sheet column, `v1_*`) +- **V-2 · fill never fights rotation** — `grow` and `self_align:Stretch` + apply to the unrotated box regardless of rotation; continuous at + every angle by construction. E-A4/E-A11/E-A12 retire; DEC-1/DEC-2/ + DEC-3 close n/a. (`v2_*`) +- **V-3 · hug ignores transforms** — auto-sized containers (flex hug + AND free-context hug) measure children's unrotated boxes at their + pins. A rotated child's ink may escape its hug parent; that is the + accepted CSS wart, and the escape is visible in the read tier + (`world_aabb` ⊃ box). (`v3_*`) +- **V-4 · THE GROUP-BOX FORK, decided: derived boxes are sizing-tier.** + A group/lens box = union of members' **unrotated** boxes at their + pins (translation only — member rotation AND flips ignored). Chosen + over the oriented union because the derived box feeds flex/hug: an + oriented union would smuggle the envelope back into layout one + nesting level deep — the exact behavior DEC-0 removes. Consequences: + `xywh` of a group under-reports rotated ink (by design; ink = + `world_aabb`); selection chrome for derived kinds SHOULD use ink + bounds (spike HUD does). E-A1 origin placement and D-2 sibling + stability are untouched (the union is still union-shaped, just + unrotated). (`v4_*`) +- **V-5 · the group's own transform** — a group's OWN rotation/flip + still paints (origin pivot, E-A1/B1 unchanged) and still does not + feed its parent's sizing (the group contributes its unrotated union + box). (`v4_group_own_rotation_paints_only`) +- **V-6 · rotated parent rigidity (unchanged)** — layout runs in the + parent's local space; the assembly transforms as one body. Identical + in both arms and in CSS. (existing `rotation.rs` arm-pinned) +- **V-7 · the lens (unchanged mechanics, demoted distinction)** — + lens ops stay post-resolution paint; with header rotation now also + paint-only, `lens-rotate ≡ header-rotate` behaviorally. E-A8's lint + inverts: a lens containing ONLY `Rotate` is always redundant — use + header rotation. The lens remains the quarantine for shear/matrix/ + retained-scale. (existing `derived.rs` lens tests, arm-pinned where + needed) +- **V-8 · reads stay oriented** — `world_aabb`, pick, HUD chrome + geometry: unchanged from the anchor arm; flip/rotation fully visible + here. (existing `arena_pick.rs`, `geometry.rs`) +- **V-9 · reports** — the rotated-inert report class + (grow/stretch-ignored-when-rotated) must NOT fire: nothing is ignored + anymore. §8's matrix loses those rows. (`v2_no_inert_reports`) +- **V-10 · importer posture (COMPAT)** — Figma rotated-in-auto-layout + children import as `flow:Absolute` + pins at their resolved position + (geometry-exact, flow participation dropped); frequency to be + measured by E9. CSS `transform:rotate` imports 1:1 (this framing IS + CSS's). + +## What stays true from the anchor run (unchanged laws) + +E-A1 origins · E-A2/E-A14 flip semantics (flip was ALREADY +layout-invisible for boxed kinds; V-4 extends "sizing ignores flips" to +derived members) · E-A3 two stretches · E-A5 underdetermined bindings · +E-A6 format RMW (DEC-7 open) · E-A9/E-A10 points & scales · E-A13 group +constraint pass-through (orthogonal to rotation) · the ops layer, +write counts, typed walls · the IR · free context (no sizing consumer +of rotation existed there anyway — hug aside, which V-3 now covers). + +## Retirements + +- **E-A4, E-A11, E-A12** (grow/stretch × rotation policy) — the + configurations are legal and continuous now; nothing to declare. +- **E-A7** (envelope readout as layout explainer) — demoted to + selection info: basis vs ink is still worth showing, but mid-turn + slot width no longer changes, so nothing "reads as a bug". +- **E-A8** (context-scoped lens-rotate lint) — inverted, see V-7. +- **DEC-1, DEC-2, DEC-3** — closed n/a in the register. +- The dec0-fork and edge-cases artifacts remain as the DECISION RECORD + (they show both arms; the left panel is now the road not taken). + +## Review verdict + +The flip is semantics-narrow (one resolver read policy + the V-4 +definition) but the V-4 fork was REAL undefined behavior until decided +— a group with rotated members had four consumers reading one value +with two possible meanings. Decided sizing-tier; the suite now pins +both arms explicitly (the anchor arm's laws remain tested as the +documented alternative), and `visual_only.rs` is the new default's +conformance floor. diff --git a/model-v2/a/e1-rotation-in-flow/README.md b/model-v2/a/e1-rotation-in-flow/README.md new file mode 100644 index 0000000000..4d69bbc96d --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/README.md @@ -0,0 +1,39 @@ +# E1 — rotation-in-flow prototype + +**Question.** A rotated child inside a flex flow: does layout see it — the +oriented AABB participates and siblings make room (`anchor` §5) — or is +rotation paint-only, layout frozen, overlap correct (CSS `transform` +semantics)? The model-v2 triage left this as the spec's only open core +semantic (conformance **R-3**, editor **OP-ROT-2**), tilted layout-visible, +to be decided by measurement, not argument (triage #5). + +**Method.** Both semantics implemented behind one flag +([`RotationInFlow`](../lab/src/resolve.rs)) in the same resolver — nothing +else differs (guarded by the `flag_scope_is_limited_to_flow_rotation` +test). The triage-#27 scene — three 60×100 cards in a hug row (gap 10, +pad 10), middle card rotated — swept θ = 0…360° in 2° steps under both +modes, plus a fixed-width-container variant. Every step applied through +the op layer, asserting rotation stays a **1-field write** in flow. + +**Artifacts.** + +- [`metrics.csv`](./metrics.csv) — per-frame container width, sibling + position, peak sibling AABB overlap, per-step displacement. +- [`frames/theta_*.svg`](./frames) — side-by-side snapshots (anchor arm + above, visual-only control below, magenta dashed = world AABBs). +- [`demo.html`](./demo.html) — interactive scrubber + play button over the + precomputed frames; open in any browser to _feel_ both arms. +- Driver: [`../lab/src/bin/e1.rs`](../lab/src/bin/e1.rs) + (`cargo run --bin e1` from `../lab`). + +**Measured** (sweep summary, reproduced by the driver): + +| metric | anchor (AABB participates) | visual-only control | +| --------------------------- | -------------------------------------------------------- | ----------------------------------- | +| sibling overlap, any θ | **0 px², always** | up to **1830 px²** (≈31% of a card) | +| container (hug) | breathes 220 → 276.6 px (peak at θ\*≈59°) | frozen at 220 px | +| sibling displacement per 2° | ≤ 3.45 px, smooth (analytic bound √(w²+h²)·Δθ = 4.07 px) | 0 | +| envelope peak | θ\* = atan(h/w) ≈ 59°, **not** 90° | n/a | +| rotate gesture | 1 field write | 1 field write | + +The verdict lives in [`verdict.md`](./verdict.md). diff --git a/model-v2/a/e1-rotation-in-flow/demo.html b/model-v2/a/e1-rotation-in-flow/demo.html new file mode 100644 index 0000000000..0679f572d5 --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/demo.html @@ -0,0 +1,2257 @@ + + +E1 — rotation-in-flow: anchor vs visual-only + +

E1 — rotate a card inside an auto-flowing list

+

+ Top: anchor (rotated AABB participates — siblings make room, container + breathes).
+ Bottom: visual-only control (CSS transform semantics — layout frozen, + overlap). +

+
+ + + +
+ + + diff --git a/model-v2/a/e1-rotation-in-flow/frames/theta_000.svg b/model-v2/a/e1-rotation-in-flow/frames/theta_000.svg new file mode 100644 index 0000000000..c4d19f79a6 --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/frames/theta_000.svg @@ -0,0 +1,26 @@ + + +anchor: AABB participates — θ=0° + + + + + + + + + + + +control: visual-only (CSS) — θ=0° + + + + + + + + + + + diff --git a/model-v2/a/e1-rotation-in-flow/frames/theta_015.svg b/model-v2/a/e1-rotation-in-flow/frames/theta_015.svg new file mode 100644 index 0000000000..df40d01091 --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/frames/theta_015.svg @@ -0,0 +1,26 @@ + + +anchor: AABB participates — θ=15° + + + + + + + + + + + +control: visual-only (CSS) — θ=15° + + + + + + + + + + + diff --git a/model-v2/a/e1-rotation-in-flow/frames/theta_030.svg b/model-v2/a/e1-rotation-in-flow/frames/theta_030.svg new file mode 100644 index 0000000000..19d544aeac --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/frames/theta_030.svg @@ -0,0 +1,26 @@ + + +anchor: AABB participates — θ=30° + + + + + + + + + + + +control: visual-only (CSS) — θ=30° + + + + + + + + + + + diff --git a/model-v2/a/e1-rotation-in-flow/frames/theta_045.svg b/model-v2/a/e1-rotation-in-flow/frames/theta_045.svg new file mode 100644 index 0000000000..1bef369966 --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/frames/theta_045.svg @@ -0,0 +1,26 @@ + + +anchor: AABB participates — θ=45° + + + + + + + + + + + +control: visual-only (CSS) — θ=45° + + + + + + + + + + + diff --git a/model-v2/a/e1-rotation-in-flow/frames/theta_060.svg b/model-v2/a/e1-rotation-in-flow/frames/theta_060.svg new file mode 100644 index 0000000000..8c29c75c7a --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/frames/theta_060.svg @@ -0,0 +1,26 @@ + + +anchor: AABB participates — θ=60° + + + + + + + + + + + +control: visual-only (CSS) — θ=60° + + + + + + + + + + + diff --git a/model-v2/a/e1-rotation-in-flow/frames/theta_075.svg b/model-v2/a/e1-rotation-in-flow/frames/theta_075.svg new file mode 100644 index 0000000000..da75c455b5 --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/frames/theta_075.svg @@ -0,0 +1,26 @@ + + +anchor: AABB participates — θ=75° + + + + + + + + + + + +control: visual-only (CSS) — θ=75° + + + + + + + + + + + diff --git a/model-v2/a/e1-rotation-in-flow/frames/theta_090.svg b/model-v2/a/e1-rotation-in-flow/frames/theta_090.svg new file mode 100644 index 0000000000..672f255a7d --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/frames/theta_090.svg @@ -0,0 +1,26 @@ + + +anchor: AABB participates — θ=90° + + + + + + + + + + + +control: visual-only (CSS) — θ=90° + + + + + + + + + + + diff --git a/model-v2/a/e1-rotation-in-flow/metrics.csv b/model-v2/a/e1-rotation-in-flow/metrics.csv new file mode 100644 index 0000000000..61303d7b31 --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/metrics.csv @@ -0,0 +1,182 @@ +theta,aabb_container_w,aabb_card2_x,aabb_overlap,visual_container_w,visual_card2_x,visual_overlap,fixedw_aabb_card2_x,fixedw_aabb_overlap +0,220.000,150.000,0.000,220.000,150.000,0.000,150.000,0.000 +2,223.453,153.453,0.000,220.000,150.000,0.000,153.453,0.000 +4,226.829,156.829,0.000,220.000,150.000,0.000,156.829,0.000 +6,230.124,160.124,0.000,220.000,150.000,0.000,160.124,0.000 +8,233.333,163.333,0.000,220.000,150.000,0.000,163.333,0.000 +10,236.453,166.453,0.000,220.000,150.000,0.000,166.453,0.000 +12,239.480,169.480,0.000,220.000,150.000,0.000,169.480,0.000 +14,242.410,172.410,0.000,220.000,150.000,120.497,172.410,0.000 +16,245.239,175.239,0.000,220.000,150.000,261.972,175.239,0.000 +18,247.965,177.965,0.000,220.000,150.000,398.255,177.965,0.000 +20,250.584,180.584,0.000,220.000,150.000,529.179,180.584,0.000 +22,253.092,183.092,0.000,220.000,150.000,654.585,183.092,0.000 +24,255.486,185.486,0.000,220.000,150.000,774.319,185.486,0.000 +26,257.765,187.765,0.000,220.000,150.000,888.239,187.765,0.000 +28,259.924,189.924,0.000,220.000,150.000,996.201,189.924,0.000 +30,261.962,191.962,0.000,220.000,150.000,1098.077,191.962,0.000 +32,263.875,193.875,0.000,220.000,150.000,1193.741,193.875,0.000 +34,265.662,195.662,0.000,220.000,150.000,1283.078,195.662,0.000 +36,267.320,197.320,0.000,220.000,150.000,1365.978,197.320,0.000 +38,268.847,198.847,0.000,220.000,150.000,1442.340,198.847,0.000 +40,270.241,200.241,0.000,220.000,150.000,1512.071,200.241,0.000 +42,271.502,201.502,0.000,220.000,150.000,1575.088,201.502,0.000 +44,272.626,202.626,0.000,220.000,150.000,1631.312,202.626,0.000 +46,273.613,203.613,0.000,220.000,150.000,1680.675,203.613,0.000 +48,274.462,204.462,0.000,220.000,150.000,1723.117,204.462,0.000 +50,275.172,205.172,0.000,220.000,150.000,1758.586,205.172,0.000 +52,275.741,205.741,0.000,220.000,150.000,1787.039,205.741,0.000 +54,276.169,206.169,0.000,220.000,150.000,1808.441,206.169,0.000 +56,276.455,206.455,0.000,220.000,150.000,1822.767,206.455,0.000 +58,276.600,206.600,0.000,220.000,150.000,1829.999,206.600,0.000 +60,276.603,206.603,0.000,220.000,150.000,1830.128,206.603,0.000 +62,276.463,206.463,0.000,220.000,150.000,1821.768,206.463,0.000 +64,276.182,206.182,0.000,220.000,150.000,1768.648,206.182,0.000 +66,275.759,205.759,0.000,220.000,150.000,1707.237,205.759,0.000 +68,275.195,205.195,0.000,220.000,150.000,1638.171,205.195,0.000 +70,274.490,204.490,0.000,220.000,150.000,1562.135,204.490,0.000 +72,273.647,203.647,0.000,220.000,150.000,1479.866,203.647,0.000 +74,272.664,202.664,0.000,220.000,150.000,1392.148,202.664,0.000 +76,271.545,201.545,0.000,220.000,150.000,1299.806,201.545,0.000 +78,270.289,200.289,0.000,220.000,150.000,1203.704,200.289,0.000 +80,268.900,198.900,0.000,220.000,150.000,1104.738,198.900,0.000 +82,267.377,197.377,0.000,220.000,150.000,1003.832,197.377,0.000 +84,265.724,195.724,0.000,220.000,150.000,901.934,195.724,0.000 +86,263.942,193.942,0.000,220.000,150.000,800.009,193.942,0.000 +88,262.033,192.033,0.000,220.000,150.000,699.036,192.033,0.000 +90,260.000,190.000,0.000,220.000,150.000,600.000,190.000,0.000 +92,262.033,192.033,0.000,220.000,150.000,699.036,192.033,0.000 +94,263.942,193.942,0.000,220.000,150.000,800.009,193.942,0.000 +96,265.724,195.724,0.000,220.000,150.000,901.934,195.724,0.000 +98,267.377,197.377,0.000,220.000,150.000,1003.831,197.377,0.000 +100,268.900,198.900,0.000,220.000,150.000,1104.737,198.900,0.000 +102,270.289,200.289,0.000,220.000,150.000,1203.703,200.289,0.000 +104,271.545,201.545,0.000,220.000,150.000,1299.806,201.545,0.000 +106,272.664,202.664,0.000,220.000,150.000,1392.149,202.664,0.000 +108,273.647,203.647,0.000,220.000,150.000,1479.866,203.647,0.000 +110,274.490,204.490,0.000,220.000,150.000,1562.135,204.490,0.000 +112,275.195,205.195,0.000,220.000,150.000,1638.172,205.195,0.000 +114,275.759,205.759,0.000,220.000,150.000,1707.237,205.759,0.000 +116,276.182,206.182,0.000,220.000,150.000,1768.646,206.182,0.000 +118,276.463,206.463,0.000,220.000,150.000,1821.768,206.463,0.000 +120,276.603,206.603,0.000,220.000,150.000,1830.127,206.603,0.000 +122,276.600,206.600,0.000,220.000,150.000,1829.999,206.600,0.000 +124,276.455,206.455,0.000,220.000,150.000,1822.768,206.455,0.000 +126,276.169,206.169,0.000,220.000,150.000,1808.441,206.169,0.000 +128,275.741,205.741,0.000,220.000,150.000,1787.039,205.741,0.000 +130,275.172,205.172,0.000,220.000,150.000,1758.586,205.172,0.000 +132,274.462,204.462,0.000,220.000,150.000,1723.117,204.462,0.000 +134,273.613,203.613,0.000,220.000,150.000,1680.675,203.613,0.000 +136,272.626,202.626,0.000,220.000,150.000,1631.312,202.626,0.000 +138,271.502,201.502,0.000,220.000,150.000,1575.089,201.502,0.000 +140,270.241,200.241,0.000,220.000,150.000,1512.071,200.241,0.000 +142,268.847,198.847,0.000,220.000,150.000,1442.340,198.847,0.000 +144,267.320,197.320,0.000,220.000,150.000,1365.978,197.320,0.000 +146,265.662,195.662,0.000,220.000,150.000,1283.078,195.662,0.000 +148,263.875,193.875,0.000,220.000,150.000,1193.741,193.875,0.000 +150,261.962,191.962,0.000,220.000,150.000,1098.077,191.962,0.000 +152,259.924,189.924,0.000,220.000,150.000,996.201,189.924,0.000 +154,257.765,187.765,0.000,220.000,150.000,888.239,187.765,0.000 +156,255.486,185.486,0.000,220.000,150.000,774.319,185.486,0.000 +158,253.092,183.092,0.000,220.000,150.000,654.585,183.092,0.000 +160,250.584,180.584,0.000,220.000,150.000,529.179,180.584,0.000 +162,247.965,177.965,0.000,220.000,150.000,398.254,177.965,0.000 +164,245.239,175.239,0.000,220.000,150.000,261.972,175.239,0.000 +166,242.410,172.410,0.000,220.000,150.000,120.497,172.410,0.000 +168,239.480,169.480,0.000,220.000,150.000,0.000,169.480,0.000 +170,236.453,166.453,0.000,220.000,150.000,0.000,166.453,0.000 +172,233.333,163.333,0.000,220.000,150.000,0.000,163.333,0.000 +174,230.124,160.124,0.000,220.000,150.000,0.000,160.124,0.000 +176,226.829,156.829,0.000,220.000,150.000,0.000,156.829,0.000 +178,223.453,153.453,0.000,220.000,150.000,0.000,153.453,0.000 +180,220.000,150.000,0.000,220.000,150.000,0.000,150.000,0.000 +182,223.453,153.453,0.000,220.000,150.000,0.000,153.453,0.000 +184,226.829,156.829,0.000,220.000,150.000,0.000,156.829,0.000 +186,230.124,160.124,0.000,220.000,150.000,0.000,160.124,0.000 +188,233.333,163.333,0.000,220.000,150.000,0.000,163.333,0.000 +190,236.453,166.453,0.000,220.000,150.000,0.000,166.453,0.000 +192,239.480,169.480,0.000,220.000,150.000,0.000,169.480,0.000 +194,242.410,172.410,0.000,220.000,150.000,120.497,172.410,0.000 +196,245.239,175.239,0.000,220.000,150.000,261.972,175.239,0.000 +198,247.965,177.965,0.000,220.000,150.000,398.255,177.965,0.000 +200,250.584,180.584,0.000,220.000,150.000,529.179,180.584,0.000 +202,253.092,183.092,0.000,220.000,150.000,654.585,183.092,0.000 +204,255.486,185.486,0.000,220.000,150.000,774.319,185.486,0.000 +206,257.765,187.765,0.000,220.000,150.000,888.239,187.765,0.000 +208,259.924,189.924,0.000,220.000,150.000,996.201,189.924,0.000 +210,261.962,191.962,0.000,220.000,150.000,1098.077,191.962,0.000 +212,263.875,193.875,0.000,220.000,150.000,1193.741,193.875,0.000 +214,265.662,195.662,0.000,220.000,150.000,1283.078,195.662,0.000 +216,267.320,197.320,0.000,220.000,150.000,1365.977,197.320,0.000 +218,268.847,198.847,0.000,220.000,150.000,1442.340,198.847,0.000 +220,270.241,200.241,0.000,220.000,150.000,1512.071,200.241,0.000 +222,271.502,201.502,0.000,220.000,150.000,1575.089,201.502,0.000 +224,272.626,202.626,0.000,220.000,150.000,1631.311,202.626,0.000 +226,273.613,203.613,0.000,220.000,150.000,1680.675,203.613,0.000 +228,274.462,204.462,0.000,220.000,150.000,1723.117,204.462,0.000 +230,275.172,205.172,0.000,220.000,150.000,1758.585,205.172,0.000 +232,275.741,205.741,0.000,220.000,150.000,1787.038,205.741,0.000 +234,276.169,206.169,0.000,220.000,150.000,1808.441,206.169,0.000 +236,276.455,206.455,0.000,220.000,150.000,1822.767,206.455,0.000 +238,276.600,206.600,0.000,220.000,150.000,1829.999,206.600,0.000 +240,276.603,206.603,0.000,220.000,150.000,1830.127,206.603,0.000 +242,276.463,206.463,0.000,220.000,150.000,1821.768,206.463,0.000 +244,276.182,206.182,0.000,220.000,150.000,1768.646,206.182,0.000 +246,275.759,205.759,0.000,220.000,150.000,1707.237,205.759,0.000 +248,275.195,205.195,0.000,220.000,150.000,1638.172,205.195,0.000 +250,274.490,204.490,0.000,220.000,150.000,1562.135,204.490,0.000 +252,273.647,203.647,0.000,220.000,150.000,1479.867,203.647,0.000 +254,272.664,202.664,0.000,220.000,150.000,1392.148,202.664,0.000 +256,271.545,201.545,0.000,220.000,150.000,1299.806,201.545,0.000 +258,270.289,200.289,0.000,220.000,150.000,1203.705,200.289,0.000 +260,268.900,198.900,0.000,220.000,150.000,1104.737,198.900,0.000 +262,267.377,197.377,0.000,220.000,150.000,1003.832,197.377,0.000 +264,265.724,195.724,0.000,220.000,150.000,901.933,195.724,0.000 +266,263.942,193.942,0.000,220.000,150.000,800.009,193.942,0.000 +268,262.033,192.033,0.000,220.000,150.000,699.037,192.033,0.000 +270,260.000,190.000,0.000,220.000,150.000,600.000,190.000,0.000 +272,262.033,192.033,0.000,220.000,150.000,699.036,192.033,0.000 +274,263.942,193.942,0.000,220.000,150.000,800.009,193.942,0.000 +276,265.724,195.724,0.000,220.000,150.000,901.933,195.724,0.000 +278,267.377,197.377,0.000,220.000,150.000,1003.832,197.377,0.000 +280,268.900,198.900,0.000,220.000,150.000,1104.737,198.900,0.000 +282,270.289,200.289,0.000,220.000,150.000,1203.703,200.289,0.000 +284,271.545,201.545,0.000,220.000,150.000,1299.806,201.545,0.000 +286,272.664,202.664,0.000,220.000,150.000,1392.149,202.664,0.000 +288,273.647,203.647,0.000,220.000,150.000,1479.867,203.647,0.000 +290,274.490,204.490,0.000,220.000,150.000,1562.135,204.490,0.000 +292,275.195,205.195,0.000,220.000,150.000,1638.170,205.195,0.000 +294,275.759,205.759,0.000,220.000,150.000,1707.237,205.759,0.000 +296,276.182,206.182,0.000,220.000,150.000,1768.646,206.182,0.000 +298,276.463,206.463,0.000,220.000,150.000,1821.768,206.463,0.000 +300,276.603,206.603,0.000,220.000,150.000,1830.127,206.603,0.000 +302,276.600,206.600,0.000,220.000,150.000,1829.999,206.600,0.000 +304,276.455,206.455,0.000,220.000,150.000,1822.767,206.455,0.000 +306,276.169,206.169,0.000,220.000,150.000,1808.441,206.169,0.000 +308,275.741,205.741,0.000,220.000,150.000,1787.039,205.741,0.000 +310,275.172,205.172,0.000,220.000,150.000,1758.585,205.172,0.000 +312,274.462,204.462,0.000,220.000,150.000,1723.117,204.462,0.000 +314,273.613,203.613,0.000,220.000,150.000,1680.674,203.613,0.000 +316,272.626,202.626,0.000,220.000,150.000,1631.311,202.626,0.000 +318,271.502,201.502,0.000,220.000,150.000,1575.089,201.502,0.000 +320,270.241,200.241,0.000,220.000,150.000,1512.073,200.241,0.000 +322,268.847,198.847,0.000,220.000,150.000,1442.340,198.847,0.000 +324,267.320,197.320,0.000,220.000,150.000,1365.978,197.320,0.000 +326,265.662,195.662,0.000,220.000,150.000,1283.078,195.662,0.000 +328,263.875,193.875,0.000,220.000,150.000,1193.741,193.875,0.000 +330,261.962,191.962,0.000,220.000,150.000,1098.077,191.962,0.000 +332,259.924,189.924,0.000,220.000,150.000,996.201,189.924,0.000 +334,257.765,187.765,0.000,220.000,150.000,888.239,187.765,0.000 +336,255.486,185.486,0.000,220.000,150.000,774.319,185.486,0.000 +338,253.092,183.092,0.000,220.000,150.000,654.584,183.092,0.000 +340,250.584,180.584,0.000,220.000,150.000,529.179,180.584,0.000 +342,247.965,177.965,0.000,220.000,150.000,398.254,177.965,0.000 +344,245.239,175.239,0.000,220.000,150.000,261.972,175.239,0.000 +346,242.410,172.410,0.000,220.000,150.000,120.496,172.410,0.000 +348,239.480,169.480,0.000,220.000,150.000,0.000,169.480,0.000 +350,236.453,166.453,0.000,220.000,150.000,0.000,166.453,0.000 +352,233.333,163.333,0.000,220.000,150.000,0.000,163.333,0.000 +354,230.124,160.124,0.000,220.000,150.000,0.000,160.124,0.000 +356,226.829,156.829,0.000,220.000,150.000,0.000,156.829,0.000 +358,223.453,153.453,0.000,220.000,150.000,0.000,153.453,0.000 +360,220.000,150.000,0.000,220.000,150.000,0.000,150.000,0.000 diff --git a/model-v2/a/e1-rotation-in-flow/verdict.md b/model-v2/a/e1-rotation-in-flow/verdict.md new file mode 100644 index 0000000000..ab8a690510 --- /dev/null +++ b/model-v2/a/e1-rotation-in-flow/verdict.md @@ -0,0 +1,62 @@ +# E1 verdict — rotation-in-flow + +**Decision: lock layout-visible.** A rotated in-flow child participates in +flex by its oriented AABB (`w' = |w·cosθ| + |h·sinθ|`), box center placed +at the slot center — exactly a.md §5. Conformance **R-3** and editor +**OP-ROT-2** graduate from `POL` to `INV` on the anchor column. + +Run: 2026-07-07, `anchor-lab` @ `cargo run --bin e1` (metrics.csv is the +record; demo.html is the feel). + +## Why (measured, in triage order) + +1. **Canvas truth (#1).** The control arm produces up to **1830 px²** of + sibling overlap that _no document field expresses_ — the document says + "a list of three cards", the pixels say two of them collide. The anchor + arm never overlaps, at any angle, by construction and by measurement + (0 px² across the full sweep). This is the overlap-lie the triage's + canvas-truth answer refuses. +2. **It feels continuous (#5's fear, retired).** The plausible objection + to layout-visible rotation was jitter — layout reacting per frame. + Measured: sibling displacement is smooth and bounded by the analytic + envelope derivative (3.45 px per 2° observed vs 4.07 px bound; no + discontinuity anywhere, including through 0°/90°/180°). Rotating a card + in a list _reads as the list making room_, not as instability. +3. **The turn animates (#27).** The container breathes 56.6 px over a full + spin (peaking at θ\* = atan(h/w) ≈ 59°). For a _gesture_ this is the + point — the list visibly negotiates space. For _pure motion_ (a card + spinning forever) breathing would be noise — which is precisely the + spec's existing two-lane rule (§5): **motion rotation targets a lens + channel; only intent rotation is layout-visible.** E1 turns that rule + from doctrine into a measured necessity: the experiment _confirms_ the + two-lane split rather than weakening layout-visible. +4. **No gesture tax.** Rotation stays a single-field write in flow in both + arms (asserted every step of the sweep). Layout-visibility costs zero + write amplification; the reflow is resolver work, not document work. +5. **Predictability (#17).** The arithmetic an agent needs is one formula + (`|w·cosθ|+|h·sinθ|`); the E3 cold-prediction probe includes + rotated-in-flow cases — see + [`../e3-text-ir/verdict.md`](../e3-text-ir/verdict.md) for the scored + result feeding this clause. + +## Declared consequences (into the spec at phase 3) + +- R-3 → INV: "siblings make room for the rotated AABB; contribution is + computed from resolved size only, never position" (single-pass safety + stays load-bearing). +- OP-ROT-2 → INV: rotate-in-flow = 1 write; the editor previews reflow + live during the gesture. +- **Envelope-peak note for the editor layer**: the AABB maximum is at + θ\* = atan(h/w), not 90° — a card is _widest_ mid-turn. HUD affordances + (e.g. snapping readouts) should surface the envelope, or users will + read the pre-90° maximum as a bug. Recorded as an editor.md follow-up, + not a model change. +- Fixed-width containers don't breathe (measured identical displacement, + zero container change) — breathing is a hug-container phenomenon only. + +## What would have reversed this + +If the sweep had shown displacement discontinuities (slot reordering +mid-turn), unbounded per-degree movement, or gesture write amplification, +the tilt would have flipped to visual-only + an explicit opt-in switch. +None occurred. diff --git a/model-v2/a/e2-format/README.md b/model-v2/a/e2-format/README.md new file mode 100644 index 0000000000..927efc32fa --- /dev/null +++ b/model-v2/a/e2-format/README.md @@ -0,0 +1,52 @@ +# E2 — anchor.fbs draft + codec round-trip + +**Question.** Can the anchor header — per-axis `AxisBinding` unions, +`SizeIntent` unions, nullable min/max boxes, payload union — encode under +the grida.fbs header rules (H9: tables-over-structs, additive evolution; +H11: unset is structural, never a scalar sentinel), and does the codec +round-trip? + +**Method.** [`anchor.fbs`](./anchor.fbs) is the standalone schema draft of +models/a.md §9 (geometry header full-fidelity; identity/paint stubbed). +[`quartet.json`](./quartet.json) encodes the a.md §7 worked examples plus +sentinel-freedom witnesses (`SizeAuto` vs absent, `max_lines` box, +aspect-ratio table, lens op vector). No generated code needed — +`flatc` (25.12.19) itself is the codec: JSON → binary → JSON → binary. +Reproduce with [`run.sh`](./run.sh). + +**Results (2026-07-07).** All pass: + +- **schema compiles** (`flatc --binary --schema`), v1 and v2. +- **S-1 fixpoint**: encode → decode → re-encode is **byte-identical**. +- **S-2**: f32 values (15.0, 0.25, 3.5, 16:9) exact through the trip. +- **H11 witnessed in the decoded JSON**: a node with no `x`/`y` keys + (⇒ `Pin{Start,0}` by rule) is structurally distinct from an explicit + `Span{0,0}`; `height_type: SizeAuto` (present, empty) is distinct from + absent height (⇒ kind default); absent `max_lines` box ⇒ unlimited — + the `max_lines: 0 ≡ unset` class of hack is unrepresentable. +- **M-4 forward**: v1 binary decodes under a v2 schema (new nullable + header field + new payload arm) — new fields read as absent. +- **M-4 backward**: v2 binary decodes under the v1 schema — unknown + fields skipped safely (vtables). + +**Findings** (into the phase-3 spec): + +1. **RMW-preservation is a policy, not a freebie.** FlatBuffers _reads_ + unknown fields safely, but a decode→re-encode through an old schema + **drops** them (verified: `corner_radius` vanishes from the v1 + projection). M-4's "preserves unknown content through + read-modify-write" needs an explicit mechanism: retain the original + buffer and patch, or version-gate writers to the newest schema. + Phase-3 must lock one. +2. **Scalar-default elision is aggressive and fine — but choose defaults + as semantics.** `parent.id: 0` and `root: 0` elide (default-valued); + absence-of-table vs present-table-with-defaults carries the real + distinction. Rule of thumb confirmed: _state lives in table presence; + scalar defaults must equal the semantic default_ — exactly how the + schema is drafted. +3. Union-vector lens ops (`[LensOpSlot]` wrapping `LensOpU`) round-trip + cleanly — the "each op parameter is an animation channel" encoding + works without codegen tricks. + +Verdict: **encodable, round-trips, evolves additively — pass**, with +finding 1 as the one genuine open policy for phase 3. diff --git a/model-v2/a/e2-format/anchor.fbs b/model-v2/a/e2-format/anchor.fbs new file mode 100644 index 0000000000..4651399f67 --- /dev/null +++ b/model-v2/a/e2-format/anchor.fbs @@ -0,0 +1,134 @@ +// anchor.fbs — E2 draft of the anchor model encoding (models/a.md §9). +// +// Written against the grida.fbs header rules: +// - intent states are STRUCTURAL (unions / nullable tables), never scalar +// sentinels (H11): absent AxisBinding ⇒ Pin{Start,0}; absent SizeIntent +// ⇒ the kind default; absent MinMax ⇒ unconstrained; absent MaxLines ⇒ +// unlimited (the current `max_lines: 0 ≡ unset` hack is unrepresentable). +// - tables over structs everywhere evolution is conceivable. +// - evolution is additive: new payloads extend NodePayload; new binding +// forms extend AxisBinding (e.g. a future PinPercent), new layout modes +// extend LayoutBehavior. +// +// This is a lab-standalone schema: identity/asset/paint tables from the +// real grida.fbs are stubbed to keep E2 focused on the geometry header. + +namespace grida.anchor; + +// ---------- geometry intent ---------- + +enum AnchorEdge : byte { Start = 0, Center = 1, End = 2 } + +table Pin { anchor: AnchorEdge = Start; offset: float = 0; } +table Span { start: float = 0; end: float = 0; } +union AxisBinding { Pin, Span } // absent ⇒ Pin{Start, 0} + +table SizeFixed { value: float; } +table SizeAuto {} +union SizeIntent { SizeFixed, SizeAuto } // absent ⇒ kind default (a.md §4) + +// nullable scalar boxes — structural unset, no sentinel values +table FloatBox { value: float; } +table UInt32Box { value: uint32; } +table StringBox { value: string; } + +table AspectRatio { w: float; h: float; } + +enum Flow : byte { InFlow = 0, Absolute = 1 } +enum SelfAlign : byte { Auto = 0, Start = 1, Center = 2, End = 3, Stretch = 4 } + +// ---------- hierarchy ---------- + +table ParentRef { + id: uint32; + order: string; // fractional index (S-4) +} + +// ---------- the uniform header ---------- + +table NodeHeader { + id: uint32; + name: string; + active: bool = true; + locked: bool = false; + parent: ParentRef; // absent ⇒ scene root + + x: AxisBinding; + y: AxisBinding; + width: SizeIntent; + height: SizeIntent; + min_width: FloatBox; // nullable tables, not sentinels + max_width: FloatBox; + min_height: FloatBox; + max_height: FloatBox; + aspect_ratio: AspectRatio; // absent ⇒ unset (no (0,0) hack) + rotation: float = 0; // degrees; pivot per a.md §5 + + flow: Flow = InFlow; + grow: float = 0; + self_align: SelfAlign = Auto; + + opacity: float = 1; +} + +// ---------- payloads (lab subset; the full set is additive) ---------- + +enum LayoutMode : byte { None = 0, Flex = 1 } +enum Direction : byte { Row = 0, Column = 1 } +enum MainAlign : byte { Start = 0, Center = 1, End = 2, SpaceBetween = 3, SpaceAround = 4, SpaceEvenly = 5 } +enum CrossAlign : byte { Start = 0, Center = 1, End = 2, Stretch = 3 } + +table EdgeInsets { top: float = 0; right: float = 0; bottom: float = 0; left: float = 0; } + +table LayoutBehavior { + mode: LayoutMode = None; + direction: Direction = Row; + wrap: bool = false; + main_align: MainAlign = Start; + cross_align: CrossAlign = Start; + padding: EdgeInsets; + gap_main: float = 0; + gap_cross: float = 0; +} + +// shape descriptors are size-free; evaluated at the resolved box (a.md §3.2) +table RectDesc {} +table EllipseDesc { inner_ratio: float = 0; start_angle: float = 0; sweep: float = 360; } +table LineDesc {} +table RegularPolygonDesc { points: uint32; } +table StarDesc { points: uint32; inner_ratio: float; } +union ShapeDesc { RectDesc, EllipseDesc, LineDesc, RegularPolygonDesc, StarDesc } + +table FramePayload { layout: LayoutBehavior; clips_content: bool = false; } +table ShapePayload { desc: ShapeDesc; } +table TextPayload { + content: string; + font_size: float = 16; + max_lines: UInt32Box; // absent ⇒ unlimited (T-2 witness) + ellipsis: StringBox; // absent ⇒ "…"; empty string ⇒ none +} +table GroupPayload {} + +// each lens op parameter is a named scalar — an animation channel (a.md §3.3) +table TranslateOp { x: float = 0; y: float = 0; } +table RotateOp { deg: float = 0; } +table ScaleOp { x: float = 1; y: float = 1; } +table SkewOp { x_deg: float = 0; y_deg: float = 0; } +table Matrix2D { m00: float; m01: float; m02: float; m10: float; m11: float; m12: float; } +union LensOpU { TranslateOp, RotateOp, ScaleOp, SkewOp, Matrix2D } +table LensOpSlot { op: LensOpU; } +table LensPayload { ops: [LensOpSlot]; } + +union NodePayload { FramePayload, ShapePayload, TextPayload, GroupPayload, LensPayload } + +table NodeSlot { + header: NodeHeader (required); + payload: NodePayload; +} + +table AnchorDocument { + nodes: [NodeSlot]; + root: uint32; +} + +root_type AnchorDocument; diff --git a/model-v2/a/e2-format/anchor_v2.fbs b/model-v2/a/e2-format/anchor_v2.fbs new file mode 100644 index 0000000000..4a9297a875 --- /dev/null +++ b/model-v2/a/e2-format/anchor_v2.fbs @@ -0,0 +1,136 @@ +// anchor.fbs — E2 draft of the anchor model encoding (models/a.md §9). +// +// Written against the grida.fbs header rules: +// - intent states are STRUCTURAL (unions / nullable tables), never scalar +// sentinels (H11): absent AxisBinding ⇒ Pin{Start,0}; absent SizeIntent +// ⇒ the kind default; absent MinMax ⇒ unconstrained; absent MaxLines ⇒ +// unlimited (the current `max_lines: 0 ≡ unset` hack is unrepresentable). +// - tables over structs everywhere evolution is conceivable. +// - evolution is additive: new payloads extend NodePayload; new binding +// forms extend AxisBinding (e.g. a future PinPercent), new layout modes +// extend LayoutBehavior. +// +// This is a lab-standalone schema: identity/asset/paint tables from the +// real grida.fbs are stubbed to keep E2 focused on the geometry header. + +namespace grida.anchor; + +// ---------- geometry intent ---------- + +enum AnchorEdge : byte { Start = 0, Center = 1, End = 2 } + +table Pin { anchor: AnchorEdge = Start; offset: float = 0; } +table Span { start: float = 0; end: float = 0; } +union AxisBinding { Pin, Span } // absent ⇒ Pin{Start, 0} + +table SizeFixed { value: float; } +table SizeAuto {} +union SizeIntent { SizeFixed, SizeAuto } // absent ⇒ kind default (a.md §4) + +// nullable scalar boxes — structural unset, no sentinel values +table FloatBox { value: float; } +table UInt32Box { value: uint32; } +table StringBox { value: string; } + +table AspectRatio { w: float; h: float; } + +enum Flow : byte { InFlow = 0, Absolute = 1 } +enum SelfAlign : byte { Auto = 0, Start = 1, Center = 2, End = 3, Stretch = 4 } + +// ---------- hierarchy ---------- + +table ParentRef { + id: uint32; + order: string; // fractional index (S-4) +} + +// ---------- the uniform header ---------- + +table NodeHeader { + id: uint32; + name: string; + active: bool = true; + locked: bool = false; + parent: ParentRef; // absent ⇒ scene root + + x: AxisBinding; + y: AxisBinding; + width: SizeIntent; + height: SizeIntent; + min_width: FloatBox; // nullable tables, not sentinels + max_width: FloatBox; + min_height: FloatBox; + max_height: FloatBox; + aspect_ratio: AspectRatio; // absent ⇒ unset (no (0,0) hack) + rotation: float = 0; // degrees; pivot per a.md §5 + + flow: Flow = InFlow; + grow: float = 0; + self_align: SelfAlign = Auto; + + opacity: float = 1; + corner_radius: FloatBox; // v2 additive field +} + +// ---------- payloads (lab subset; the full set is additive) ---------- + +enum LayoutMode : byte { None = 0, Flex = 1 } +enum Direction : byte { Row = 0, Column = 1 } +enum MainAlign : byte { Start = 0, Center = 1, End = 2, SpaceBetween = 3, SpaceAround = 4, SpaceEvenly = 5 } +enum CrossAlign : byte { Start = 0, Center = 1, End = 2, Stretch = 3 } + +table EdgeInsets { top: float = 0; right: float = 0; bottom: float = 0; left: float = 0; } + +table LayoutBehavior { + mode: LayoutMode = None; + direction: Direction = Row; + wrap: bool = false; + main_align: MainAlign = Start; + cross_align: CrossAlign = Start; + padding: EdgeInsets; + gap_main: float = 0; + gap_cross: float = 0; +} + +// shape descriptors are size-free; evaluated at the resolved box (a.md §3.2) +table RectDesc {} +table EllipseDesc { inner_ratio: float = 0; start_angle: float = 0; sweep: float = 360; } +table LineDesc {} +table RegularPolygonDesc { points: uint32; } +table StarDesc { points: uint32; inner_ratio: float; } +union ShapeDesc { RectDesc, EllipseDesc, LineDesc, RegularPolygonDesc, StarDesc } + +table FramePayload { layout: LayoutBehavior; clips_content: bool = false; } +table ShapePayload { desc: ShapeDesc; } +table TextPayload { + content: string; + font_size: float = 16; + max_lines: UInt32Box; // absent ⇒ unlimited (T-2 witness) + ellipsis: StringBox; // absent ⇒ "…"; empty string ⇒ none +} +table GroupPayload {} + +// each lens op parameter is a named scalar — an animation channel (a.md §3.3) +table TranslateOp { x: float = 0; y: float = 0; } +table RotateOp { deg: float = 0; } +table ScaleOp { x: float = 1; y: float = 1; } +table SkewOp { x_deg: float = 0; y_deg: float = 0; } +table Matrix2D { m00: float; m01: float; m02: float; m10: float; m11: float; m12: float; } +union LensOpU { TranslateOp, RotateOp, ScaleOp, SkewOp, Matrix2D } +table LensOpSlot { op: LensOpU; } +table LensPayload { ops: [LensOpSlot]; } + +table EmbedPayload { source: string; } +union NodePayload { FramePayload, ShapePayload, TextPayload, GroupPayload, LensPayload, EmbedPayload } + +table NodeSlot { + header: NodeHeader (required); + payload: NodePayload; +} + +table AnchorDocument { + nodes: [NodeSlot]; + root: uint32; +} + +root_type AnchorDocument; diff --git a/model-v2/a/e2-format/quartet.json b/model-v2/a/e2-format/quartet.json new file mode 100644 index 0000000000..eb519dbc0e --- /dev/null +++ b/model-v2/a/e2-format/quartet.json @@ -0,0 +1,140 @@ +{ + "nodes": [ + { + "header": { + "id": 0, + "name": "root", + "x": { "start": 0.0, "end": 0.0 }, + "x_type": "Span", + "y": { "start": 0.0, "end": 0.0 }, + "y_type": "Span" + }, + "payload_type": "FramePayload", + "payload": {} + }, + { + "header": { + "id": 1, + "name": "a-rotated-rect", + "parent": { "id": 0, "order": "a0" }, + "x": { "offset": 10.0 }, + "x_type": "Pin", + "y": { "offset": 20.0 }, + "y_type": "Pin", + "width": { "value": 120.0 }, + "width_type": "SizeFixed", + "height": { "value": 80.0 }, + "height_type": "SizeFixed", + "rotation": 15.0 + }, + "payload_type": "ShapePayload", + "payload": { "desc_type": "RectDesc", "desc": {} } + }, + { + "header": { + "id": 2, + "name": "b-end-pinned", + "parent": { "id": 0, "order": "a1" }, + "x": { "anchor": "End", "offset": 24.0 }, + "x_type": "Pin", + "y": { "offset": 20.0 }, + "y_type": "Pin", + "width": { "value": 120.0 }, + "width_type": "SizeFixed", + "height": { "value": 80.0 }, + "height_type": "SizeFixed" + }, + "payload_type": "ShapePayload", + "payload": { "desc_type": "RectDesc", "desc": {} } + }, + { + "header": { + "id": 3, + "name": "c-flex-column", + "parent": { "id": 0, "order": "a2" }, + "width": { "value": 400.0 }, + "width_type": "SizeFixed", + "height": {}, + "height_type": "SizeAuto" + }, + "payload_type": "FramePayload", + "payload": { + "layout": { + "mode": "Flex", + "direction": "Column", + "gap_main": 8.0, + "padding": { + "top": 16.0, + "right": 16.0, + "bottom": 16.0, + "left": 16.0 + } + } + } + }, + { + "header": { + "id": 4, + "name": "stretch-rect", + "parent": { "id": 3, "order": "a0" }, + "width": { "value": 100.0 }, + "width_type": "SizeFixed", + "height": { "value": 40.0 }, + "height_type": "SizeFixed", + "self_align": "Stretch" + }, + "payload_type": "ShapePayload", + "payload": { "desc_type": "RectDesc", "desc": {} } + }, + { + "header": { + "id": 5, + "name": "grow-text", + "parent": { "id": 3, "order": "a1" }, + "grow": 1.0 + }, + "payload_type": "TextPayload", + "payload": { "content": "hello", "max_lines": { "value": 2 } } + }, + { + "header": { + "id": 6, + "name": "d-group", + "parent": { "id": 0, "order": "a3" }, + "x": { "offset": 100.0 }, + "x_type": "Pin", + "y": { "offset": 50.0 }, + "y_type": "Pin", + "rotation": 30.0 + }, + "payload_type": "GroupPayload", + "payload": {} + }, + { + "header": { + "id": 7, + "name": "e-lens", + "parent": { "id": 0, "order": "a4" }, + "aspect_ratio": { "w": 16.0, "h": 9.0 } + }, + "payload_type": "LensPayload", + "payload": { + "ops": [ + { "op_type": "SkewOp", "op": { "x_deg": 20.0 } }, + { + "op_type": "Matrix2D", + "op": { + "m00": 1.0, + "m01": 0.25, + "m02": 0.0, + "m10": 0.0, + "m11": 1.0, + "m12": 3.5 + } + } + ] + } + } + ], + "root": 0 +} diff --git a/model-v2/a/e2-format/quartet_v2.json b/model-v2/a/e2-format/quartet_v2.json new file mode 100644 index 0000000000..af822d9c15 --- /dev/null +++ b/model-v2/a/e2-format/quartet_v2.json @@ -0,0 +1,141 @@ +{ + "nodes": [ + { + "header": { + "id": 0, + "name": "root", + "x": { "start": 0.0, "end": 0.0 }, + "x_type": "Span", + "y": { "start": 0.0, "end": 0.0 }, + "y_type": "Span" + }, + "payload_type": "FramePayload", + "payload": {} + }, + { + "header": { + "id": 1, + "name": "a-rotated-rect", + "parent": { "id": 0, "order": "a0" }, + "x": { "offset": 10.0 }, + "x_type": "Pin", + "y": { "offset": 20.0 }, + "y_type": "Pin", + "width": { "value": 120.0 }, + "width_type": "SizeFixed", + "height": { "value": 80.0 }, + "height_type": "SizeFixed", + "rotation": 15.0, + "corner_radius": { "value": 8.0 } + }, + "payload_type": "ShapePayload", + "payload": { "desc_type": "RectDesc", "desc": {} } + }, + { + "header": { + "id": 2, + "name": "b-end-pinned", + "parent": { "id": 0, "order": "a1" }, + "x": { "anchor": "End", "offset": 24.0 }, + "x_type": "Pin", + "y": { "offset": 20.0 }, + "y_type": "Pin", + "width": { "value": 120.0 }, + "width_type": "SizeFixed", + "height": { "value": 80.0 }, + "height_type": "SizeFixed" + }, + "payload_type": "ShapePayload", + "payload": { "desc_type": "RectDesc", "desc": {} } + }, + { + "header": { + "id": 3, + "name": "c-flex-column", + "parent": { "id": 0, "order": "a2" }, + "width": { "value": 400.0 }, + "width_type": "SizeFixed", + "height": {}, + "height_type": "SizeAuto" + }, + "payload_type": "FramePayload", + "payload": { + "layout": { + "mode": "Flex", + "direction": "Column", + "gap_main": 8.0, + "padding": { + "top": 16.0, + "right": 16.0, + "bottom": 16.0, + "left": 16.0 + } + } + } + }, + { + "header": { + "id": 4, + "name": "stretch-rect", + "parent": { "id": 3, "order": "a0" }, + "width": { "value": 100.0 }, + "width_type": "SizeFixed", + "height": { "value": 40.0 }, + "height_type": "SizeFixed", + "self_align": "Stretch" + }, + "payload_type": "ShapePayload", + "payload": { "desc_type": "RectDesc", "desc": {} } + }, + { + "header": { + "id": 5, + "name": "grow-text", + "parent": { "id": 3, "order": "a1" }, + "grow": 1.0 + }, + "payload_type": "TextPayload", + "payload": { "content": "hello", "max_lines": { "value": 2 } } + }, + { + "header": { + "id": 6, + "name": "d-group", + "parent": { "id": 0, "order": "a3" }, + "x": { "offset": 100.0 }, + "x_type": "Pin", + "y": { "offset": 50.0 }, + "y_type": "Pin", + "rotation": 30.0 + }, + "payload_type": "GroupPayload", + "payload": {} + }, + { + "header": { + "id": 7, + "name": "e-lens", + "parent": { "id": 0, "order": "a4" }, + "aspect_ratio": { "w": 16.0, "h": 9.0 } + }, + "payload_type": "LensPayload", + "payload": { + "ops": [ + { "op_type": "SkewOp", "op": { "x_deg": 20.0 } }, + { + "op_type": "Matrix2D", + "op": { + "m00": 1.0, + "m01": 0.25, + "m02": 0.0, + "m10": 0.0, + "m11": 1.0, + "m12": 3.5 + } + } + ] + } + } + ], + "root": 0 +} diff --git a/model-v2/a/e2-format/run.sh b/model-v2/a/e2-format/run.sh new file mode 100755 index 0000000000..0e8b065720 --- /dev/null +++ b/model-v2/a/e2-format/run.sh @@ -0,0 +1,14 @@ +#!/bin/sh -e +# E2 reproduction: schema compile, S-1 byte-fixpoint, additive evolution. +flatc --binary --schema anchor.fbs +flatc --binary --schema anchor_v2.fbs +rm -rf rt rt2 rt_v2 rt_v2b && mkdir -p rt rt2 rt_v2 rt_v2b +flatc --binary -o rt anchor.fbs quartet.json +flatc --json --raw-binary --strict-json -o rt anchor.fbs -- rt/quartet.bin +flatc --binary -o rt2 anchor.fbs rt/quartet.json +cmp rt/quartet.bin rt2/quartet.bin && echo "S-1 FIXPOINT: byte-identical" +flatc --json --raw-binary --strict-json -o rt_v2 anchor_v2.fbs -- rt/quartet.bin +echo "M-4 forward: v1 binary decodes under v2 schema" +flatc --binary -o rt_v2b anchor_v2.fbs quartet_v2.json +flatc --json --raw-binary --strict-json -o rt_v2b anchor.fbs -- rt_v2b/quartet_v2.bin +echo "M-4 backward: v2 binary decodes under v1 schema (unknown field skipped)" diff --git a/model-v2/a/e3-text-ir/README.md b/model-v2/a/e3-text-ir/README.md new file mode 100644 index 0000000000..2230ff66bb --- /dev/null +++ b/model-v2/a/e3-text-ir/README.md @@ -0,0 +1,41 @@ +# E3 — the agent text IR + +**Question.** Triage amendment 3 makes an XML-ish textual projection a +first-class product surface whose audience is LLMs: an agent must be able +to _write_ a document and _predict its resolved geometry mentally_ +(triage #17 "critical", #20 "the agent should be tricked it's writing a +file"). Is the anchor model actually mentally simulable from a spec alone? + +**Deliverables here.** + +- [`grammar.md`](./grammar.md) — the pocket grammar + hand-resolution + semantics (the exact text given to probe agents; also the seed of the + phase-3 text-IR spec). +- Reference implementation: parser + canonical printer in + [`../lab/src/textir.rs`](../lab/src/textir.rs), with round-trip tests + (`parse ∘ print` fixpoint; a.md §7 quartet expressed and resolved — + `../lab/tests/textir_suite.rs`). +- [`probes/`](./probes) — six probe documents: bindings/span (p1), flex + grow + cross alignment (p2), hug + text wrap + stretch (p3), free + rotation AABB (p4), **rotated-in-flow** under the E1-locked rule (p5), + group union + origin placement (p6). +- [`truth.txt`](./truth.txt) — resolver ground truth + (`cargo run --bin e3 -- truth`). +- [`predictions/`](./predictions) — raw agent outputs, scored by + `cargo run --bin e3 -- score ` (tolerance 0.5 px). + +**Probe protocol.** Three fresh agents (two Fable-class, one Haiku-class +as a small-model control), given ONLY the grammar text and the six +documents inline. No tools, no resolver, no prior context from this +workbench. Output: `file,node,box|aabb,x,y,w,h` lines for every named +node (22 lines). + +**Results (2026-07-07).** + +| agent | score | failures | +| ------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fable-a | **22/22** | — | +| fable-b | **22/22** | — | +| haiku-c | 17/22 | p3: counted 15 chars as 14 (wrap off-by-one) and stretched a fixed-width shape against the stated rule; p5: stated the slot-center rule correctly, then placed the box at slot _start_ | + +The verdict lives in [`verdict.md`](./verdict.md). diff --git a/model-v2/a/e3-text-ir/grammar.md b/model-v2/a/e3-text-ir/grammar.md new file mode 100644 index 0000000000..6ad2aac8d2 --- /dev/null +++ b/model-v2/a/e3-text-ir/grammar.md @@ -0,0 +1,143 @@ +# The anchor text IR — pocket grammar & resolution semantics + +An anchor document is XML. The root element is a ``. Every element +is a node; nesting is the scene tree. This document is **complete**: an +agent holding only this file can compute the resolved geometry of any +document in the subset by hand. + +## Elements + +| element | children | box | +| ------------------------------------ | ----------------- | ------------------------------------------------ | +| `` | yes | declared (`w`/`h`), `auto` = hug children | +| `` | no | declared (`w`/`h` required) | +| `` | no (text content) | measured from content | +| `` | yes | derived = union of children | +| `` | yes | derived; `ops` apply to paint only, never layout | + +## Common attributes + +- `name` — identifier (used to report results). +- `x`, `y` — position binding, only effective when the parent does **not** + lay the node out (see flex). Forms: + - `"12"` → offset 12 from parent's left/top edge (start pin) + - `"end 24"` → node's right/bottom edge sits 24 from parent's right/bottom + - `"center"` / `"center 6"` → centered in parent (+ optional offset) + - `"span 30 50"` → both edges bound: starts at 30, ends 50 from the far + edge; **the span owns the size on that axis** (w/h ignored there) +- `w`, `h` — `"120"` (fixed px) or `"auto"` (text: measured; frame: hug). +- `rotation` — degrees, clockwise, y-down. See §Rotation. +- `flow="absolute"` — opt out of a flex parent's layout; x/y bindings + apply against the parent box instead. +- `grow` — flex main-axis growth factor (default 0). +- `align` — per-child cross-axis override: `start|center|end|stretch`. +- Frame only: `layout="flex"`, `direction="row|column"` (default row), + `gap="G"`, `padding="P"` (all four sides; or `"t r b l"`), + `main="start|center|end|space-between|space-around|space-evenly"`, + `cross="start|center|end|stretch"` (default start). +- Text only: `size="16"` — font size (default 16). + +## Resolution — how to compute geometry by hand + +Report a node's **box** as `(x, y, w, h)` in its **parent's coordinate +space**, _before_ rotation is applied. Rotation never changes the box; it +changes where the node paints (its world AABB). + +### 1. Text measurement (exact, deterministic) + +- character advance = `0.6 × size`; every character counts (spaces too) +- line height = `1.2 × size` +- unconstrained (auto width): one line — `w = chars × 0.6 × size`, + `h = 1.2 × size` +- constrained to width `W` (fixed or stretched): greedy word wrap: + max chars per line = `floor(W / (0.6 × size))`; words (split on single + spaces) are packed left to right; a joining space costs 1 char; a word + never breaks. `w = widest line's chars × 0.6 × size`, + `h = lines × 1.2 × size`. (Reported w may be smaller than W only when + width is `auto`; a fixed or stretched width reports that width.) + +### 2. Free placement (parent has no `layout`, or `flow="absolute"`) + +With parent box extent `E` on an axis and the node's resolved extent `s`: + +| binding | resolved start | +| ------------- | ----------------------------- | +| `"o"` (start) | `o` | +| `"end o"` | `E − o − s` | +| `"center o"` | `(E − s)/2 + o` | +| `"span a b"` | start `a`, extent `E − a − b` | + +### 3. Flex (`layout="flex"`) + +Standard flexbox, simplified and exact: + +- content box = frame box minus padding. +- each in-flow child contributes its **basis** on the main axis: its + resolved size (text: measured per §1; frames: hug per §5; rotated: §4). +- children are placed in order from the content box start, separated by + `gap`. `main` alignment distributes leftover space like CSS + justify-content. +- `grow`: leftover = content main extent − (Σ bases + gaps). Each grower + gets `leftover × grow/Σgrow` **added** to its basis. Leftover only + exists when the container's main size is fixed — an `auto` (hug) + container has none, so grow adds nothing there. Nothing ever shrinks. +- cross axis: default each child sits at content-box start (`cross`/its + `align` may center/end it: offset by `(C − s)` or `(C − s)/2` with `C` + the content cross extent). Stretch, two flavors: + - the container's `cross="stretch"` stretches only children whose cross + size is `auto` (a stretched text re-wraps at the stretched width; a + shape with a fixed cross size keeps it); + - a child's own `align="stretch"` means **fill** — it overrides even a + fixed cross size (this is the format's only cross-axis fill). +- x/y attributes on in-flow children are **ignored** (layout owns them). + +### 4. Rotation + +- Pivot: the **box center**. The box `(x,y,w,h)` is unchanged by rotation. +- World AABB of a rotated w×h box: + `w' = |w·cosθ| + |h·sinθ|`, `h' = |w·sinθ| + |h·cosθ|`, **concentric + with the box** (same center). +- **In flex flow (the locked rule):** a rotated child participates with + its AABB: its main/cross contribution is `(w', h')` instead of `(w, h)`. + It gets a `(w', h')`-sized slot like any other child, and its **box + center is placed at the slot center**. Siblings make room; nothing + overlaps. + +### 5. Hug (`w="auto"`/`h="auto"` on a frame) + +- main axis: `padding + Σ child contributions + gaps + padding` + (grow adds nothing — there is no leftover space in a hug axis). +- cross axis: `padding + max child contribution + padding`. +- free (non-flex) frame hug: `padding + max(child start offset + child +AABB extent) + padding`. + +### 6. Groups (derived box) + +- A group has no size of its own. Its children resolve **in group space** + at their own x/y offsets (start pins). +- union = the smallest rect containing all children's local AABBs + (rotation included). The union's origin may be negative (a child at + `x="-12"` puts the union's left at −12). +- The group's `x`/`y` place the group-space **origin** in the parent — + not the union. The group's reported box is + `(x + union.x, y + union.y, union.w, union.h)`. + (Consequence: moving a child changes the group's reported box, but + never moves the other children in world space.) + +### 7. Lens + +Like a group, but carries `ops` (skew/matrix/…). Ops affect painting +only: the reported box and all layout participation use the pre-ops +union. (Consequence: a skewed lens never pushes its flex siblings.) + +## Worked micro-example + +```xml + + + hi there + +``` + +- `t` measured: 8 chars × 6 = 48 wide, 12 tall (one line, auto width). +- `s` box = (10, 10, 50, 50); `t` box = (70, 10, 48, 12). diff --git a/model-v2/a/e3-text-ir/predictions/fable-a.txt b/model-v2/a/e3-text-ir/predictions/fable-a.txt new file mode 100644 index 0000000000..76c63d4e1c --- /dev/null +++ b/model-v2/a/e3-text-ir/predictions/fable-a.txt @@ -0,0 +1,22 @@ +p1,panel,box,50,40,400,300 +p1,a,box,10,20,120,80 +p1,b,box,256,110,120,80 +p1,c,box,30,240,320,60 +p2,row,box,0,0,500,120 +p2,a,box,14,40,80,40 +p2,b,box,106,14,60,92 +p2,c,box,178,35,308,50 +p3,card,box,10,10,200,178 +p3,hero,box,16,16,100,90 +p3,title,box,16,114,168,48 +p4,stage,box,0,0,400,400 +p4,tile,box,60,80,120,60 +p4,tile,aabb,53.038,54.019,133.923,111.962 +p5,list,box,0,0,260,140 +p5,a,box,10,20,60,100 +p5,b,box,100,20,60,100 +p5,b,aabb,80,40,100,60 +p5,c,box,190,20,60,100 +p6,g,box,88,50,108,44 +p6,p,box,-12,0,40,40 +p6,q,box,56,4,40,40 diff --git a/model-v2/a/e3-text-ir/predictions/fable-b.txt b/model-v2/a/e3-text-ir/predictions/fable-b.txt new file mode 100644 index 0000000000..76c63d4e1c --- /dev/null +++ b/model-v2/a/e3-text-ir/predictions/fable-b.txt @@ -0,0 +1,22 @@ +p1,panel,box,50,40,400,300 +p1,a,box,10,20,120,80 +p1,b,box,256,110,120,80 +p1,c,box,30,240,320,60 +p2,row,box,0,0,500,120 +p2,a,box,14,40,80,40 +p2,b,box,106,14,60,92 +p2,c,box,178,35,308,50 +p3,card,box,10,10,200,178 +p3,hero,box,16,16,100,90 +p3,title,box,16,114,168,48 +p4,stage,box,0,0,400,400 +p4,tile,box,60,80,120,60 +p4,tile,aabb,53.038,54.019,133.923,111.962 +p5,list,box,0,0,260,140 +p5,a,box,10,20,60,100 +p5,b,box,100,20,60,100 +p5,b,aabb,80,40,100,60 +p5,c,box,190,20,60,100 +p6,g,box,88,50,108,44 +p6,p,box,-12,0,40,40 +p6,q,box,56,4,40,40 diff --git a/model-v2/a/e3-text-ir/predictions/haiku-c.txt b/model-v2/a/e3-text-ir/predictions/haiku-c.txt new file mode 100644 index 0000000000..543a63483e --- /dev/null +++ b/model-v2/a/e3-text-ir/predictions/haiku-c.txt @@ -0,0 +1,22 @@ +p1,panel,box,50,40,400,300 +p1,a,box,10,20,120,80 +p1,b,box,256,110,120,80 +p1,c,box,30,240,320,60 +p2,row,box,0,0,500,120 +p2,a,box,14,40,80,40 +p2,b,box,106,14,60,92 +p2,c,box,178,35,308,50 +p3,card,box,10,10,200,154 +p3,hero,box,16,26,168,90 +p3,title,box,16,122,168,24 +p4,stage,box,0,0,400,400 +p4,tile,box,60,80,120,60 +p4,tile,aabb,53.04,54.02,133.92,111.96 +p5,list,box,0,0,260,140 +p5,a,box,10,20,60,100 +p5,b,box,50,40,60,100 +p5,b,aabb,30,40,100,60 +p5,c,box,190,20,60,100 +p6,g,box,88,50,108,44 +p6,p,box,-12,0,40,40 +p6,q,box,56,4,40,40 diff --git a/model-v2/a/e3-text-ir/probes/p1.xml b/model-v2/a/e3-text-ir/probes/p1.xml new file mode 100644 index 0000000000..e12b2fa3cd --- /dev/null +++ b/model-v2/a/e3-text-ir/probes/p1.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/model-v2/a/e3-text-ir/probes/p2.xml b/model-v2/a/e3-text-ir/probes/p2.xml new file mode 100644 index 0000000000..5c4cfdec2c --- /dev/null +++ b/model-v2/a/e3-text-ir/probes/p2.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/model-v2/a/e3-text-ir/probes/p3.xml b/model-v2/a/e3-text-ir/probes/p3.xml new file mode 100644 index 0000000000..e3e19943e0 --- /dev/null +++ b/model-v2/a/e3-text-ir/probes/p3.xml @@ -0,0 +1,6 @@ + + + + Anchor wins big + + diff --git a/model-v2/a/e3-text-ir/probes/p4.xml b/model-v2/a/e3-text-ir/probes/p4.xml new file mode 100644 index 0000000000..dea983b5ba --- /dev/null +++ b/model-v2/a/e3-text-ir/probes/p4.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/model-v2/a/e3-text-ir/probes/p5.xml b/model-v2/a/e3-text-ir/probes/p5.xml new file mode 100644 index 0000000000..0092b4acf2 --- /dev/null +++ b/model-v2/a/e3-text-ir/probes/p5.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/model-v2/a/e3-text-ir/probes/p6.xml b/model-v2/a/e3-text-ir/probes/p6.xml new file mode 100644 index 0000000000..9334245928 --- /dev/null +++ b/model-v2/a/e3-text-ir/probes/p6.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/model-v2/a/e3-text-ir/truth.txt b/model-v2/a/e3-text-ir/truth.txt new file mode 100644 index 0000000000..596ecd633d --- /dev/null +++ b/model-v2/a/e3-text-ir/truth.txt @@ -0,0 +1,22 @@ +p1,panel,box,50.000,40.000,400.000,300.000 +p1,a,box,10.000,20.000,120.000,80.000 +p1,b,box,256.000,110.000,120.000,80.000 +p1,c,box,30.000,240.000,320.000,60.000 +p2,row,box,0.000,0.000,500.000,120.000 +p2,a,box,14.000,40.000,80.000,40.000 +p2,b,box,106.000,14.000,60.000,92.000 +p2,c,box,178.000,35.000,308.000,50.000 +p3,card,box,10.000,10.000,200.000,178.000 +p3,hero,box,16.000,16.000,100.000,90.000 +p3,title,box,16.000,114.000,168.000,48.000 +p4,stage,box,0.000,0.000,400.000,400.000 +p4,tile,box,60.000,80.000,120.000,60.000 +p4,tile,aabb,53.038,54.019,133.923,111.962 +p5,list,box,0.000,0.000,260.000,140.000 +p5,a,box,10.000,20.000,60.000,100.000 +p5,b,box,100.000,20.000,60.000,100.000 +p5,b,aabb,80.000,40.000,100.000,60.000 +p5,c,box,190.000,20.000,60.000,100.000 +p6,g,box,88.000,50.000,108.000,44.000 +p6,p,box,-12.000,0.000,40.000,40.000 +p6,q,box,56.000,4.000,40.000,40.000 diff --git a/model-v2/a/e3-text-ir/verdict.md b/model-v2/a/e3-text-ir/verdict.md new file mode 100644 index 0000000000..9832e6d955 --- /dev/null +++ b/model-v2/a/e3-text-ir/verdict.md @@ -0,0 +1,51 @@ +# E3 verdict — the agent text IR + +**Decision: amendment 3 is validated — the anchor model is mentally +simulable by an LLM from a ~150-line grammar, at 100% on frontier-class +models.** The text IR graduates from "documentation sugar" to a designed +surface with a reference parser/printer and a scoreable conformance +protocol. + +Run: 2026-07-07. Probes, truth, raw predictions and scorer are all in +this directory; re-run with `cargo run --bin e3 -- truth|score`. + +## Findings + +1. **Cold prediction works (triage #17).** Two independent frontier + agents, given only the grammar, predicted all 22 resolved boxes/AABBs + exactly (≤0.5 px) — including greedy text wrap arithmetic, flex grow + distribution, hug sizing, the rotated-AABB envelope, and the group + origin-placement rule. Nothing about the model required running an + engine to know the answer. This was the design bet of + "closed-form, locally decidable resolution" — it held. +2. **The E1-locked rule is learnable in one paragraph.** All three agents + — including the small-model control — correctly computed the + rotated-in-flow _layout_ consequences (container hug = 260, sibling + displaced to 190) from four sentences of spec. The two frontier agents + also placed the rotated box itself perfectly. This closes the + predictability clause of [`../e1-rotation-in-flow/verdict.md`](../e1-rotation-in-flow/verdict.md): + layout-visible rotation does not cost agent predictability. +3. **Small-model failures are slips, not structure.** Haiku's five misses + trace to two causes: a character-count off-by-one (then compounded), + and executing "box center at slot center" as "box corner at slot + corner" _after stating the rule correctly_. No failure involved the + model's semantics being ambiguous or arbitration-dependent — exactly + the failure class a format can't fix and a linter/preview can. +4. **Grammar gap found and fixed during the run**: the two flavors of + stretch (container `cross="stretch"` respects fixed sizes; child + `align="stretch"` means fill and overrides) needed explicit prose — + the first grammar draft under-specified it and the truth run exposed + it. Recorded for the phase-3 spec: this distinction must be a + headlined rule, not a footnote. +5. **Open item for the real IR** (recorded, not blocking): the lab IR + does not carry node ids — re-parsing assigns document-order ids, which + round-trips content but not identity. The product IR needs an optional + stable-id attribute for edit workflows (agents editing an existing doc + rather than authoring a fresh one). + +## Score summary + +fable-a **22/22**, fable-b **22/22**, haiku-c 17/22 (tolerance 0.5 px; +22 lines across 6 probes). Zero missing lines from any agent — the output +protocol itself (comma lines, box-vs-aabb split) was followed perfectly +by all three. diff --git a/model-v2/a/e4-resolver/README.md b/model-v2/a/e4-resolver/README.md new file mode 100644 index 0000000000..6cf782ce1c --- /dev/null +++ b/model-v2/a/e4-resolver/README.md @@ -0,0 +1,40 @@ +# E4 — resolver spike + +**Question.** Does the four-phase resolution (measure → layout → +transforms → bounds, models/a.md §6) hold up as an engine architecture — +throughput, scaling, invalidation shape — against the current +`crates/grida` pipeline (`cache/geometry.rs` + `layout/engine.rs`)? + +**Method.** The lab resolver ([`../lab/src/resolve.rs`](../lab/src/resolve.rs)) +is a faithful §6 implementation over Taffy 0.9 (the engine's own layout +crate, same version). [`../lab/src/bin/e4.rs`](../lab/src/bin/e4.rs) +benches full resolution on three scene shapes at 1k/10k nodes +(median of 11, release, single-threaded, M-series laptop). + +## Measured (2026-07-07) + +| scene | nodes | full resolve | throughput | +| ----------------------------------------------------- | ------ | ------------ | -------------- | +| flat canvas (free shapes, ⅐ rotated) | 1,001 | 1.05 ms | 952 nodes/ms | +| flat canvas | 10,001 | 5.42 ms | 1,844 nodes/ms | +| flex cards (nested hug column-in-row, text measure) | 577 | 2.55 ms | 226 nodes/ms | +| flex cards | 5,785 | 24.4 ms | 237 nodes/ms | +| mixed groups + flex (rotated-in-flow on the hot path) | 10,003 | 8.21 ms | 1,218 nodes/ms | +| single card subtree (locality bound) | 6 | **18 µs** | — | + +- **N-4 linearity holds**: 10× nodes → 5.2×–9.6× time across shapes + (sub-linear on flat scenes from cache warmth; the all-flex worst case + is cleanly linear). +- **Locality**: a leaf edit under clean parent boxes re-resolves one + container subtree — measured 18 µs per card. The one-way phase order + (measure never reads position; contributions never read assigned + position) is what makes "clean parent ⇒ subtree-local" a _structural_ + guarantee rather than a cache heuristic. +- Flex-heavy costs ~5–8× per node vs free placement. Two known lab + inefficiencies inflate it: hug containers run Taffy twice (intrinsic + + definite pass), and each container builds a fresh `TaffyTree`. The + production engine's single-tree build (`layout/engine.rs`) removes + both; treat 237 nodes/ms as the floor, not the ceiling. + +The architecture comparison and dependency findings are in +[`verdict.md`](./verdict.md). diff --git a/model-v2/a/e4-resolver/verdict.md b/model-v2/a/e4-resolver/verdict.md new file mode 100644 index 0000000000..c20ce46aec --- /dev/null +++ b/model-v2/a/e4-resolver/verdict.md @@ -0,0 +1,61 @@ +# E4 verdict — resolver architecture + +**Decision: the four-phase resolver is viable as the phase-4 +architecture — it is simpler than what it replaces, its costs are +in-budget unoptimized, and its structure gives locality for free.** +Plus two dependency findings that phase 4 must carry. + +## vs `cache/geometry.rs` (the branch forest) + +What the lab resolver structurally _does not contain_, because the model +made the cases unrepresentable: + +| current pipeline (`crates/grida`) | lab resolver | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `resolve_layout` (geometry.rs:754): per-kind branch forest — 26 `match`/`if let` arms reconciling three geometry models | one uniform header path + a 3-way box-source split (declared / measured / derived) | +| `AffineTransform::new(l.x, l.y, geo.schema_transform.rotation())` (geometry.rs:823 …): rebuilds leaf transforms through `atan2`, destroying scale/skew (X-SELF-2) | no matrix in the document to destroy; `from_box_center` is _the_ constructor (Phase T) — no `atan2` anywhere in steady state | +| `NodeGeoData` carries `schema_transform` **and** a separate rotation — two truths to keep coherent | the resolved tier holds resolved values only; intent lives in the document | +| `MIN_SIZE_DIRTY_HACK = 1.0` px floors (geometry.rs:812–869) patching text/markdown zero-size cases | G-E5's declared rule; zero-size resolves as zero, reported — no floors (the hack is a sentinel workaround the model no longer needs) | +| ICB special regime (root children's transforms ignored) | root is an ordinary viewport-bound frame (X-SELF-5 break, tested) | + +That 26-arm forest is not "bad code" — it is the _interest payment_ on +three coexisting geometry models. The spike's point is that the payment +stops when the model unifies: the whole lab resolver is ~700 lines +including the E1 fork, reports, and both derived-box semantics. + +## Performance posture + +10k free nodes in 5.4 ms and the all-flex worst case at 24 ms — +single-threaded, zero caching, double-running Taffy on hug containers — +comfortably clears interactive budgets before any of the production +engine's machinery (single Taffy tree, damage tracking, parallelism) is +applied. Phase 4 carries over `layout/engine.rs`'s single-tree build and +adds the anchor semantics on top; nothing measured here suggests the +model itself adds cost — the AABB contribution for rotated-in-flow +children is two `abs`-weighted products per child (`mixed` scene: 1,218 +nodes/ms _with_ rotated flow children on the hot path). + +## Dependency findings (Taffy 0.9.2) — carried to phase 4 + +1. **Rounding default**: Taffy pixel-snaps unless `disable_rounding()` is + called. L-7 declares resolution unquantized (snapping is paint's job) + — the engine must disable rounding or L-7 drifts silently. +2. **Intrinsic-pass grow inflation**: in the max-content sizing pass a + _growable_ item's contribution is floored by the container's own + padding (`.max(main_content_box_inset)`, flexbox.rs — the source + comments admit trial-and-error provenance). Symptom: hug column with + `padding:16` + a growable one-line text hugs to 112 instead of 99.2 — + deviating from both L-3 ("grow distributes only definite free space") + and Chromium. Lab enforcement: strip grow factors in indefinite-main + intrinsic runs; the definite re-run applies them. Phase 4 needs the + same guard (or an upstream Taffy fix) — and L-3 needs a conformance + test pinned against Chromium, not against Taffy. + +## Spec consequences + +- N-4 (linear scaling, bounded invalidation) — evidenced; the phase-order + acyclicity note in §6 should be promoted from an implementation remark + to a normative invariant, since locality _derives_ from it. +- The two-pass hug (intrinsic then definite) is inherent to + hug-with-layout in any engine; single-tree Taffy hides it inside one + `compute_layout`. No model change needed. diff --git a/model-v2/a/e5-svg-corpus/README.md b/model-v2/a/e5-svg-corpus/README.md new file mode 100644 index 0000000000..fc5277e55a --- /dev/null +++ b/model-v2/a/e5-svg-corpus/README.md @@ -0,0 +1,47 @@ +# E5 — SVG import corpus measurement + +**Question.** Triage amendment 5 makes SVG import ≈100% a hard +requirement but left the carrying mechanism open (lens-quarantine default +vs declared degradation), to be decided by corpus measurement. How much +real SVG content actually exceeds the anchor header's native vocabulary +(bindings + size + rotation)? + +**Method.** [`../lab/src/bin/e5scan.rs`](../lab/src/bin/e5scan.rs) scans +every `transform` / `gradientTransform` / `patternTransform` attribute, +composes each transform list into a 2×3 matrix, and classifies by the +decomposition `M = R(θ)·[sx m; 0 sy]`: identity/translate → bindings; +rotate → header rotation; scale (uniform or not, `m≈0`) → folds into +size; `det<0` → single-axis mirror (flip class); `|m|>ε` → true shear. +Geometry and paint transforms are tallied separately — a gradient's +matrix lives in the _paint_, never in node geometry. + +**Corpus** (2026-07-07, repo-local): `fixtures/local` (6,626 wild +real-world files), `fixtures/test-svg` (37), `third_party/usvg` (62), +`packages/grida-canvas-sdk-render-figma` (333 Figma-derived), +`editor/public` (24), `public/slides-templates` (56) — +**7,138 files, 1,003,787 transforms** (613,084 geometry + 390,703 paint). + +## Results + +Geometry transforms by class: + +| class | count | share | lands where | +| ----------------------------- | ----------- | --------- | ------------------- | +| identity / translate | 55,529 | 9.1% | bindings | +| rotate | 7,989 | 1.3% | header `rotation` | +| scale, uniform | 121,268 | 19.8% | size fold | +| scale, non-uniform (no shear) | 262,607 | 42.8% | size fold | +| **flip (det < 0)** | **159,872** | **26.1%** | ← the finding | +| true shear | 5,809 | **0.95%** | lens | +| unparsable | 10 | 0.002% | lens (conservative) | + +Per file: **57.8%** import fully natively as-is; **+23.9%** are blocked +_only_ by flips; **18.3%** contain ≥1 geometry shear (wild corpus 19.7%; +curated corpora 0–8.1%). Explicit `skewX/skewY` appears in only 44 +files — shear arrives almost entirely as baked `matrix()`. + +Paint transforms: 390,703 total, 17,872 with shear — none of them a node +problem (the paint model already carries full matrices). Splitting these +out cut the apparent "needs lens" file rate from 32.5% to 18.3%. + +The verdict lives in [`verdict.md`](./verdict.md). diff --git a/model-v2/a/e5-svg-corpus/verdict.md b/model-v2/a/e5-svg-corpus/verdict.md new file mode 100644 index 0000000000..e9e2f60fb4 --- /dev/null +++ b/model-v2/a/e5-svg-corpus/verdict.md @@ -0,0 +1,49 @@ +# E5 verdict — SVG import mechanism + +**Decision: quarantine confirmed for shear; and the corpus forces one +spec amendment — flip must be native.** Amendment 5's ≈100% requirement +is achievable with zero degradation. + +## The four rulings + +1. **Flip goes native (spec amendment proposal, measured).** Single-axis + mirrors are **26.1% of all geometry transforms** (159,872 occurrences; + sole blocker for 23.9% of files) — the standard y-up→y-down and + mirrored-asset idioms of real SVG. A quarantine that fires on a + quarter of wild files is not a quarantine. Proposal for phase 3: two + header booleans `flip_x` / `flip_y` (applied about the box center, + after rotation semantics are unchanged) — scalar intent, CRDT-atomic, + animatable, and exactly what Figma exposes. This answers conformance + **R-E5** ("declared representation for negative-determinant + transforms"). With native flip, per-file native coverage rises from + 57.8% → **81.7%**. +2. **Lens is the right home for true shear.** Genuine shear is **0.95%** + of geometry transforms — per-transform it is exactly the exceptional + class the lens was designed for (H8: wrap, never silent loss). It + appears in 18.3% of _wild_ files (0–8% in curated corpora), typically + on a handful of nodes per file — an acceptable wrap rate for content + that genuinely is sheared. Degradation is unnecessary: only 10 of a + million transforms were unparsable (wrapped conservatively). +3. **Paint transforms never touch the node model.** 390k gradient/pattern + matrices (including all 17,872 paint shears) belong to the paint, + which already carries a full 2×3 in Grida. Importer rule, locked: + `gradientTransform`/`patternTransform` → paint transform, never a + lens. (Skipping this split overstates the lens rate by 14 points — + the measurement that justified the rule.) +4. **X-SVG-1/2/3 verdicts confirmed as written**: translate/rotate/scale + lists import losslessly into bindings/rotation/size (73% of geometry + transforms immediately, no structure added); skew/matrix = "Y-with- + structure" via lens; `x/y` attr + `translate()` compose into one + binding offset at import. + +## What this cost the model + +One honest widening: the header grows two booleans. The alternative — +lens-wrapping a quarter of imported files — would have diluted H5's +"matrices are tier-2 only" story far more than two flip bits do. The +scale fold (62.6% of transforms landing in `w`/`h`) confirms the +size-free shape-descriptor design (`CanonicalLayerShape` direction) pulls +its weight in practice. + +Reproduce: `cargo run --release --bin e5scan -- ` from +`../lab`. diff --git a/model-v2/a/e6-pluggable-layout/SKETCH.md b/model-v2/a/e6-pluggable-layout/SKETCH.md new file mode 100644 index 0000000000..748274002b --- /dev/null +++ b/model-v2/a/e6-pluggable-layout/SKETCH.md @@ -0,0 +1,72 @@ +# E6 — pluggable layout, the curiosity sketch + +Status: **exploratory, non-binding** (triage #28: "never core — but I +would LOVE to see what lands if I picked 'core must anticipate'"). +Nothing here is a commitment; this is the owed look down the road not +taken. + +## The finding: the seam already exists + +Building the E1/E4 resolver revealed that the anchor model already +contains a layout-plugin interface _by accident of its own discipline_. +Phase L's contract per container is exactly: + +``` +layout: (container content box, [child contributions]) → [slots] +child placement: box center := slot center +``` + +where a **contribution** is `(main, cross)` — already an abstraction, because +rotated children submit their AABB, not their box (E1). Flex never knows +whether it is placing a box, a rotated envelope, or a group union. That +is the whole plugin surface: + +``` +trait LayoutMode { + fn measure(&self, children: &[Contribution], avail: Avail) -> Size; // hug + fn place(&self, children: &[Contribution], content: Size) -> Vec; +} +``` + +`None` (bindings) and `Flex` (Taffy) are the two built-ins. Grid is a +third implementation of the same trait — which is why a.md could call +grid "an additive future mode variant" without hand-waving. + +## What the exotic modes would look like + +- **Radial**: slots on a circle; parameters `radius`, `start_angle`, + `spread`. Child rotation-to-tangent is _not_ the layout's job — it + writes nothing; a `follow: tangent` child flag would resolve rotation + from the slot angle (a resolved-tier value, never serialized — the + "derivable ⇒ not encodable" law extends cleanly). +- **Masonry**: `place` with per-column running heights — pure function of + contributions, no new model concepts at all. +- **Text-on-path**: the one that _breaks_ the seam. Slots stop being + rects (they become arc-length frames with tangents), and measurement + couples to glyph shaping. This wants to live inside the `text` payload + (a content concern), not in the container protocol. The seam's limit + is: **slots are axis-aligned rects**; anything needing oriented slots + is payload territory. + +## Why it stays out of core (unchanged, now with reasons priced) + +1. **E3 is the veto.** The model's proven superpower is that an LLM + predicts geometry from a 150-line grammar. Every pluggable mode + multiplies the grammar; a _user-definable_ mode (wasm hook?) destroys + cold prediction entirely — resolution would no longer be closed-form + from the document alone. That trade is not worth radial menus. +2. **Conformance surface.** Each mode adds a suite the size of L-\*. + Flex is testable against Chromium; radial/masonry have no oracle but + us (§9-style authored spec each time). +3. **Nothing is precluded.** `LayoutBehavior.mode` is an fbs enum/union — + additive by the E2-verified evolution rules. The trait boundary above + is an _internal_ engine shape; adopting it costs a refactor, not a + format migration. Anticipation is therefore free — which is the best + argument for not spending on it now. + +## One sentence of verdict + +The core should _stay_ two modes; but phase 4 should implement Phase L +against the `(contributions → slots)` trait anyway, because E1's rotated +AABBs already forced that abstraction into existence — the plugin door +comes free with the rotation feature, locked from the format side. diff --git a/model-v2/a/e7-shape-points-scale/README.md b/model-v2/a/e7-shape-points-scale/README.md new file mode 100644 index 0000000000..9f37dfdddf --- /dev/null +++ b/model-v2/a/e7-shape-points-scale/README.md @@ -0,0 +1,168 @@ +# E7 — shape definition vs layout; the points problem; the two scales + +Owner question (2026-07-07, post-run): did the model solve the +**XYWH-vs-shape-definition** semantic problem? What about the **points +problem** — when layout changes the box, do points update? And when +content "scales", is it **geometry-scaled or post-render-scaled** — and +what happens to an **image fill**? (Hint given: Framer/Sketch solve this +with an explicit ``/``-like container node.) + +Method: 5-agent research fan-out (Framer runtime source from the shipped +npm package, Sketch file-format + Athens docs, Figma plugin API + .fig +format, SVG2/CSSWG spec, and a grounding pass over the current Grida +engine/editor). Full structured facts in the workflow record; the +decisive ones are quoted below. + +## 1. XYWH vs shape definition — SOLVED (by design, lab-proven) + +The anchor answer is the _inversion_: a shape has no intrinsic geometry +the box must chase — **geometry is a function of the box** +(`ShapeDescriptor`, size-free, evaluated at the resolved box; a.md §3.2, +problem P3). W/H are ordinary layout participants; there is nothing for +layout to fight. + +- This is also what the current engine already does for primitives + (verified: `to_shape()` rebuilds from `size` each call; fbs + `CanonicalLayerShape` "intentionally does NOT encode size") — the model + ratifies the direction rather than inventing one. +- It is also Figma's answer (verified via plugin API/.fig: star = + `pointCount` + `innerRadius` _ratio_ + size; `cornerRadius` in px, so a + non-uniform resize re-evaluates the outline and arcs stay circular). +- Lab-proven for rect/ellipse/line through flex/stretch/grow. + +## 2. The points problem — industry-convergent answer; a.md needs E-A9 + +Three independent systems converged on the same structure, and none of +them rewrites points on resize: + +| system | storage | resize | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | +| Sketch | curve points normalized 0–1 relative to the frame — "so they don't require any update in case the frame of the layer changes"; the format has **no transform property at all** | frame write only | +| Figma | `.fig` vectorNetwork stored against `vectorData.normalizedSize`; render maps by `size / normalizedSize`; the plugin API _presents_ materialized coordinates | size write only | +| SVG | `viewBox` — a render-time coordinate-system transform | viewport write only | +| **Grida today** | absolute vector network; the TS reducer **bakes** the resize into every vertex + tangent (`VectorNetworkEditor.scale`) | **full point rewrite** — the outlier | + +The bake is exactly the class of behavior the model exists to kill: +write amplification (a drag writes N vertices), CRDT hostility +(resize ∥ vertex-edit conflicts on every vertex), float accumulation +over repeated resizes. + +**E-A9 (spec delta):** `vector`'s box source changes from +"measured (network bounds; no size intent)" to **declared-with-reference- +space**: points live in a reference space; the node carries ordinary +size intent; resolution maps points by `resolved_size / reference_size` +(render-time geometry scale — never a document write). Consequences: +resize = 1–2 field writes like every other kind; vectors under +layout-imposed extents (grow/stretch) become _defined_ for free (the +same mapping) — closing a real hole in a.md §4/§6; T-5 still holds +(vertex edits update the reference bounds); the current editor's +point-bake becomes an X-SELF break with a migration +(bounds-normalize once at import). +Predictability cost, mitigated: the text IR (an agent projection) can +present materialized coordinates, exactly as Figma's plugin API does +over its normalized storage. + +### 2b. Reference: our own io-figma already implements the mapping + +The owner's hint checks out in-repo (studied 2026-07-07, `io-figma`): + +- **Logic**: `packages/grida-canvas-io-figma/lib.ts` — + `scaleVectorNetworkFromNormalizedSize()` maps blob coordinates by + `sx = size.x / normalizedSize.x` (etc.), applied to **both vertex + positions and segment tangents**, at import time. +- **Proof Figma never rewrites the blob on resize**: the test + `__tests__/iofigma.kiwi.vector-network.test.ts` deliberately selects a + real `.fig` fixture node **where `normalizedSize != size`** — such + nodes exist in shipped files precisely because resize only writes + `NodeChange.size` and leaves the blob untouched (the owner's + observation, fixture-verified). +- **Notes**: `docs/wg/feat-fig/glossary/fig.kiwi.md` + §"Vector network coordinate space (observed)" documents the mapping, + the mis-render consequence of reading blobs "as-is", and one caveat + that **refines E-A9**: _some blobs have non-zero bbox origins + (minX/minY ≠ 0), so an additional translation may be needed beyond + pure scaling_ — i.e. the reference space is a **rect, not a size**. + E-A9 should either store a reference _rect_ or normalize the origin + at write time; a bare `reference_size` under-specifies. +- **Polygon/star: the mechanism does NOT apply, because there are no + points.** In the kiwi data, STAR/POLYGON are purely parametric — + `count`, `starInnerScale`, `cornerRadius`, `size` (lib.ts `star()` + uses `nc.count`/`nc.starInnerScale`; polygon `nc.count ?? 3`). No + stored outline exists to be in any coordinate space; resize + re-derives. Only VECTOR carries the normalized blob. +- **A tidy asymmetry worth naming**: the _editable_ network lives in + reference space (intent), while the _pre-baked_ `fillGeometry`/ + `strokeGeometry` command blobs — when present — are derived, size-space + geometry (our converter prefers them and applies no normalizedSize + scaling). Figma's own file thus mirrors the model's tier split: + intent in a stable space, resolved output baked separately. +- **The caveat the owner predicted** is real and documented: any tool + editing blob data directly must know the mapping (the Plugin API + hides it by materializing coordinates into current-size space). For + anchor/E-A9 the same division of labor applies: the binary format + keeps the reference space (round-trip- and CRDT-friendly — resize + ∥ vertex-edit touch disjoint fields); the **text IR materializes**, + so agents read/write real coordinates. + +## 3. Scaled how — geometry-at-render, never raster; and TWO scales + +- SVG's viewBox stretch is **geometry scaling at render** (a live + coordinate transform; content re-rasterizes crisp at device + resolution). Framer is the cautionary partial exception: its editor + canvas post-render CSS-scales the Graphic for drag performance, and + small SVGs are flattened to a stretched `background-image` data-URI. +- Every design tool refuses SVG's stroke default for plain resize: + strokes stay px-constant (Sketch: "strokes… stay the same when you + resize"; Figma: `resize()` never touches strokeWeight; Grida today: + same). The **anchor model has this by construction** — stroke width is + a style scalar that never enters the geometry mapping; + `vector-effect: non-scaling-stroke` semantics without the attribute. +- The second scale — "scale it like a picture" — is everywhere an + **explicit, separate gesture** that rewrites parameters: Figma + `rescale()`/K (strokes, blurs, font size), Sketch K (border, shadow, + text), and Grida already ships it (`parametric_scale`, K). The model + keeps this as an op-layer _bake_, not a stored mode. For a _retained_ + picture-scale (rare), the structural home is a `lens` `Scale` op — + under it strokes and everything else scale visually, which is exactly + SVG/Framer-Graphic semantics, quarantined and opt-in. + +## 4. Image fills — re-fit at the box, scaled only inside a lens + +Consensus (Figma scaleModes; current Grida verified: the image matrix is +recomputed from `container_size` at paint time, `BoxFit` + +box-relative `Transform` + `Tile`): a fill is **paint-tier state +re-evaluated at the resolved box**, never baked into geometry. Framer's +Graphic does the opposite (`objectBoundingBox` pattern, +`preserveAspectRatio="none"` → fills stretch and distort with the box) — +and that distortion is among its documented user complaints. +**E-A10 (rule, locked):** paints evaluate at the resolved box per their +fit mode; the only way an image fill "scales like a picture" is through +a lens op — same quarantine as every other picture-scale. + +## 5. The `` container — deliberately not required + +Both hinted systems make proportional graphics a _container mode_: +Framer's Graphic layer (opaque SVG string, stretch semantics) and +Sketch's Athens **Graphics** container (2025.1 — "contents resize +proportionally", no pinning inside, pasted SVG becomes one). The +evidence says the container works but its _mandatoriness_ hurts: Framer +users buy SVG code components to escape Graphic resize behavior, K was +added (Feb 2025) because plain resize couldn't scale styles, and +community threads complain about px-only sizing and aspect-lock fights. + +Anchor inverts the ergonomics: the **default** world is the layout world +— shapes are first-class citizens with parametric geometry and vectors +carry the normalized mapping _per leaf_, so no container is ever needed +for the common case (including "resize an imported icon group": +per-child size writes, no point rewrites, no wrapper). The proportional +world still exists, but as the opt-in escape (`lens` scale / wrapper +frame), not as a prerequisite node users must remember to create. + +## Verdict + +Question 1 was already solved and proven; question 2 exposes the one +real correction (**E-A9** — adopt the Sketch/Figma reference-space +mapping for `vector`, retiring today's point-bake); questions 3–4 were +implicitly right and are now locked as explicit rules (**E-A10**, two +scales doctrine); the Graphics-container path is rejected as a +requirement with evidence, kept as the lens escape. diff --git a/model-v2/a/edge-cases/frames.json b/model-v2/a/edge-cases/frames.json new file mode 100644 index 0000000000..f39fbe7cc9 --- /dev/null +++ b/model-v2/a/edge-cases/frames.json @@ -0,0 +1,3609 @@ +{ + "step": 3, + "scenes": [ + { + "id": "grow", + "name": "grow × rotation (E-A4)", + "labels": ["container", "a", "grow+rot", "c"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 55.0, 300.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 222.658, 53.5, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 225.391, 52.14, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 228.191, 50.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 231.051, 49.86, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 233.963, 48.94, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 236.919, 48.18, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 239.911, 47.57, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 242.93, 47.12, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 245.969, 46.83, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 249.019, 46.7, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 252.072, 46.73, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 255.119, 46.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 258.152, 47.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 261.162, 47.77, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 264.142, 48.43, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 267.083, 49.25, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 269.978, 50.22, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 272.817, 51.34, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 275.594, 52.61, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 278.301, 54.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 280.931, 55.57, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 283.475, 57.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 285.928, 59.07, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 288.282, 61.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 290.532, 63.08, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 292.67, 65.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 294.691, 67.55, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 296.59, 69.94, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 298.361, 72.42, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 300.0, 75.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 301.502, 77.66, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 302.862, 80.39, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 304.077, 83.19, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 305.145, 86.05, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 306.061, 88.96, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 306.823, 91.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 307.43, 94.91, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 307.879, 97.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 308.17, 100.97, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 308.301, 104.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 308.273, 107.07, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 308.084, 110.12, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 307.737, 113.15, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 307.231, 116.16, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 306.569, 119.14, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 305.751, 122.08, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 304.78, 124.98, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 303.66, 127.82, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 302.392, 130.59, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 300.981, 133.3, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 299.43, 135.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 297.743, 138.48, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 295.926, 140.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 293.983, 143.28, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 291.919, 145.53, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 289.74, 147.67, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 287.452, 149.69, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 285.062, 151.59, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 282.576, 153.36, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 280.0, 155.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ] + ] + }, + { + "id": "stretch", + "name": "stretch × rotation", + "labels": ["container", "a", "stretch+rot", "c"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 30.0, 60.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 105.234, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.151, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 110.453, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 180.124, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 115.643, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 184.905, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 120.791, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 189.48, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 125.882, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 193.837, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 130.902, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 197.965, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 135.837, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 201.852, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 140.674, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 205.486, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 145.399, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 208.859, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 150.0, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 211.962, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 154.464, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 214.784, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 158.779, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 217.32, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 162.932, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 219.561, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 166.913, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 221.502, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 170.711, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 223.137, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 174.314, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.462, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 177.715, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 225.474, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 180.902, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.169, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 183.867, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.545, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 186.603, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.603, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 189.101, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.34, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 191.355, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 225.759, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 193.358, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.86, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 195.106, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 223.647, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 196.593, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 222.122, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 197.815, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 220.289, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 198.769, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 218.155, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 199.452, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 215.724, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 199.863, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 213.003, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 200.0, 30.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 210.0, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 203.003, 35.23, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 213.003, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 205.724, 40.45, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 215.724, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 208.155, 45.64, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 218.155, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 210.289, 50.79, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 220.289, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 212.122, 55.88, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 222.122, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 213.647, 60.9, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 223.647, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 214.86, 65.84, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.86, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 215.759, 70.67, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 225.759, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 216.34, 75.4, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.34, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 216.603, 80.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.603, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 216.545, 84.46, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.545, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 216.169, 88.78, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.169, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 215.474, 92.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 225.474, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 214.462, 96.91, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.462, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 213.137, 100.71, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 223.137, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 211.502, 104.31, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 221.502, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 209.561, 107.71, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 219.561, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 207.32, 110.9, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 217.32, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 204.784, 113.87, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 214.784, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 201.962, 116.6, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 211.962, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 198.859, 119.1, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 208.859, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 195.486, 121.35, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 205.486, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 191.852, 123.36, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 201.852, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 187.965, 125.11, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 197.965, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 183.837, 126.59, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 193.837, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 179.48, 127.81, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 189.48, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 174.905, 128.77, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 184.905, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 170.124, 129.45, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 180.124, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 165.151, 129.86, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.151, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 160.0, 130.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0, 0.0] + ] + ] + }, + { + "id": "text", + "name": "rotated text in flow", + "labels": ["container", "a", "text+rot", "c"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 214.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 94.2, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 164.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 215.06, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 101.13, 92.8, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 165.056, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 215.96, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 102.258, 91.44, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 165.962, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 216.71, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 103.379, 90.11, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 166.714, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.31, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 104.491, 88.82, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.311, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.75, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 105.59, 87.58, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.75, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 218.03, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 106.675, 86.39, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 168.032, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 218.15, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 107.741, 85.24, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 168.154, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 218.12, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 108.785, 84.15, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 168.117, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.92, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 109.806, 83.12, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.921, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.57, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 110.8, 82.15, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.565, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.05, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 111.764, 81.24, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.052, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 216.38, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 112.696, 80.39, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 166.383, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 215.56, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 113.593, 79.62, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 165.559, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 214.58, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 114.453, 78.91, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 164.583, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 213.46, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 115.274, 78.27, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 163.457, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 212.18, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 116.052, 77.71, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 162.185, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 210.77, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 116.786, 77.22, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 160.77, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 209.22, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 117.475, 76.81, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 159.215, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 207.53, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 118.115, 76.47, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 157.526, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 205.71, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 118.706, 76.22, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 155.706, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 203.76, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 119.246, 76.04, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 153.761, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 201.7, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 119.733, 75.94, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 151.696, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 199.52, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 120.165, 75.92, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 149.517, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 197.23, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 120.543, 75.98, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 147.23, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 194.84, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 120.864, 76.12, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 144.84, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 192.36, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 121.128, 76.34, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 142.355, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 189.78, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 121.334, 76.64, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 139.782, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 187.13, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 121.482, 77.02, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 137.126, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 184.4, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 121.57, 77.47, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 134.397, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 181.6, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 121.6, 78.0, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 131.6, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 184.4, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 124.397, 78.6, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 134.397, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 187.13, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 127.126, 79.28, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 137.126, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 189.78, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 129.782, 80.02, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 139.782, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 192.36, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 132.355, 80.84, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 142.355, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 194.84, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 134.84, 81.72, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 144.84, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 197.23, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 137.23, 82.66, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 147.23, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 199.52, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 139.517, 83.66, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 149.517, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 201.7, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 141.696, 84.73, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 151.696, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 203.76, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 143.761, 85.85, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 153.761, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 205.71, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 145.706, 87.02, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 155.706, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 207.53, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 147.526, 88.24, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 157.526, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 209.22, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 149.215, 89.5, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 159.215, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 210.77, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 150.77, 90.81, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 160.77, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 212.18, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 152.185, 92.16, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 162.185, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 213.46, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 153.457, 93.54, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 163.457, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 214.58, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 154.583, 94.96, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 164.583, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 215.56, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 155.559, 96.4, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 165.559, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 216.38, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 156.383, 97.87, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 166.383, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.05, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 157.052, 99.35, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.052, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.57, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 157.565, 100.85, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.565, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.92, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 157.921, 102.37, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.921, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 218.12, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 158.117, 103.88, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 168.117, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 218.15, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 158.154, 105.41, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 168.154, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 218.03, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 158.032, 106.93, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 168.032, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.75, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 157.75, 108.44, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.75, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 217.31, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 157.311, 109.95, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 167.311, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 216.71, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 156.714, 111.44, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 166.714, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 215.96, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 155.962, 112.92, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 165.962, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 215.06, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 155.056, 114.37, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 165.056, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 214.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 154.0, 115.8, 54.0, 21.6, 0.0], + [1.0, 0.0, 0.0, 1.0, 164.0, 55.0, 60.0, 100.0, 0.0] + ] + ] + }, + { + "id": "rotframe", + "name": "rotated flex frame (rigid)", + "labels": ["flex frame (rotated)", "a", "grow", "c"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 90.0, 60.0, 320.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 75.0, 80.0, 90.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 75.0, 120.0, 90.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 320.0, 75.0, 80.0, 90.0, 0.0] + ], + [ + [1.0, 0.052, -0.052, 0.999, 93.359, 51.71, 320.0, 120.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 102.561, 67.21, 80.0, 90.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 192.437, 71.92, 120.0, 90.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 322.259, 78.73, 80.0, 90.0, 0.0] + ], + [ + [0.99, 0.105, -0.105, 0.995, 97.148, 43.6, 320.0, 120.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 105.525, 59.57, 80.0, 90.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 195.032, 68.97, 120.0, 90.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 324.32, 82.56, 80.0, 90.0, 0.0] + ], + [ + [0.99, 0.156, -0.156, 0.988, 101.356, 35.71, 320.0, 120.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 108.886, 52.09, 80.0, 90.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 197.778, 66.17, 120.0, 90.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 326.178, 86.5, 80.0, 90.0, 0.0] + ], + [ + [0.98, 0.208, -0.208, 0.978, 105.971, 28.05, 320.0, 120.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 112.634, 44.8, 80.0, 90.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 200.667, 63.51, 120.0, 90.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 327.826, 90.54, 80.0, 90.0, 0.0] + ], + [ + [0.97, 0.259, -0.259, 0.966, 110.981, 20.63, 320.0, 120.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 116.758, 37.71, 80.0, 90.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 203.691, 61.0, 120.0, 90.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 329.262, 94.65, 80.0, 90.0, 0.0] + ], + [ + [0.95, 0.309, -0.309, 0.951, 116.372, 13.49, 320.0, 120.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 121.247, 30.85, 80.0, 90.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 206.842, 58.66, 120.0, 90.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 330.48, 98.83, 80.0, 90.0, 0.0] + ], + [ + [0.93, 0.358, -0.358, 0.934, 122.129, 6.65, 320.0, 120.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 126.089, 24.23, 80.0, 90.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 210.112, 56.49, 120.0, 90.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 331.477, 103.07, 80.0, 90.0, 0.0] + ], + [ + [0.91, 0.407, -0.407, 0.914, 128.237, 0.11, 320.0, 120.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 131.271, 17.88, 80.0, 90.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 213.49, 54.49, 120.0, 90.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 332.251, 107.36, 80.0, 90.0, 0.0] + ], + [ + [0.89, 0.454, -0.454, 0.891, 134.678, -6.1, 320.0, 120.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 136.779, 11.81, 80.0, 90.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 216.969, 52.67, 120.0, 90.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 332.8, 111.68, 80.0, 90.0, 0.0] + ], + [ + [0.87, 0.5, -0.5, 0.866, 141.436, -11.96, 320.0, 120.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 142.596, 6.03, 80.0, 90.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 220.538, 51.03, 120.0, 90.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 333.122, 116.03, 80.0, 90.0, 0.0] + ], + [ + [0.84, 0.545, -0.545, 0.839, 148.491, -17.46, 320.0, 120.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 148.708, 0.56, 80.0, 90.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 224.189, 49.58, 120.0, 90.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 333.216, 120.38, 80.0, 90.0, 0.0] + ], + [ + [0.81, 0.588, -0.588, 0.809, 155.824, -22.59, 320.0, 120.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 155.098, -4.57, 80.0, 90.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 227.909, 48.33, 120.0, 90.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 333.082, 124.74, 80.0, 90.0, 0.0] + ], + [ + [0.78, 0.629, -0.629, 0.777, 163.416, -27.32, 320.0, 120.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 161.748, -9.37, 80.0, 90.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 231.691, 47.27, 120.0, 90.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 332.72, 129.08, 80.0, 90.0, 0.0] + ], + [ + [0.74, 0.669, -0.669, 0.743, 171.245, -31.65, 320.0, 120.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 168.639, -13.81, 80.0, 90.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 235.522, 46.41, 120.0, 90.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 332.131, 133.4, 80.0, 90.0, 0.0] + ], + [ + [0.71, 0.707, -0.707, 0.707, 179.289, -35.56, 320.0, 120.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 175.754, -17.89, 80.0, 90.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 239.393, 45.75, 120.0, 90.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 331.317, 137.68, 80.0, 90.0, 0.0] + ], + [ + [0.67, 0.743, -0.743, 0.669, 187.528, -39.05, 320.0, 120.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 183.072, -21.58, 80.0, 90.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 243.294, 45.3, 120.0, 90.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 330.281, 141.91, 80.0, 90.0, 0.0] + ], + [ + [0.63, 0.777, -0.777, 0.629, 195.937, -42.1, 320.0, 120.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 190.574, -24.89, 80.0, 90.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 247.212, 45.05, 120.0, 90.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 329.024, 146.08, 80.0, 90.0, 0.0] + ], + [ + [0.59, 0.809, -0.809, 0.588, 204.495, -44.71, 320.0, 120.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 198.238, -27.8, 80.0, 90.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 251.139, 45.01, 120.0, 90.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 327.551, 150.18, 80.0, 90.0, 0.0] + ], + [ + [0.54, 0.839, -0.839, 0.545, 213.178, -46.87, 320.0, 120.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 206.044, -30.31, 80.0, 90.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 255.062, 45.17, 120.0, 90.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 325.865, 154.2, 80.0, 90.0, 0.0] + ], + [ + [0.5, 0.866, -0.866, 0.5, 221.962, -48.56, 320.0, 120.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 213.971, -32.4, 80.0, 90.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 258.971, 45.54, 120.0, 90.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 323.971, 158.12, 80.0, 90.0, 0.0] + ], + [ + [0.45, 0.891, -0.891, 0.454, 230.822, -49.8, 320.0, 120.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 221.997, -34.08, 80.0, 90.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 262.856, 46.11, 120.0, 90.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 321.875, 161.94, 80.0, 90.0, 0.0] + ], + [ + [0.41, 0.914, -0.914, 0.407, 239.735, -50.57, 320.0, 120.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 230.099, -35.33, 80.0, 90.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 266.705, 46.88, 120.0, 90.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 319.581, 165.65, 80.0, 90.0, 0.0] + ], + [ + [0.36, 0.934, -0.934, 0.358, 248.676, -50.87, 320.0, 120.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 238.256, -36.16, 80.0, 90.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 270.509, 47.86, 120.0, 90.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 317.097, 169.22, 80.0, 90.0, 0.0] + ], + [ + [0.31, 0.951, -0.951, 0.309, 257.621, -50.71, 320.0, 120.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 246.445, -36.56, 80.0, 90.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 274.257, 49.03, 120.0, 90.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 314.429, 172.67, 80.0, 90.0, 0.0] + ], + [ + [0.26, 0.966, -0.966, 0.259, 266.544, -50.08, 320.0, 120.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 254.644, -36.54, 80.0, 90.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 277.938, 50.4, 120.0, 90.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 311.584, 175.97, 80.0, 90.0, 0.0] + ], + [ + [0.21, 0.978, -0.978, 0.208, 275.423, -48.98, 320.0, 120.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 262.83, -36.08, 80.0, 90.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 281.542, 51.96, 120.0, 90.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 308.57, 179.11, 80.0, 90.0, 0.0] + ], + [ + [0.16, 0.988, -0.988, 0.156, 284.232, -47.42, 320.0, 120.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 270.981, -35.19, 80.0, 90.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 285.06, 53.7, 120.0, 90.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 305.396, 182.1, 80.0, 90.0, 0.0] + ], + [ + [0.1, 0.995, -0.995, 0.105, 292.947, -45.4, 320.0, 120.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 279.074, -33.88, 80.0, 90.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 288.482, 55.62, 120.0, 90.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 302.07, 184.91, 80.0, 90.0, 0.0] + ], + [ + [0.05, 0.999, -0.999, 0.052, 301.544, -42.92, 320.0, 120.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 287.088, -32.15, 80.0, 90.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 291.798, 57.73, 120.0, 90.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 298.602, 187.55, 80.0, 90.0, 0.0] + ], + [ + [0.0, 1.0, -1.0, 0.0, 310.0, -40.0, 320.0, 120.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 295.0, -30.0, 80.0, 90.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 295.0, 60.0, 120.0, 90.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 295.0, 190.0, 80.0, 90.0, 0.0] + ], + [ + [-0.05, 0.999, -0.999, -0.052, 318.292, -36.64, 320.0, 120.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 302.789, -27.44, 80.0, 90.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 298.078, 62.44, 120.0, 90.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 291.275, 192.26, 80.0, 90.0, 0.0] + ], + [ + [-0.1, 0.995, -0.995, -0.105, 326.396, -32.85, 320.0, 120.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 310.433, -24.47, 80.0, 90.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 301.025, 65.03, 120.0, 90.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 287.436, 194.32, 80.0, 90.0, 0.0] + ], + [ + [-0.16, 0.988, -0.988, -0.156, 334.291, -28.64, 320.0, 120.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 317.911, -21.11, 80.0, 90.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 303.832, 67.78, 120.0, 90.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 283.496, 196.18, 80.0, 90.0, 0.0] + ], + [ + [-0.21, 0.978, -0.978, -0.208, 341.955, -24.03, 320.0, 120.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 325.203, -17.37, 80.0, 90.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 306.491, 70.67, 120.0, 90.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 279.463, 197.83, 80.0, 90.0, 0.0] + ], + [ + [-0.26, 0.966, -0.966, -0.259, 349.367, -19.02, 320.0, 120.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 332.289, -13.24, 80.0, 90.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 308.996, 73.69, 120.0, 90.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 275.349, 199.26, 80.0, 90.0, 0.0] + ], + [ + [-0.31, 0.951, -0.951, -0.309, 356.506, -13.63, 320.0, 120.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 339.15, -8.75, 80.0, 90.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 311.339, 76.84, 120.0, 90.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 271.166, 200.48, 80.0, 90.0, 0.0] + ], + [ + [-0.36, 0.934, -0.934, -0.358, 363.354, -7.87, 320.0, 120.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 345.766, -3.91, 80.0, 90.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 313.513, 80.11, 120.0, 90.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 266.925, 201.48, 80.0, 90.0, 0.0] + ], + [ + [-0.41, 0.914, -0.914, -0.407, 369.891, -1.76, 320.0, 120.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 352.12, 1.27, 80.0, 90.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 315.514, 83.49, 120.0, 90.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 262.638, 202.25, 80.0, 90.0, 0.0] + ], + [ + [-0.45, 0.891, -0.891, -0.454, 376.099, 4.68, 320.0, 120.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 358.194, 6.78, 80.0, 90.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 317.335, 86.97, 120.0, 90.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 258.316, 202.8, 80.0, 90.0, 0.0] + ], + [ + [-0.5, 0.866, -0.866, -0.5, 381.962, 11.44, 320.0, 120.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 363.971, 12.6, 80.0, 90.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 318.971, 90.54, 120.0, 90.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 253.971, 203.12, 80.0, 90.0, 0.0] + ], + [ + [-0.54, 0.839, -0.839, -0.545, 387.462, 18.49, 320.0, 120.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 369.436, 18.71, 80.0, 90.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 320.419, 94.19, 120.0, 90.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 249.615, 203.22, 80.0, 90.0, 0.0] + ], + [ + [-0.59, 0.809, -0.809, -0.588, 392.587, 25.82, 320.0, 120.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 374.574, 25.1, 80.0, 90.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 321.673, 97.91, 120.0, 90.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 245.261, 203.08, 80.0, 90.0, 0.0] + ], + [ + [-0.63, 0.777, -0.777, -0.629, 397.32, 33.42, 320.0, 120.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 379.37, 31.75, 80.0, 90.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 322.731, 101.69, 120.0, 90.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 240.919, 202.72, 80.0, 90.0, 0.0] + ], + [ + [-0.67, 0.743, -0.743, -0.669, 401.65, 41.24, 320.0, 120.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 383.811, 38.64, 80.0, 90.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 323.589, 105.52, 120.0, 90.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 236.602, 202.13, 80.0, 90.0, 0.0] + ], + [ + [-0.71, 0.707, -0.707, -0.707, 405.563, 49.29, 320.0, 120.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 387.886, 45.75, 80.0, 90.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 324.246, 109.39, 120.0, 90.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 232.322, 201.32, 80.0, 90.0, 0.0] + ], + [ + [-0.74, 0.669, -0.669, -0.743, 409.051, 57.53, 320.0, 120.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 391.583, 53.07, 80.0, 90.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 324.7, 113.29, 120.0, 90.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 228.091, 200.28, 80.0, 90.0, 0.0] + ], + [ + [-0.78, 0.629, -0.629, -0.777, 412.103, 65.94, 320.0, 120.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 394.891, 60.57, 80.0, 90.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 324.948, 117.21, 120.0, 90.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 223.919, 199.02, 80.0, 90.0, 0.0] + ], + [ + [-0.81, 0.588, -0.588, -0.809, 414.71, 74.5, 320.0, 120.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 397.803, 68.24, 80.0, 90.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 324.991, 121.14, 120.0, 90.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 219.819, 197.55, 80.0, 90.0, 0.0] + ], + [ + [-0.84, 0.545, -0.545, -0.839, 416.866, 83.18, 320.0, 120.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 400.309, 76.04, 80.0, 90.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 324.829, 125.06, 120.0, 90.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 215.802, 195.86, 80.0, 90.0, 0.0] + ], + [ + [-0.87, 0.5, -0.5, -0.866, 418.564, 91.96, 320.0, 120.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 402.404, 83.97, 80.0, 90.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 324.462, 128.97, 120.0, 90.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 211.878, 193.97, 80.0, 90.0, 0.0] + ], + [ + [-0.89, 0.454, -0.454, -0.891, 419.8, 100.82, 320.0, 120.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 404.081, 92.0, 80.0, 90.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 323.89, 132.86, 120.0, 90.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 208.059, 191.87, 80.0, 90.0, 0.0] + ], + [ + [-0.91, 0.407, -0.407, -0.914, 420.571, 109.73, 320.0, 120.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 405.335, 100.1, 80.0, 90.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 323.116, 136.71, 120.0, 90.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 204.355, 189.58, 80.0, 90.0, 0.0] + ], + [ + [-0.93, 0.358, -0.358, -0.934, 420.875, 118.68, 320.0, 120.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 406.164, 108.26, 80.0, 90.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 322.141, 140.51, 120.0, 90.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 200.776, 187.1, 80.0, 90.0, 0.0] + ], + [ + [-0.95, 0.309, -0.309, -0.951, 420.71, 127.62, 320.0, 120.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 406.564, 116.44, 80.0, 90.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 320.969, 144.26, 120.0, 90.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 197.332, 184.43, 80.0, 90.0, 0.0] + ], + [ + [-0.97, 0.259, -0.259, -0.966, 420.077, 136.54, 320.0, 120.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 406.536, 124.64, 80.0, 90.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 319.602, 147.94, 120.0, 90.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 194.032, 181.58, 80.0, 90.0, 0.0] + ], + [ + [-0.98, 0.208, -0.208, -0.978, 418.978, 145.42, 320.0, 120.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 406.078, 132.83, 80.0, 90.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 318.045, 151.54, 120.0, 90.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 190.886, 178.57, 80.0, 90.0, 0.0] + ], + [ + [-0.99, 0.156, -0.156, -0.988, 417.416, 154.23, 320.0, 120.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 405.193, 140.98, 80.0, 90.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 316.301, 155.06, 120.0, 90.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 187.901, 175.4, 80.0, 90.0, 0.0] + ], + [ + [-0.99, 0.105, -0.105, -0.995, 415.395, 162.95, 320.0, 120.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 403.882, 149.07, 80.0, 90.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 314.375, 158.48, 120.0, 90.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 185.087, 172.07, 80.0, 90.0, 0.0] + ], + [ + [-1.0, 0.052, -0.052, -0.999, 412.921, 171.54, 320.0, 120.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 402.15, 157.09, 80.0, 90.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 312.273, 161.8, 120.0, 90.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 182.451, 168.6, 80.0, 90.0, 0.0] + ], + [ + [-1.0, 0.0, -0.0, -1.0, 410.0, 180.0, 320.0, 120.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 400.0, 165.0, 80.0, 90.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 310.0, 165.0, 120.0, 90.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 180.0, 165.0, 80.0, 90.0, 0.0] + ] + ] + }, + { + "id": "group", + "name": "group rotating in flow", + "labels": ["container", "a", "group.a", "group.b", "c"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 240.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 70.0, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 130.0, 110.0, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 244.6, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 104.71, 67.97, 50.0, 50.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 132.576, 109.48, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 194.601, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 248.97, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 109.408, 66.07, 50.0, 50.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 135.062, 108.98, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 198.969, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 253.09, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 114.079, 64.3, 50.0, 50.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 137.452, 108.5, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 203.094, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 256.96, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 118.712, 62.67, 50.0, 50.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 139.74, 108.03, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 206.964, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 260.57, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 123.294, 61.18, 50.0, 50.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 141.919, 107.58, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 210.568, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 263.9, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 127.812, 59.84, 50.0, 50.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 143.983, 107.15, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 213.896, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 266.94, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 132.253, 58.65, 50.0, 50.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 145.926, 106.75, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 216.94, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 269.69, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 136.606, 57.62, 50.0, 50.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 147.743, 106.36, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 219.69, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 272.14, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 140.859, 56.75, 50.0, 50.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 149.43, 106.01, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 222.14, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.28, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 145.0, 56.03, 50.0, 50.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 150.981, 105.67, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.282, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.11, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 149.018, 55.47, 50.0, 50.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 152.392, 105.36, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.111, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 277.62, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 152.901, 55.08, 50.0, 50.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 153.66, 105.08, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 227.622, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 278.81, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 156.639, 54.86, 50.0, 50.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 154.78, 104.82, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 228.811, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 279.67, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 160.222, 54.79, 50.0, 50.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 155.751, 104.59, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 229.673, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 280.21, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 163.64, 54.9, 50.0, 50.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 156.569, 104.39, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 230.208, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 280.41, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 166.883, 55.16, 50.0, 50.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 157.231, 104.22, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 230.413, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 280.29, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 169.943, 55.59, 50.0, 50.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 157.737, 104.08, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 230.289, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 279.83, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 172.812, 56.19, 50.0, 50.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 158.084, 103.97, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 229.834, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 279.05, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 175.48, 56.94, 50.0, 50.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 158.273, 103.89, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 229.051, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 277.94, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 177.942, 57.86, 50.0, 50.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 158.301, 103.84, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 227.942, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.51, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 180.191, 58.93, 50.0, 50.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 158.17, 103.82, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.51, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.76, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 182.219, 60.16, 50.0, 50.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 157.879, 103.83, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.758, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 272.69, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 184.022, 61.53, 50.0, 50.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 157.43, 103.87, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 222.692, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 270.32, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 185.595, 63.05, 50.0, 50.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 156.823, 103.94, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 220.316, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 267.64, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 186.933, 64.72, 50.0, 50.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 156.061, 104.05, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 217.639, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 264.67, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 188.033, 66.52, 50.0, 50.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 155.145, 104.18, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 214.666, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 261.41, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 188.892, 68.45, 50.0, 50.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 154.077, 104.34, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 211.407, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 257.87, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 189.507, 70.52, 50.0, 50.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 152.862, 104.53, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 207.869, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 254.06, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 189.877, 72.7, 50.0, 50.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 151.502, 104.75, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 204.064, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 250.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 190.0, 75.0, 50.0, 50.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 150.0, 105.0, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 200.0, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 254.06, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 194.064, 77.41, 50.0, 50.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 152.548, 105.28, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 204.064, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 257.87, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 197.869, 79.92, 50.0, 50.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 154.953, 105.58, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 207.869, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 261.41, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 201.407, 82.53, 50.0, 50.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 157.206, 105.91, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 211.407, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 264.67, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 204.666, 85.23, 50.0, 50.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 159.303, 106.26, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 214.666, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 267.64, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 207.639, 88.01, 50.0, 50.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 161.237, 106.63, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 217.639, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 270.32, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 210.316, 90.86, 50.0, 50.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 163.004, 107.03, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 220.316, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 272.69, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 212.692, 93.78, 50.0, 50.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 164.597, 107.46, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 222.692, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.76, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 214.758, 96.76, 50.0, 50.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 166.014, 107.9, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.758, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.51, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 216.51, 99.79, 50.0, 50.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 167.25, 108.36, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.51, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 277.94, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 217.942, 102.86, 50.0, 50.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 168.301, 108.84, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 227.942, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 279.05, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 219.051, 105.96, 50.0, 50.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 169.165, 109.34, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 229.051, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 279.83, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 219.834, 109.09, 50.0, 50.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 169.84, 109.85, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 229.834, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 280.29, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 220.289, 112.23, 50.0, 50.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 170.323, 110.38, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 230.289, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 280.41, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 220.413, 115.39, 50.0, 50.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 170.614, 110.91, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 230.413, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 280.21, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 220.208, 118.54, 50.0, 50.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 170.711, 111.46, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 230.208, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 279.67, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 219.673, 121.68, 50.0, 50.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 170.614, 112.02, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 229.673, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 278.81, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 218.811, 124.8, 50.0, 50.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 170.323, 112.59, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 228.811, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 277.62, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 217.622, 127.89, 50.0, 50.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 169.84, 113.17, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 227.622, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 276.11, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 216.111, 130.95, 50.0, 50.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 169.165, 113.75, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.111, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 274.28, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 214.282, 133.97, 50.0, 50.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 168.301, 114.33, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.282, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 272.14, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 212.14, 136.94, 50.0, 50.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 167.25, 114.92, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 222.14, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 269.69, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 209.69, 139.84, 50.0, 50.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 166.014, 115.5, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 219.69, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 266.94, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 206.94, 142.68, 50.0, 50.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 164.597, 116.08, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 216.94, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 263.9, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 203.896, 145.44, 50.0, 50.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 163.004, 116.67, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 213.896, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 260.57, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 200.568, 148.11, 50.0, 50.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 161.237, 117.24, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 210.568, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 256.96, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 196.964, 150.7, 50.0, 50.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 159.303, 117.81, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 206.964, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 253.09, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 193.094, 153.19, 50.0, 50.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 157.206, 118.37, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 203.094, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 248.97, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 188.969, 155.57, 50.0, 50.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 154.952, 118.93, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 198.969, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 244.6, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 184.601, 157.84, 50.0, 50.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 152.548, 119.47, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 194.601, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 240.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 180.0, 160.0, 50.0, 50.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 150.0, 120.0, 50.0, 50.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 65.0, 60.0, 100.0, 0.0] + ] + ] + }, + { + "id": "two", + "name": "two rotating siblings", + "labels": ["container", "rot", "still", "rot(-)"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 65.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 170.0, 65.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 230.3, 190.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 35.234, 63.5, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 105.151, 65.0, 60.0, 100.0, 0.0], + [1.0, -0.052, 0.052, 0.999, 175.151, 66.64, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 240.25, 190.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 40.453, 62.14, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 110.124, 65.0, 60.0, 100.0, 0.0], + [0.99, -0.105, 0.105, 0.995, 180.124, 68.41, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 249.81, 190.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 45.643, 60.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 114.905, 65.0, 60.0, 100.0, 0.0], + [0.99, -0.156, 0.156, 0.988, 184.905, 70.31, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 258.96, 190.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 50.791, 59.86, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 119.48, 65.0, 60.0, 100.0, 0.0], + [0.98, -0.208, 0.208, 0.978, 189.48, 72.33, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 267.67, 190.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 55.882, 58.94, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 123.837, 65.0, 60.0, 100.0, 0.0], + [0.97, -0.259, 0.259, 0.966, 193.837, 74.47, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 275.93, 190.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 60.902, 58.18, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 127.965, 65.0, 60.0, 100.0, 0.0], + [0.95, -0.309, 0.309, 0.951, 197.965, 76.72, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 283.7, 190.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 65.837, 57.57, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 131.852, 65.0, 60.0, 100.0, 0.0], + [0.93, -0.358, 0.358, 0.934, 201.852, 79.07, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 290.97, 190.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 70.674, 57.12, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 135.486, 65.0, 60.0, 100.0, 0.0], + [0.91, -0.407, 0.407, 0.914, 205.486, 81.52, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 297.72, 190.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 75.399, 56.83, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 138.859, 65.0, 60.0, 100.0, 0.0], + [0.89, -0.454, 0.454, 0.891, 208.859, 84.07, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 303.92, 190.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 80.0, 56.7, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 141.962, 65.0, 60.0, 100.0, 0.0], + [0.87, -0.5, 0.5, 0.866, 211.962, 86.7, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 309.57, 190.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 84.464, 56.73, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 144.784, 65.0, 60.0, 100.0, 0.0], + [0.84, -0.545, 0.545, 0.839, 214.784, 89.41, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 314.64, 190.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 88.779, 56.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 147.32, 65.0, 60.0, 100.0, 0.0], + [0.81, -0.588, 0.588, 0.809, 217.32, 92.18, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 319.12, 190.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 92.932, 57.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 149.561, 65.0, 60.0, 100.0, 0.0], + [0.78, -0.629, 0.629, 0.777, 219.561, 95.02, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 323.0, 190.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 96.913, 57.77, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 151.502, 65.0, 60.0, 100.0, 0.0], + [0.74, -0.669, 0.669, 0.743, 221.502, 97.92, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 326.27, 190.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 100.711, 58.43, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 153.137, 65.0, 60.0, 100.0, 0.0], + [0.71, -0.707, 0.707, 0.707, 223.137, 100.86, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 328.92, 190.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 104.314, 59.25, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 154.462, 65.0, 60.0, 100.0, 0.0], + [0.67, -0.743, 0.743, 0.669, 224.462, 103.84, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 330.95, 190.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 107.715, 60.22, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 155.474, 65.0, 60.0, 100.0, 0.0], + [0.63, -0.777, 0.777, 0.629, 225.474, 106.85, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 332.34, 190.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 110.902, 61.34, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.169, 65.0, 60.0, 100.0, 0.0], + [0.59, -0.809, 0.809, 0.588, 226.169, 109.88, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 333.09, 190.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 113.867, 62.61, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.545, 65.0, 60.0, 100.0, 0.0], + [0.54, -0.839, 0.839, 0.545, 226.545, 112.93, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 333.21, 190.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 116.603, 64.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.603, 65.0, 60.0, 100.0, 0.0], + [0.5, -0.866, 0.866, 0.5, 226.603, 115.98, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 332.68, 190.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 119.101, 65.57, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.34, 65.0, 60.0, 100.0, 0.0], + [0.45, -0.891, 0.891, 0.454, 226.34, 119.03, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 331.52, 190.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 121.355, 67.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 155.759, 65.0, 60.0, 100.0, 0.0], + [0.41, -0.914, 0.914, 0.407, 225.759, 122.07, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 329.72, 190.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 123.358, 69.07, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 154.86, 65.0, 60.0, 100.0, 0.0], + [0.36, -0.934, 0.934, 0.358, 224.86, 125.09, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 327.29, 190.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 125.106, 71.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 153.647, 65.0, 60.0, 100.0, 0.0], + [0.31, -0.951, 0.951, 0.309, 223.647, 128.08, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 324.24, 190.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 126.593, 73.08, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 152.122, 65.0, 60.0, 100.0, 0.0], + [0.26, -0.966, 0.966, 0.259, 222.122, 131.04, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 320.58, 190.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 127.815, 75.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 150.289, 65.0, 60.0, 100.0, 0.0], + [0.21, -0.978, 0.978, 0.208, 220.289, 133.95, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 316.31, 190.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 128.769, 77.55, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 148.155, 65.0, 60.0, 100.0, 0.0], + [0.16, -0.988, 0.988, 0.156, 218.155, 136.81, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 311.45, 190.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 129.452, 79.94, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 145.724, 65.0, 60.0, 100.0, 0.0], + [0.1, -0.995, 0.995, 0.105, 215.724, 139.61, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 306.01, 190.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 129.863, 82.42, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 143.003, 65.0, 60.0, 100.0, 0.0], + [0.05, -0.999, 0.999, 0.052, 213.003, 142.34, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 300.0, 190.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 130.0, 85.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 140.0, 65.0, 60.0, 100.0, 0.0], + [0.0, -1.0, 1.0, 0.0, 210.0, 145.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 306.01, 190.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 133.003, 87.66, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 143.003, 65.0, 60.0, 100.0, 0.0], + [-0.05, -0.999, 0.999, -0.052, 216.143, 147.58, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 311.45, 190.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 135.724, 90.39, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 145.724, 65.0, 60.0, 100.0, 0.0], + [-0.1, -0.995, 0.995, -0.105, 221.996, 150.06, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 316.31, 190.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 138.155, 93.19, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 148.155, 65.0, 60.0, 100.0, 0.0], + [-0.16, -0.988, 0.988, -0.156, 227.541, 152.45, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 320.58, 190.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 140.289, 96.05, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 150.289, 65.0, 60.0, 100.0, 0.0], + [-0.21, -0.978, 0.978, -0.208, 232.764, 154.74, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 324.24, 190.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 142.122, 98.96, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 152.122, 65.0, 60.0, 100.0, 0.0], + [-0.26, -0.966, 0.966, -0.259, 237.651, 156.92, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 327.29, 190.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 143.647, 101.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 153.647, 65.0, 60.0, 100.0, 0.0], + [-0.31, -0.951, 0.951, -0.309, 242.188, 158.98, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 329.72, 190.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 144.86, 104.91, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 154.86, 65.0, 60.0, 100.0, 0.0], + [-0.36, -0.934, 0.934, -0.358, 246.362, 160.93, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 331.52, 190.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 145.759, 107.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 155.759, 65.0, 60.0, 100.0, 0.0], + [-0.41, -0.914, 0.914, -0.407, 250.163, 162.74, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 332.68, 190.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 146.34, 110.97, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.34, 65.0, 60.0, 100.0, 0.0], + [-0.45, -0.891, 0.891, -0.454, 253.58, 164.43, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 333.21, 190.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 146.603, 114.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.603, 65.0, 60.0, 100.0, 0.0], + [-0.5, -0.866, 0.866, -0.5, 256.603, 165.98, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 333.09, 190.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 146.545, 117.07, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.545, 65.0, 60.0, 100.0, 0.0], + [-0.54, -0.839, 0.839, -0.545, 259.224, 167.39, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 332.34, 190.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 146.169, 120.12, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 156.169, 65.0, 60.0, 100.0, 0.0], + [-0.59, -0.809, 0.809, -0.588, 261.436, 168.66, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 330.95, 190.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 145.474, 123.15, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 155.474, 65.0, 60.0, 100.0, 0.0], + [-0.63, -0.777, 0.777, -0.629, 263.233, 169.78, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 328.92, 190.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 144.462, 126.16, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 154.462, 65.0, 60.0, 100.0, 0.0], + [-0.67, -0.743, 0.743, -0.669, 264.61, 170.75, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 326.27, 190.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 143.137, 129.14, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 153.137, 65.0, 60.0, 100.0, 0.0], + [-0.71, -0.707, 0.707, -0.707, 265.563, 171.57, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 323.0, 190.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 141.502, 132.08, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 151.502, 65.0, 60.0, 100.0, 0.0], + [-0.74, -0.669, 0.669, -0.743, 266.09, 172.23, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 319.12, 190.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 139.561, 134.98, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 149.561, 65.0, 60.0, 100.0, 0.0], + [-0.78, -0.629, 0.629, -0.777, 266.19, 172.74, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 314.64, 190.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 137.32, 137.82, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 147.32, 65.0, 60.0, 100.0, 0.0], + [-0.81, -0.588, 0.588, -0.809, 265.861, 173.08, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 309.57, 190.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 134.784, 140.59, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 144.784, 65.0, 60.0, 100.0, 0.0], + [-0.84, -0.545, 0.545, -0.839, 265.104, 173.27, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 303.92, 190.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 131.962, 143.3, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 141.962, 65.0, 60.0, 100.0, 0.0], + [-0.87, -0.5, 0.5, -0.866, 263.923, 173.3, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 297.72, 190.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 128.859, 145.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 138.859, 65.0, 60.0, 100.0, 0.0], + [-0.89, -0.454, 0.454, -0.891, 262.32, 173.17, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 290.97, 190.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 125.486, 148.48, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 135.486, 65.0, 60.0, 100.0, 0.0], + [-0.91, -0.407, 0.407, -0.914, 260.299, 172.88, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 283.7, 190.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 121.852, 150.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 131.852, 65.0, 60.0, 100.0, 0.0], + [-0.93, -0.358, 0.358, -0.934, 257.866, 172.43, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 275.93, 190.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 117.965, 153.28, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 127.965, 65.0, 60.0, 100.0, 0.0], + [-0.95, -0.309, 0.309, -0.951, 255.028, 171.82, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 267.67, 190.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 113.837, 155.53, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 123.837, 65.0, 60.0, 100.0, 0.0], + [-0.97, -0.259, 0.259, -0.966, 251.793, 171.06, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 258.96, 190.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 109.48, 157.67, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 119.48, 65.0, 60.0, 100.0, 0.0], + [-0.98, -0.208, 0.208, -0.978, 248.169, 170.14, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 249.81, 190.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 104.905, 159.69, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 114.905, 65.0, 60.0, 100.0, 0.0], + [-0.99, -0.156, 0.156, -0.988, 244.166, 169.08, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 240.25, 190.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 100.124, 161.59, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 110.124, 65.0, 60.0, 100.0, 0.0], + [-0.99, -0.105, 0.105, -0.995, 239.795, 167.86, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 230.3, 190.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 95.151, 163.36, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 105.151, 65.0, 60.0, 100.0, 0.0], + [-1.0, -0.052, 0.052, -0.999, 235.069, 166.5, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 220.0, 190.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 90.0, 165.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 100.0, 65.0, 60.0, 100.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 230.0, 165.0, 60.0, 100.0, 0.0] + ] + ] + }, + { + "id": "between", + "name": "space-between × rotation", + "labels": ["container", "a", "rot", "c"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 220.0, 55.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 222.658, 53.5, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 225.391, 52.14, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 228.191, 50.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 231.051, 49.86, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 233.963, 48.94, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 236.919, 48.18, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 239.911, 47.57, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 242.93, 47.12, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 245.969, 46.83, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 249.019, 46.7, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 252.072, 46.73, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 255.119, 46.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 258.152, 47.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 261.162, 47.77, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 264.142, 48.43, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 267.083, 49.25, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 269.978, 50.22, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 272.817, 51.34, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 275.594, 52.61, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 278.301, 54.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 280.931, 55.57, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 283.475, 57.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 285.928, 59.07, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 288.282, 61.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 290.532, 63.08, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 292.67, 65.26, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 294.691, 67.55, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 296.59, 69.94, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 298.361, 72.42, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 300.0, 75.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 301.502, 77.66, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 302.862, 80.39, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 304.077, 83.19, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 305.145, 86.05, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 306.061, 88.96, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 306.823, 91.92, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 307.43, 94.91, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 307.879, 97.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 308.17, 100.97, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 308.301, 104.02, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 308.273, 107.07, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 308.084, 110.12, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 307.737, 113.15, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 307.231, 116.16, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 306.569, 119.14, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 305.751, 122.08, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 304.78, 124.98, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 303.66, 127.82, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 302.392, 130.59, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 300.981, 133.3, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 299.43, 135.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 297.743, 138.48, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 295.926, 140.93, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 293.983, 143.28, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 291.919, 145.53, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 289.74, 147.67, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 287.452, 149.69, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 285.062, 151.59, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 282.576, 153.36, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 460.0, 170.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 55.0, 60.0, 100.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 280.0, 155.0, 60.0, 100.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 410.0, 55.0, 60.0, 100.0, 0.0] + ] + ] + }, + { + "id": "wrap", + "name": "wrap reflow mid-rotation", + "labels": ["container", "a", "rot", "c", "d"], + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 110.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 143.5, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.052, -0.052, 0.999, 116.28, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 196.184, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 276.184, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 146.66, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.99, 0.105, -0.105, 0.995, 122.543, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 202.16, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 282.16, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 149.47, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.99, 0.156, -0.156, 0.988, 128.772, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 207.91, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 287.91, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 151.93, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.98, 0.208, -0.208, 0.978, 134.949, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 213.42, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 293.42, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 154.03, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.97, 0.259, -0.259, 0.966, 141.058, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 218.673, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 298.673, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 155.76, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.95, 0.309, -0.309, 0.951, 147.082, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 223.656, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 303.656, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 157.12, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.93, 0.358, -0.358, 0.934, 153.004, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 228.355, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 308.355, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.1, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.91, 0.407, -0.407, 0.914, 158.808, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 232.757, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 312.757, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.7, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.89, 0.454, -0.454, 0.891, 164.479, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 236.849, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 316.849, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.92, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.87, 0.5, -0.5, 0.866, 170.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 240.622, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.92, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.77, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.84, 0.545, -0.545, 0.839, 175.357, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 244.064, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.77, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.23, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.81, 0.588, -0.588, 0.809, 180.534, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 247.165, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.23, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 287.31, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.78, 0.629, -0.629, 0.777, 185.518, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 249.919, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 177.31, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 286.02, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.74, 0.669, -0.669, 0.743, 190.296, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 252.316, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 176.02, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 284.35, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.71, 0.707, -0.707, 0.707, 194.853, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 254.35, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 174.35, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 282.32, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.67, 0.743, -0.743, 0.669, 199.177, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 256.017, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 172.32, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 279.92, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.63, 0.777, -0.777, 0.629, 203.258, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 257.31, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 169.92, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 277.17, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.59, 0.809, -0.809, 0.588, 207.082, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.227, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 167.17, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 274.06, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.54, 0.839, -0.839, 0.545, 210.64, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.765, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 164.06, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.62, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.5, 0.866, -0.866, 0.5, 213.923, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.923, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.62, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.45, 0.891, -0.891, 0.454, 216.921, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.7, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.41, 0.914, -0.914, 0.407, 219.625, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.097, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.36, 0.934, -0.934, 0.358, 222.03, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 257.115, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.31, 0.951, -0.951, 0.309, 224.127, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 255.758, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.26, 0.966, -0.966, 0.259, 225.911, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 254.028, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.21, 0.978, -0.978, 0.208, 227.378, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 251.932, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.16, 0.988, -0.988, 0.156, 228.523, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 249.473, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.1, 0.995, -0.995, 0.105, 229.343, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 246.66, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.05, 0.999, -0.999, 0.052, 229.836, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 243.499, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [0.0, 1.0, -1.0, 0.0, 230.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 240.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 320.0, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.05, 0.999, -0.999, -0.052, 233.499, 36.28, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 243.499, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.1, 0.995, -0.995, -0.105, 236.66, 42.54, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 246.66, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.16, 0.988, -0.988, -0.156, 239.473, 48.77, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 249.473, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.21, 0.978, -0.978, -0.208, 241.932, 54.95, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 251.932, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.26, 0.966, -0.966, -0.259, 244.028, 61.06, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 254.028, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.31, 0.951, -0.951, -0.309, 245.758, 67.08, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 255.758, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.36, 0.934, -0.934, -0.358, 247.115, 73.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 257.115, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.41, 0.914, -0.914, -0.407, 248.097, 78.81, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.097, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.45, 0.891, -0.891, -0.454, 248.7, 84.48, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.7, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 270.62, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.5, 0.866, -0.866, -0.5, 248.923, 90.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.923, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 160.62, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 274.06, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.54, 0.839, -0.839, -0.545, 248.765, 95.36, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.765, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 164.06, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 277.17, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.59, 0.809, -0.809, -0.588, 248.227, 100.53, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 258.227, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 167.17, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 279.92, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.63, 0.777, -0.777, -0.629, 247.31, 105.52, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 257.31, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 169.92, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 282.32, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.67, 0.743, -0.743, -0.669, 246.017, 110.3, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 256.017, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 172.32, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 284.35, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.71, 0.707, -0.707, -0.707, 244.35, 114.85, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 254.35, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 174.35, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 286.02, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.74, 0.669, -0.669, -0.743, 242.316, 119.18, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 252.316, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 176.02, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 287.31, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.78, 0.629, -0.629, -0.777, 239.919, 123.26, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 249.919, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 177.31, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.23, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.81, 0.588, -0.588, -0.809, 237.165, 127.08, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 247.165, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.23, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.77, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.84, 0.545, -0.545, -0.839, 234.064, 130.64, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 244.064, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.77, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 288.92, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.87, 0.5, -0.5, -0.866, 230.622, 133.92, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 240.622, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 178.92, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.7, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.89, 0.454, -0.454, -0.891, 226.849, 136.92, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 236.849, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 316.849, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 158.1, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.91, 0.407, -0.407, -0.914, 222.757, 139.63, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 232.757, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 312.757, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 157.12, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.93, 0.358, -0.358, -0.934, 218.355, 142.03, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 228.355, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 308.355, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 155.76, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.95, 0.309, -0.309, -0.951, 213.656, 144.13, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 223.656, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 303.656, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 154.03, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.97, 0.259, -0.259, -0.966, 208.673, 145.91, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 218.673, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 298.673, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 151.93, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.98, 0.208, -0.208, -0.978, 203.42, 147.38, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 213.42, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 293.42, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 149.47, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.99, 0.156, -0.156, -0.988, 197.91, 148.52, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 207.91, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 287.91, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 146.66, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-0.99, 0.105, -0.105, -0.995, 192.16, 149.34, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 202.16, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 282.16, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 143.5, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-1.0, 0.052, -0.052, -0.999, 186.184, 149.84, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 196.184, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 276.184, 30.0, 70.0, 120.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 20.0, 380.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 30.0, 30.0, 70.0, 120.0, 0.0], + [-1.0, 0.0, -0.0, -1.0, 180.0, 150.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 30.0, 70.0, 120.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 270.0, 30.0, 70.0, 120.0, 0.0] + ] + ] + }, + { + "id": "flipzero", + "name": "resize across zero (flip)", + "labels": ["wall", "R", "slide", "R", "flip", "R"], + "outlines": [0, 2, 4], + "marker": true, + "sweep": { "label": "drag", "from": 60, "to": -60, "unit": "px" }, + "frames": [ + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 60.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 60.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 60.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 58.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 58.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 58.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 56.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 56.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 56.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 54.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 54.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 54.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 52.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 52.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 52.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 50.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 50.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 50.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 48.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 48.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 48.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 46.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 46.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 46.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 44.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 44.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 44.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 42.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 42.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 42.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 40.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 40.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 40.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 38.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 38.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 38.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 36.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 36.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 36.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 34.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 34.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 34.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 32.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 32.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 32.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 30.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 30.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 30.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 28.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 28.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 28.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 26.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 26.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 26.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 24.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 24.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 24.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 22.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 22.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 22.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 20.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 20.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 20.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 18.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 18.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 18.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 16.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 16.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 16.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 14.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 14.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 14.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 12.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 12.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 12.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 10.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 10.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 10.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 8.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 8.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 8.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 6.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 6.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 6.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 4.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 4.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 4.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 2.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 2.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 2.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 250.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 0.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 248.0, 65.0, 2.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 2.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 246.0, 65.0, 4.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 4.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 244.0, 65.0, 6.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 6.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 242.0, 65.0, 8.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 8.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 240.0, 65.0, 10.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 10.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 238.0, 65.0, 12.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 12.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 236.0, 65.0, 14.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 14.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 234.0, 65.0, 16.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 16.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 232.0, 65.0, 18.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 18.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 230.0, 65.0, 20.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 20.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 228.0, 65.0, 22.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 22.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 226.0, 65.0, 24.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 24.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 224.0, 65.0, 26.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 26.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 222.0, 65.0, 28.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 28.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 220.0, 65.0, 30.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 30.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 218.0, 65.0, 32.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 32.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 216.0, 65.0, 34.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 34.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 214.0, 65.0, 36.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 36.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 212.0, 65.0, 38.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 38.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 210.0, 65.0, 40.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 40.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 208.0, 65.0, 42.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 42.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 206.0, 65.0, 44.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 44.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 204.0, 65.0, 46.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 46.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 202.0, 65.0, 48.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 48.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 200.0, 65.0, 50.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 50.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 198.0, 65.0, 52.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 52.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 196.0, 65.0, 54.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 54.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 194.0, 65.0, 56.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 56.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 192.0, 65.0, 58.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 58.0, 70.0, 0.0] + ], + [ + [1.0, 0.0, 0.0, 1.0, 20.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 95.0, 65.0, 0.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 175.0, 30.0, 145.0, 140.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 190.0, 65.0, 60.0, 70.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 330.0, 30.0, 145.0, 140.0, 0.0], + [-1.0, 0.0, 0.0, 1.0, 405.0, 65.0, 60.0, 70.0, 0.0] + ] + ] + } + ] +} diff --git a/model-v2/a/edge-cases/index.html b/model-v2/a/edge-cases/index.html new file mode 100644 index 0000000000..698d2d5ae3 --- /dev/null +++ b/model-v2/a/edge-cases/index.html @@ -0,0 +1,4053 @@ +anchor — rotation × layout edge cases + + +
+
+
+ model-v2 · anchor · edge cases — 2026-07-07 +
+

Rotation × layout, at the edges.

+

+ Nine scenes where the model’s laws intersect, + resolved by the actual Rust lab (every frame is engine output, not + a mockup). The question per scene: does it read naturally? The + discontinuity meter is the objective proxy — the biggest single-step + jump of any box through the whole sweep. Two scenes fail it; they are now + findings E-A11/E-A12, not footnotes. The ninth scene sweeps a drag, + not an angle — the cross-zero resize decision (DEC-9), three + policies side by side. +

+
+ +
+ +
+
+ θ = 0° + + +
+ +
+ container + biggest single-step box jump this sweep + + +
+
+
+
+ + diff --git a/model-v2/a/free-editing/index.html b/model-v2/a/free-editing/index.html new file mode 100644 index 0000000000..8956352a94 --- /dev/null +++ b/model-v2/a/free-editing/index.html @@ -0,0 +1,911 @@ +anchor — free context: the editing experience + + +
+
+
+ model-v2 · anchor · free context — 2026-07-07 +
+

Without layout: the base case, hands on.

+

+ A live free-context document — no layout engine runs here; + resolution is bindings + one rotation scalar, mirrored from the + lab’s free-context rules. Drag bodies to move, the corner dot to + resize, the stick to rotate. Watch two things: the + IR stays SVG-shaped, and every gesture is a + counted set of scalar writes (never a matrix). Then drag the + artboard’s right edge: the green badge is + end-pinned and the amber bar is spanned — they respond + with zero writes, because position is a relation. That is what SVG + cannot say, and it is the on-ramp to layout-as-feature. +

+
+ +
+
+

canvas — free frame, five nodes

+ +
+ click = select · drag = move (2 writes) · corner dot = + resize (2) · stick = rotate (1; group = 3 center-feel) · + right-edge grip = artboard resize (0 writes) +
+
+ select a node — reads come from the resolved tier +
+
+
+

the IR — canonical print, live

+

+      

writes — per gesture, typed

+
no gestures yet
+
+
+ +
+ what this shows + Free context is not a degraded mode — it is the model’s ground + floor. + Pin Start is literally svg x/y; rotation is one scalar with + one pivot rule (box center — so rotate is a 1-field write, no x/y + compensation, and resize-after-rotate never drifts: R-4 commutes, + lab-tested). A group is a named set with a coordinate space; dragging + it is 2 writes on the group, its children untouched. What SVG has no words + for: end / center / span bindings — responsiveness without any + layout engine, which is why “svg-first, layout-as-feature” is a + gradient here, not a mode switch: free (bindings) → constrained + (end/center/span) → flex (a frame feature). The lens (general + transform) never appears in the base case — it exists only when you + import one. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
gesturewritesfields
move2x.offset, y.offset (delta-form; works under any anchor)
rotate (boxed)1rotation — center pivot needs no compensation
rotate (group, center-feel)3rotation, x, y — Figma’s trick over legible scalars
resize (corner)2–4w, h (+ x, y from the top-left corner)
resize across zero2–3|extent|, pin, flip toggle (E-A14; out-and-back = identity)
artboard resize0end/center/span children respond in the resolved tier
move a spanned axistyped errorErr(AxisOwnedBySpan) — the wall is visible, never silent
+
+
+ + diff --git a/model-v2/a/lab/Cargo.lock b/model-v2/a/lab/Cargo.lock new file mode 100644 index 0000000000..647861959a --- /dev/null +++ b/model-v2/a/lab/Cargo.lock @@ -0,0 +1,130 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anchor-lab" +version = "0.0.0" +dependencies = [ + "quick-xml", + "taffy", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/model-v2/a/lab/Cargo.toml b/model-v2/a/lab/Cargo.toml new file mode 100644 index 0000000000..41e5f954b3 --- /dev/null +++ b/model-v2/a/lab/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "anchor-lab" +version = "0.0.0" +edition = "2021" +publish = false + +# Standalone on purpose: this crate is the model-v2 `anchor` proving lab. +# It is NOT a member of the repo workspace (see model-v2/a/README.md — +# experiment code is promoted into crates/ only at phase 4). +[workspace] + +[dependencies] +taffy = "0.9.2" +quick-xml = "0.37" + +[profile.release] +debug = true diff --git a/model-v2/a/lab/README.md b/model-v2/a/lab/README.md new file mode 100644 index 0000000000..b2deb9fd16 --- /dev/null +++ b/model-v2/a/lab/README.md @@ -0,0 +1,35 @@ +# anchor-lab + +The standalone proving crate for the `anchor` model +([`../../models/a.md`](../../models/a.md)) — lab subset: `frame`, `shape` +(rect/ellipse/line), `text` (deterministic 0.6/1.2 metric), `group`, +`lens` (2D ops). **Not** a member of the repo workspace; promotion into +`crates/` happens at phase 4 only. + +```sh +cargo test # 56 conformance-derived tests (MM/G/R/L/T/D/M suites) +cargo run --bin e1 # E1 sweep → ../e1-rotation-in-flow/ artifacts +cargo run --bin e3 -- truth # E3 ground truth lines +cargo run --bin e3 -- score f # score a prediction file +cargo run --release --bin e4 # E4 resolver benchmarks +cargo run --release --bin e5scan -- # E5 SVG corpus scan +``` + +Map: + +- `src/model.rs` — Header (AxisBinding / SizeIntent / rotation / flow), + payloads, `DocBuilder` +- `src/resolve.rs` — the four-phase resolver (§6); `RotationInFlow` flag + (E1); §8 `Report`s; Taffy 0.9 per-container runs with the two + dependency guards (rounding off; grow stripped in indefinite-main + intrinsic passes) +- `src/ops.rs` — gesture ops with typed errors + write-count doctrine +- `src/textir.rs` — the agent text IR parser + canonical printer (E3) +- `src/svgout.rs` — SVG snapshots of resolved documents +- `src/math.rs` / `src/measure.rs` — affine + deterministic text metric +- `tests/` — the conformance-derived suites; `tests/common/mod.rs` helpers + +Known lab simplifications (declared in the REPORT's lose column): +children as ordered `Vec` (no fractional index), per-container Taffy +trees (engine uses one tree), no incremental invalidation, no +tray/image/embed/vector/bool payloads. diff --git a/model-v2/a/lab/src/bin/e1.rs b/model-v2/a/lab/src/bin/e1.rs new file mode 100644 index 0000000000..e4b6c816f6 --- /dev/null +++ b/model-v2/a/lab/src/bin/e1.rs @@ -0,0 +1,338 @@ +//! E1 — rotation-in-flow prototype driver. +//! +//! Builds the triage-#27 scene ("rotate a card inside an auto-flowing +//! list; animate the turn"), sweeps the middle card's rotation 0→360 under +//! BOTH semantics, and emits: +//! +//! - `metrics.csv` — per-frame measurements (container width, sibling +//! position, overlap areas, per-step displacement) +//! - `frames/theta_*.svg` — side-by-side snapshots at key angles +//! - `demo.html` — interactive scrubber over the precomputed frames +//! - stdout — the measured summary that feeds `verdict.md` +//! +//! Every θ step is applied through the op layer (1 document write), +//! proving OP-ROT under flow stays a single-field gesture in both modes. + +use anchor_lab::model::*; +use anchor_lab::ops; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; +use anchor_lab::svgout; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +const OUT: &str = "../e1-rotation-in-flow"; +const STEP: f32 = 2.0; + +struct Scene { + doc: Document, + frame: NodeId, + cards: [NodeId; 3], +} + +/// The E1 scene: hug row of three portrait cards (60×100), gap 10, pad 10. +/// `fixed_w`: Some → fixed-width container (no breathing possible), None → +/// hug (container reacts). +fn scene(fixed_w: Option) -> Scene { + let mut b = DocBuilder::new(); + let mut fh = Header::new( + match fixed_w { + Some(w) => SizeIntent::Fixed(w), + None => SizeIntent::Auto, + }, + SizeIntent::Fixed(140.0), + ); + fh.x = AxisBinding::start(20.0); + fh.y = AxisBinding::start(20.0); + let fp = Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Row, + gap_main: 10.0, + padding: EdgeInsets::all(10.0), + cross_align: CrossAlign::Center, + ..Default::default() + }, + clips_content: false, + }; + let f = b.add(0, fh, fp); + let mut cards = vec![]; + for (i, color) in ["#4a90d9", "#e2574c", "#57b894"].iter().enumerate() { + let h = Header::new(SizeIntent::Fixed(60.0), SizeIntent::Fixed(100.0)); + let c = b.add(f, h, Payload::Shape { desc: ShapeDesc::Rect }); + b.node_mut(c).fill = Some(color.to_string()); + let _ = i; + cards.push(c); + } + Scene { + doc: b.build(), + frame: f, + cards: [cards[0], cards[1], cards[2]], + } +} + +#[derive(Clone, Copy)] +struct Frame { + theta: f32, + container_w: f32, + card2_x: f32, + overlap01: f32, + overlap12: f32, +} + +fn sweep(mode: RotationInFlow, fixed_w: Option) -> Vec { + let opts = ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: mode, + }; + let mut s = scene(fixed_w); + let mut frames = vec![]; + let mut theta = 0.0f32; + while theta <= 360.0 { + // the gesture: exactly one document write per step + let writes = ops::set_rotation(&mut s.doc, s.cards[1], theta).unwrap(); + assert_eq!(writes, 1, "rotation stays a 1-field gesture in flow"); + let r = resolve(&s.doc, &opts); + frames.push(Frame { + theta, + container_w: r.box_of(s.frame).w, + card2_x: r.box_of(s.cards[2]).x, + overlap01: r + .aabb_of(s.cards[0]) + .intersection_area(&r.aabb_of(s.cards[1])), + overlap12: r + .aabb_of(s.cards[1]) + .intersection_area(&r.aabb_of(s.cards[2])), + }); + theta += STEP; + } + frames +} + +fn summarize(name: &str, frames: &[Frame]) -> String { + let wmin = frames.iter().map(|f| f.container_w).fold(f32::MAX, f32::min); + let wmax = frames.iter().map(|f| f.container_w).fold(0.0f32, f32::max); + let peak_overlap = frames + .iter() + .map(|f| f.overlap01.max(f.overlap12)) + .fold(0.0f32, f32::max); + let max_step = frames + .windows(2) + .map(|w| (w[1].card2_x - w[0].card2_x).abs()) + .fold(0.0f32, f32::max); + let peak_theta = frames + .iter() + .max_by(|a, b| a.card2_x.total_cmp(&b.card2_x)) + .map(|f| f.theta) + .unwrap_or(0.0); + format!( + "{name}: container_w ∈ [{wmin:.1}, {wmax:.1}] (breathing {:.1}px), \ + peak sibling overlap {peak_overlap:.0}px², \ + max |Δsibling_x| per {STEP}° = {max_step:.2}px, \ + sibling_x peaks at θ={peak_theta}°", + wmax - wmin + ) +} + +fn write_csv(path: &Path, aabb: &[Frame], visual: &[Frame], fixed: &[Frame]) { + let mut out = String::from( + "theta,aabb_container_w,aabb_card2_x,aabb_overlap,visual_container_w,visual_card2_x,visual_overlap,fixedw_aabb_card2_x,fixedw_aabb_overlap\n", + ); + for i in 0..aabb.len() { + let _ = writeln!( + out, + "{},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3}", + aabb[i].theta, + aabb[i].container_w, + aabb[i].card2_x, + aabb[i].overlap01.max(aabb[i].overlap12), + visual[i].container_w, + visual[i].card2_x, + visual[i].overlap01.max(visual[i].overlap12), + fixed[i].card2_x, + fixed[i].overlap01.max(fixed[i].overlap12), + ); + } + fs::write(path, out).unwrap(); +} + +fn snapshot(theta: f32) -> String { + let mut svg = String::new(); + let _ = writeln!( + svg, + r#""# + ); + for (i, (mode, label)) in [ + (RotationInFlow::AabbParticipates, "anchor: AABB participates"), + (RotationInFlow::VisualOnly, "control: visual-only (CSS)"), + ] + .iter() + .enumerate() + { + let opts = ResolveOptions { + viewport: (640.0, 200.0), + rotation_in_flow: *mode, + }; + let mut s = scene(None); + ops::set_rotation(&mut s.doc, s.cards[1], theta).unwrap(); + let r = resolve(&s.doc, &opts); + let inner = svgout::render( + &s.doc, + &r, + &svgout::SvgOptions { + show_aabb: true, + width: 640.0, + height: 200.0, + }, + ); + let y = 10 + i * 200; + let _ = writeln!(svg, r#""#); + let _ = writeln!( + svg, + r##"{label} — θ={theta}°"## + ); + // strip outer wrapper of the inner render + let body: String = inner + .lines() + .filter(|l| !l.starts_with(">() + .join("\n"); + let _ = writeln!(svg, "{body}"); + let _ = writeln!(svg, ""); + } + let _ = writeln!(svg, ""); + svg +} + +fn demo_html() -> String { + // Precompute per-frame card boxes for both modes for the scrubber. + let mut data = String::from("["); + for mode in [RotationInFlow::AabbParticipates, RotationInFlow::VisualOnly] { + let opts = ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: mode, + }; + let mut s = scene(None); + data.push('['); + let mut theta = 0.0f32; + while theta <= 360.0 { + ops::set_rotation(&mut s.doc, s.cards[1], theta).unwrap(); + let r = resolve(&s.doc, &opts); + let fb = r.box_of(s.frame); + let mut entry = format!("[[{:.2},{:.2},{:.2},{:.2}]", fb.x, fb.y, fb.w, fb.h); + for (ci, c) in s.cards.iter().enumerate() { + let b = r.box_of(*c); + // box in frame space + rotation (cards are frame children) + let rot = if ci == 1 { theta } else { 0.0 }; + let _ = write!( + entry, + ",[{:.2},{:.2},{:.2},{:.2},{:.2}]", + b.x, b.y, b.w, b.h, rot + ); + } + entry.push(']'); + data.push_str(&entry); + if theta < 360.0 { + data.push(','); + } + theta += STEP; + } + data.push(']'); + if mode == RotationInFlow::AabbParticipates { + data.push(','); + } + } + data.push(']'); + + format!( + r###" + +E1 — rotation-in-flow: anchor vs visual-only + +

E1 — rotate a card inside an auto-flowing list

+

Top: anchor (rotated AABB participates — siblings make room, container breathes).
+Bottom: visual-only control (CSS transform semantics — layout frozen, overlap).

+
+ + + +
+ + + +"### + ) +} + +fn main() { + let out = Path::new(OUT); + fs::create_dir_all(out.join("frames")).unwrap(); + + let aabb = sweep(RotationInFlow::AabbParticipates, None); + let visual = sweep(RotationInFlow::VisualOnly, None); + let fixed = sweep(RotationInFlow::AabbParticipates, Some(400.0)); + + println!("E1 sweep: 0..360 step {STEP}°, hug row of 60×100 cards, gap 10, pad 10"); + println!(" {}", summarize("anchor/hug ", &aabb)); + println!(" {}", summarize("visual/hug ", &visual)); + println!(" {}", summarize("anchor/fixedW", &fixed)); + + // analytic continuity bound of the AABB envelope: + // w'(θ) = w|cosθ|+h|sinθ| ⇒ |dw'/dθ| ≤ √(w²+h²) per radian. + // The envelope (and thus sibling displacement) peaks at θ* = atan(h/w). + let bound = (60.0f32 * 60.0 + 100.0 * 100.0).sqrt() * STEP.to_radians(); + let peak = (100.0f32 / 60.0).atan().to_degrees(); + println!(" analytic continuity bound: {bound:.2}px per {STEP}° step; envelope peak θ* = {peak:.1}°"); + + write_csv(&out.join("metrics.csv"), &aabb, &visual, &fixed); + for theta in [0.0f32, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0] { + fs::write( + out.join("frames").join(format!("theta_{:03}.svg", theta as u32)), + snapshot(theta), + ) + .unwrap(); + } + fs::write(out.join("demo.html"), demo_html()).unwrap(); + println!("wrote metrics.csv, frames/*.svg, demo.html → {OUT}/"); +} diff --git a/model-v2/a/lab/src/bin/e3.rs b/model-v2/a/lab/src/bin/e3.rs new file mode 100644 index 0000000000..906a94b849 --- /dev/null +++ b/model-v2/a/lab/src/bin/e3.rs @@ -0,0 +1,118 @@ +//! E3 — text-IR ground truth + prediction scoring. +//! +//! `cargo run --bin e3 -- truth` → prints the truth lines +//! `cargo run --bin e3 -- score ` → scores a prediction file +//! +//! Line format (both truth and predictions): +//! `p1,a,box,10,20,120,80` — box in parent space (pre-rotation) +//! `p4,tile,aabb,53.0,54.0,133.9,112.0` — world AABB (rotated nodes only) +//! +//! Tolerance: |Δ| ≤ 0.5 px per component. + +use anchor_lab::model::{Document, NodeId, Payload}; +use anchor_lab::resolve::{resolve, Resolved, ResolveOptions, RotationInFlow}; +use anchor_lab::textir; +use std::collections::BTreeMap; +use std::fs; + +const PROBES: &[&str] = &["p1", "p2", "p3", "p4", "p5", "p6"]; +const DIR: &str = "../e3-text-ir/probes"; +const TOL: f32 = 0.5; + +fn truth_lines() -> Vec { + let opts = ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::AabbParticipates, + }; + let mut lines = vec![]; + for probe in PROBES { + let src = fs::read_to_string(format!("{DIR}/{probe}.xml")).unwrap(); + let doc = textir::parse(&src).unwrap(); + let r = resolve(&doc, &opts); + collect(&doc, &r, doc.root, probe, &mut lines); + } + lines +} + +fn collect(doc: &Document, r: &Resolved, id: NodeId, probe: &str, out: &mut Vec) { + let node = doc.get(id); + if let Some(name) = &node.header.name { + if name != "root" { + let b = r.box_of(id); + out.push(format!( + "{probe},{name},box,{:.3},{:.3},{:.3},{:.3}", + b.x, b.y, b.w, b.h + )); + if node.header.rotation != 0.0 && !matches!(node.payload, Payload::Group) { + let a = r.aabb_of(id); + out.push(format!( + "{probe},{name},aabb,{:.3},{:.3},{:.3},{:.3}", + a.x, a.y, a.w, a.h + )); + } + } + } + for c in &node.children { + collect(doc, r, *c, probe, out); + } +} + +fn parse_line(line: &str) -> Option<(String, [f32; 4])> { + let parts: Vec<&str> = line.trim().split(',').map(|p| p.trim()).collect(); + if parts.len() != 7 { + return None; + } + let key = format!("{},{},{}", parts[0], parts[1], parts[2]); + let mut nums = [0.0f32; 4]; + for (i, p) in parts[3..].iter().enumerate() { + nums[i] = p.parse().ok()?; + } + Some((key, nums)) +} + +fn main() { + let args: Vec = std::env::args().collect(); + match args.get(1).map(|s| s.as_str()) { + Some("truth") => { + for l in truth_lines() { + println!("{l}"); + } + } + Some("score") => { + let truth: BTreeMap = + truth_lines().iter().filter_map(|l| parse_line(l)).collect(); + let pred_src = fs::read_to_string(&args[2]).unwrap(); + let preds: BTreeMap = pred_src + .lines() + .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#')) + .filter_map(parse_line) + .collect(); + + let mut ok = 0; + let mut miss = 0; + let mut wrong: Vec = vec![]; + for (key, t) in &truth { + match preds.get(key) { + None => { + miss += 1; + wrong.push(format!("MISSING {key} (truth {t:?})")); + } + Some(p) => { + let hit = t.iter().zip(p.iter()).all(|(a, b)| (a - b).abs() <= TOL); + if hit { + ok += 1; + } else { + wrong.push(format!("WRONG {key}: pred {p:?} vs truth {t:?}")); + } + } + } + } + let total = truth.len(); + println!("score: {ok}/{total} lines correct (tolerance {TOL}px); {miss} missing"); + for w in &wrong { + println!(" {w}"); + } + } + _ => eprintln!("usage: e3 truth | e3 score "), + } +} diff --git a/model-v2/a/lab/src/bin/e4.rs b/model-v2/a/lab/src/bin/e4.rs new file mode 100644 index 0000000000..c4abc43118 --- /dev/null +++ b/model-v2/a/lab/src/bin/e4.rs @@ -0,0 +1,186 @@ +//! E4 — resolver spike: throughput + scaling of the four-phase resolution +//! on realistic scene shapes. Run with `cargo run --release --bin e4`. + +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use std::time::Instant; + +/// (a) flat canvas: N free shapes under the root (typical canvas board). +fn scene_flat(n: usize) -> Document { + let mut b = DocBuilder::new(); + for i in 0..n { + let (mut h, p) = shape_hp(60.0, 40.0); + h.x = AxisBinding::start((i % 100) as f32 * 70.0); + h.y = AxisBinding::start((i / 100) as f32 * 50.0); + if i % 7 == 0 { + h.rotation = (i % 360) as f32; + } + b.add(0, h, p); + } + b.build() +} + +/// (b) flex-heavy: rows of flex cards, each card a flex column of +/// rect + two texts (a list UI). ~7 nodes per card. +fn scene_flex(cards: usize) -> Document { + let mut b = DocBuilder::new(); + let per_row = 20; + let rows = cards.div_ceil(per_row); + for r in 0..rows { + let mut rh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + rh.x = AxisBinding::start(0.0); + rh.y = AxisBinding::start(r as f32 * 130.0); + let row = b.add( + 0, + rh, + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Row, + gap_main: 8.0, + padding: EdgeInsets::all(8.0), + ..Default::default() + }, + clips_content: false, + }, + ); + for c in 0..per_row.min(cards - r * per_row) { + let card = b.add( + row, + Header::new(SizeIntent::Fixed(120.0), SizeIntent::Auto), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Column, + gap_main: 4.0, + padding: EdgeInsets::all(6.0), + cross_align: CrossAlign::Stretch, + ..Default::default() + }, + clips_content: false, + }, + ); + let (hh, hp) = shape_hp(100.0, 60.0); + b.add(card, hh, hp); + b.add( + card, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: format!("Card number {c} title"), + font_size: 14.0, + }, + ); + b.add( + card, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: "Some longer descriptive body text that wraps a few times".into(), + font_size: 11.0, + }, + ); + } + } + b.build() +} + +/// (c) mixed: groups of rotated shapes interleaved with flex frames. +fn scene_mixed(n: usize) -> Document { + let mut b = DocBuilder::new(); + let mut count = 0; + let mut i = 0; + while count < n { + if i % 3 == 0 { + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start((i % 50) as f32 * 90.0); + gh.y = AxisBinding::start((i / 50) as f32 * 90.0); + gh.rotation = (i % 45) as f32; + let g = b.add(0, gh, Payload::Group); + for k in 0..4 { + let (mut h, p) = shape_hp(30.0, 30.0); + h.x = AxisBinding::start(k as f32 * 35.0); + h.rotation = (k * 15) as f32; + b.add(g, h, p); + } + count += 5; + } else { + let mut fh = Header::new(SizeIntent::Fixed(200.0), SizeIntent::Fixed(80.0)); + fh.x = AxisBinding::start((i % 50) as f32 * 90.0); + fh.y = AxisBinding::start((i / 50) as f32 * 90.0); + let f = b.add( + 0, + fh, + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + gap_main: 5.0, + padding: EdgeInsets::all(5.0), + ..Default::default() + }, + clips_content: false, + }, + ); + for k in 0..3 { + let (mut h, p) = shape_hp(40.0, 40.0); + if k == 1 { + h.rotation = 30.0; // rotated-in-flow on the hot path + } + b.add(f, h, p); + } + count += 4; + } + i += 1; + } + b.build() +} + +fn shape_hp(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +fn bench(name: &str, doc: &Document) { + // Pinned to the E1 arm: E4's recorded history (REPORT, SPIKE tables) + // was measured under AabbParticipates; keep the series comparable. + // The default arm (VisualOnly, DEC-0) is strictly cheaper — no + // envelope math in sizing. + let opts = ResolveOptions { + rotation_in_flow: anchor_lab::resolve::RotationInFlow::AabbParticipates, + ..ResolveOptions::default() + }; + let n = doc.len(); + // warmup + median of 9 + let mut times = vec![]; + for _ in 0..11 { + let t = Instant::now(); + let r = resolve(doc, &opts); + let dt = t.elapsed(); + assert!(r.resolved_count() > n / 2); + times.push(dt.as_secs_f64() * 1000.0); + } + times.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let med = times[times.len() / 2]; + println!( + "{name:<28} {n:>6} nodes {med:>8.3} ms {:>8.1} nodes/ms", + n as f64 / med + ); +} + +fn main() { + println!("E4 resolver spike — full-resolve wall time (median of 11, release)"); + for scale in [1_000, 10_000] { + bench(&format!("flat canvas ({scale})"), &scene_flat(scale)); + bench( + &format!("flex cards (~{scale})"), + &scene_flex(scale / 7), + ); + bench(&format!("mixed groups+flex (~{scale})"), &scene_mixed(scale)); + } + // locality proxy: cost of re-resolving one card subtree (what an + // incremental engine pays for a leaf edit under clean parents) + let one_card = scene_flex(1); + bench("single card (locality bound)", &one_card); +} diff --git a/model-v2/a/lab/src/bin/e5scan.rs b/model-v2/a/lab/src/bin/e5scan.rs new file mode 100644 index 0000000000..8fdd2c6217 --- /dev/null +++ b/model-v2/a/lab/src/bin/e5scan.rs @@ -0,0 +1,340 @@ +//! E5 — SVG corpus transform measurement. +//! +//! Decides triage amendment 5's open mechanism: how much real SVG content +//! actually needs the lens quarantine (skew / shear / single-axis mirror) +//! vs landing natively in the anchor header (translate / rotate / scale)? +//! +//! Method: scan every `transform="…"` / `gradientTransform="…"` / +//! `patternTransform="…"` attribute (raw text scan — robust to malformed +//! XML), compose each transform list into a 2×3 matrix, then classify by +//! decomposition M = R(θ)·[sx m; 0 sy]: +//! +//! identity / translate → native (bindings) +//! + rotation, scale ≈ (1,1) → native (header rotation) +//! + uniform scale → native (folds into size) +//! + non-uniform scale (m ≈ 0) → native (folds into w/h, then rotate) +//! det < 0 (single-axis mirror) → flip class (lens or a flip flag) +//! |m| > ε (shear) → lens required +//! +//! Usage: `cargo run --release --bin e5scan -- …` + +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Default, Debug, Clone, Copy)] +struct Counts { + files: usize, + files_no_transform: usize, + files_need_lens: usize, + files_flip_only: usize, + t_total: usize, + t_identity: usize, + t_translate: usize, + t_rotate: usize, + t_scale_uniform: usize, + t_scale_nonuniform: usize, + t_flip: usize, + t_shear: usize, + t_unparsable: usize, + t_paint: usize, + t_paint_shear: usize, + t_paint_unparsable: usize, +} + +#[derive(Clone, Copy)] +struct M { + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, +} + +const ID: M = M { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, +}; + +impl M { + fn mul(&self, o: &M) -> M { + M { + a: self.a * o.a + self.c * o.b, + b: self.b * o.a + self.d * o.b, + c: self.a * o.c + self.c * o.d, + d: self.b * o.c + self.d * o.d, + e: self.a * o.e + self.c * o.f + self.e, + f: self.b * o.e + self.d * o.f + self.f, + } + } +} + +fn parse_transform(s: &str) -> Option { + let mut m = ID; + let mut rest = s.trim(); + while !rest.is_empty() { + let open = rest.find('(')?; + let name = rest[..open].trim().trim_start_matches(',').trim(); + let close = rest[open..].find(')')? + open; + let args: Vec = rest[open + 1..close] + .split(|ch: char| ch == ',' || ch.is_whitespace()) + .filter(|p| !p.is_empty()) + .map(|p| p.parse::()) + .collect::>() + .ok()?; + let t = match (name, args.as_slice()) { + ("translate", [x]) => M { e: *x, ..ID }, + ("translate", [x, y]) => M { e: *x, f: *y, ..ID }, + ("scale", [s]) => M { a: *s, d: *s, ..ID }, + ("scale", [x, y]) => M { a: *x, d: *y, ..ID }, + ("rotate", [deg]) => rot(*deg), + ("rotate", [deg, cx, cy]) => M { e: *cx, f: *cy, ..ID } + .mul(&rot(*deg)) + .mul(&M { e: -*cx, f: -*cy, ..ID }), + ("skewX", [deg]) => M { + c: deg.to_radians().tan(), + ..ID + }, + ("skewY", [deg]) => M { + b: deg.to_radians().tan(), + ..ID + }, + ("matrix", [a, b, c, d, e, f]) => M { + a: *a, + b: *b, + c: *c, + d: *d, + e: *e, + f: *f, + }, + _ => return None, + }; + m = m.mul(&t); + rest = rest[close + 1..].trim(); + } + Some(m) +} + +fn rot(deg: f64) -> M { + let (s, c) = deg.to_radians().sin_cos(); + M { + a: c, + b: s, + c: -s, + d: c, + e: 0.0, + f: 0.0, + } +} + +#[derive(PartialEq, Clone, Copy, Debug)] +enum Class { + Identity, + Translate, + Rotate, + ScaleUniform, + ScaleNonUniform, + Flip, + Shear, +} + +fn classify(m: &M) -> Class { + const EPS: f64 = 1e-4; + let det = m.a * m.d - m.b * m.c; + if det < 0.0 { + return Class::Flip; + } + let sx = (m.a * m.a + m.b * m.b).sqrt(); + if sx < EPS { + return Class::Shear; // degenerate; be conservative + } + // shear term of M = R·[sx m; 0 sy] + let shear = (m.a * m.c + m.b * m.d) / sx; + let sy = det / sx; + if shear.abs() > EPS * sx.max(1.0) { + return Class::Shear; + } + let rotated = m.b.abs() > EPS || m.a < 0.0; + let scaled = (sx - 1.0).abs() > EPS || (sy - 1.0).abs() > EPS; + let translated = m.e.abs() > EPS || m.f.abs() > EPS; + match (rotated, scaled) { + (_, true) if (sx - sy).abs() > EPS * sx.max(sy) => Class::ScaleNonUniform, + (_, true) => Class::ScaleUniform, + (true, false) => Class::Rotate, + (false, false) if translated => Class::Translate, + (false, false) => Class::Identity, + } +} + +fn scan_file(path: &Path, c: &mut Counts) { + let Ok(src) = fs::read_to_string(path) else { + return; + }; + c.files += 1; + let mut found = 0usize; + let mut needs_lens = false; + let mut flip = false; + for key in ["transform=\"", "gradientTransform=\"", "patternTransform=\""] { + let is_paint = key != "transform=\""; + let mut at = 0usize; + while let Some(pos) = src[at..].find(key) { + let start = at + pos + key.len(); + // don't match `gradientTransform="` twice via the `transform="` key + if key == "transform=\"" && start >= key.len() + 8 { + let prefix = &src[..at + pos]; + if prefix.ends_with("gradient") || prefix.ends_with("pattern") { + at = start; + continue; + } + } + let Some(end) = src[start..].find('"') else { + break; + }; + let value = &src[start..start + end]; + found += 1; + c.t_total += 1; + if is_paint { + c.t_paint += 1; + match parse_transform(value) { + None => c.t_paint_unparsable += 1, + Some(m) => { + if classify(&m) == Class::Shear { + c.t_paint_shear += 1; + } + } + } + at = start + end; + continue; + } + match parse_transform(value) { + None => { + c.t_unparsable += 1; + needs_lens = true; // conservative + } + Some(m) => match classify(&m) { + Class::Identity => c.t_identity += 1, + Class::Translate => c.t_translate += 1, + Class::Rotate => c.t_rotate += 1, + Class::ScaleUniform => c.t_scale_uniform += 1, + Class::ScaleNonUniform => c.t_scale_nonuniform += 1, + Class::Flip => { + c.t_flip += 1; + flip = true; + } + Class::Shear => { + c.t_shear += 1; + needs_lens = true; + } + }, + } + at = start + end; + } + } + if found == 0 { + c.files_no_transform += 1; + } + if needs_lens { + c.files_need_lens += 1; + } else if flip { + c.files_flip_only += 1; + } +} + +fn walk(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + if name == "node_modules" || name == "target" || name == ".git" { + continue; + } + walk(&p, out); + } else if p.extension().is_some_and(|x| x == "svg") { + out.push(p); + } + } +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + println!("| corpus | files | no-tf | translate | rotate | scale= | scale≠ | flip | shear | unparsable | files needing lens |"); + println!("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"); + let mut grand = Counts::default(); + let mut grand_transforms = 0usize; + for dir in &args { + let mut files = vec![]; + walk(Path::new(dir), &mut files); + let mut c = Counts::default(); + for f in &files { + scan_file(f, &mut c); + } + println!( + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | **{} ({:.2}%)** |", + dir, + c.files, + c.files_no_transform, + c.t_translate + c.t_identity, + c.t_rotate, + c.t_scale_uniform, + c.t_scale_nonuniform, + c.t_flip, + c.t_shear, + c.t_unparsable, + c.files_need_lens, + 100.0 * c.files_need_lens as f64 / c.files.max(1) as f64 + ); + grand.files += c.files; + grand.files_no_transform += c.files_no_transform; + grand.files_need_lens += c.files_need_lens; + grand.files_flip_only += c.files_flip_only; + grand.t_translate += c.t_translate + c.t_identity; + grand.t_rotate += c.t_rotate; + grand.t_scale_uniform += c.t_scale_uniform; + grand.t_scale_nonuniform += c.t_scale_nonuniform; + grand.t_flip += c.t_flip; + grand.t_shear += c.t_shear; + grand.t_unparsable += c.t_unparsable; + grand.t_paint += c.t_paint; + grand.t_paint_shear += c.t_paint_shear; + grand.t_paint_unparsable += c.t_paint_unparsable; + grand_transforms += c.t_total; + } + println!(); + println!( + "TOTAL: {} files, {} transforms | translate {} | rotate {} | scale= {} | scale≠ {} | flip {} | shear {} | unparsable {}", + grand.files, + grand_transforms, + grand.t_translate, + grand.t_rotate, + grand.t_scale_uniform, + grand.t_scale_nonuniform, + grand.t_flip, + grand.t_shear, + grand.t_unparsable, + ); + println!( + "files importing 100% natively (no lens, no flip): {} / {} = {:.2}%", + grand.files - grand.files_need_lens - grand.files_flip_only, + grand.files, + 100.0 * (grand.files - grand.files_need_lens - grand.files_flip_only) as f64 + / grand.files.max(1) as f64 + ); + println!( + "paint transforms (gradient/pattern — stored in the paint, NOT node geometry): {} total, {} with shear, {} unparsable", + grand.t_paint, grand.t_paint_shear, grand.t_paint_unparsable + ); + println!( + "files needing ONLY flip handling: {} ({:.2}%) | files needing lens (GEOMETRY shear/unparsable): {} ({:.2}%)", + grand.files_flip_only, + 100.0 * grand.files_flip_only as f64 / grand.files.max(1) as f64, + grand.files_need_lens, + 100.0 * grand.files_need_lens as f64 / grand.files.max(1) as f64 + ); +} diff --git a/model-v2/a/lab/src/bin/edge.rs b/model-v2/a/lab/src/bin/edge.rs new file mode 100644 index 0000000000..6cd2b1b3af --- /dev/null +++ b/model-v2/a/lab/src/bin/edge.rs @@ -0,0 +1,414 @@ +//! Edge-case sweep generator — rotation × layout intersections, resolved by +//! the real lab resolver and dumped as JSON frames for the edge-cases demo. +//! +//! Output: ../edge-cases/frames.json +//! Per scene, per frame (θ), per node: world affine (a,b,c,d,e,f) + box dims. + +use anchor_lab::model::*; +use anchor_lab::ops::{self, Axis, ResizeDrag}; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; +use std::fmt::Write as _; +use std::fs; + +const STEP: f32 = 3.0; +const MAX_T: f32 = 180.0; + +struct Scene { + id: &'static str, + name: &'static str, + build: fn(f32) -> (Document, Vec<(NodeId, &'static str)>), + /// Raw JSON injected into the scene object (sweep relabel, outline set, + /// direction markers) — empty for the plain rotation sweeps. + extra: &'static str, +} + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 600.0), + rotation_in_flow: RotationInFlow::AabbParticipates, + } +} + +fn frame_flex( + w: SizeIntent, + h: SizeIntent, + dir: Direction, + gap: f32, + pad: f32, +) -> (Header, Payload) { + ( + Header::new(w, h), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: dir, + gap_main: gap, + padding: EdgeInsets::all(pad), + cross_align: CrossAlign::Center, + ..Default::default() + }, + clips_content: false, + }, + ) +} + +fn card(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +fn at(mut h: Header, x: f32, y: f32) -> Header { + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(y); + h +} + +// --- scenes ----------------------------------------------------------- + +/// S1 — grow × rotation (E-A4): fixed row, middle card grow=1 rotating. +fn s_grow(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 1 { + ch.grow = 1.0; + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "grow+rot", "c"][i])); + } + (b.build(), out) +} + +/// S2 — stretch × rotation: middle card align=stretch while rotating. +fn s_stretch(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(190.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 1 { + ch.self_align = SelfAlign::Stretch; + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "stretch+rot", "c"][i])); + } + (b.build(), out) +} + +/// S3 — rotated text in flow: measure unrotated, AABB participates. +fn s_text(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(170.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + let (h1, p1) = card(60.0, 100.0); + out.push((b.add(f, h1, p1), "a")); + let mut th = Header::new(SizeIntent::Auto, SizeIntent::Auto); + th.rotation = theta; + let t = b.add( + f, + th, + Payload::Text { + content: "hello".into(), + font_size: 18.0, + }, + ); + out.push((t, "text+rot")); + let (h2, p2) = card(60.0, 100.0); + out.push((b.add(f, h2, p2), "c")); + (b.build(), out) +} + +/// S4 — a whole flex frame rotated: layout runs in the frame's local +/// space; the assembly is rigid. +fn s_rotated_frame(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(320.0), + SizeIntent::Fixed(120.0), + Direction::Row, + 10.0, + 10.0, + ); + let mut fh = at(fh, 90.0, 60.0); + fh.rotation = theta; + let f = b.add(0, fh, fp); + let mut out = vec![(f, "flex frame (rotated)")]; + for i in 0..3 { + let (mut ch, cp) = card(80.0, 90.0); + if i == 1 { + ch.grow = 1.0; + } + out.push((b.add(f, ch, cp), ["a", "grow", "c"][i])); + } + (b.build(), out) +} + +/// S5 — group (derived box) rotating in flow. +fn s_group(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(190.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + let (h1, p1) = card(60.0, 100.0); + out.push((b.add(f, h1, p1), "a")); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.rotation = theta; + let g = b.add(f, gh, Payload::Group); + let (c1h, c1p) = card(50.0, 50.0); + let ga = b.add(g, c1h, c1p); + let (mut c2h, c2p) = card(50.0, 50.0); + c2h.x = AxisBinding::start(30.0); + c2h.y = AxisBinding::start(40.0); + let gb = b.add(g, c2h, c2p); + out.push((ga, "group.a")); + out.push((gb, "group.b")); + let (h2, p2) = card(60.0, 100.0); + out.push((b.add(f, h2, p2), "c")); + (b.build(), out) +} + +/// S6 — two siblings rotating together (compound envelopes). +fn s_two(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(190.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i != 1 { + ch.rotation = if i == 0 { theta } else { -theta }; + } + out.push((b.add(f, ch, cp), ["rot", "still", "rot(-)"][i])); + } + (b.build(), out) +} + +/// S7 — space-between with a rotating middle child. +fn s_between(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + Direction::Row, + 0.0, + 10.0, + ); + let mut fh = at(fh, 20.0, 20.0); + if let Payload::Frame { layout, .. } = &mut b.node_mut(0).payload { + let _ = layout; + } + let f = b.add(0, fh, fp); + if let Payload::Frame { layout, .. } = &mut b.node_mut(f).payload { + layout.main_align = MainAlign::SpaceBetween; + } + let mut out = vec![(f, "container")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 1 { + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "rot", "c"][i])); + } + (b.build(), out) +} + +/// S8 — wrapping row: the envelope growth reflows a card to line 2. +fn s_wrap(theta: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(380.0), + SizeIntent::Auto, + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + if let Payload::Frame { layout, .. } = &mut b.node_mut(f).payload { + layout.wrap = true; + layout.gap_cross = 10.0; + layout.cross_align = CrossAlign::Start; + } + let mut out = vec![(f, "container")]; + for i in 0..4 { + let (mut ch, cp) = card(70.0, 120.0); + if i == 1 { + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "rot", "c", "d"][i])); + } + (b.build(), out) +} + +/// S9 — cross-zero resize (open decision D-9): three policies side by side. +/// The sweep variable is the DRAG HANDLE offset from the fixed edge (px), +/// not an angle: v = 80 − t·(160/180) ∈ [+80 … −80]; anchor at local 95. +/// Arm A = the wall (extent clamps at 0); arm B = slide (rect tracks the +/// hand, content never mirrors); arm C = the real `resize_drag` op +/// (re-target: |extent| + flip toggle + re-pin — Figma-parity class). +fn s_flipzero(t: f32) -> (Document, Vec<(NodeId, &'static str)>) { + let v = 60.0 - t * (120.0 / 180.0); + let anchor = 75.0; + let target = anchor + v; + + let mut b = DocBuilder::new(); + let mut arm = |b: &mut DocBuilder, x: f32| -> NodeId { + let mut h = Header::new(SizeIntent::Fixed(145.0), SizeIntent::Fixed(140.0)); + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(30.0); + b.add( + 0, + h, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ) + }; + let rect_at = |x: f32, w: f32| -> (Header, Payload) { + let (mut h, p) = card(w, 70.0); + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(35.0); + (h, p) + }; + + // A — the wall: the pre-decision model rejects past zero. + let fa = arm(&mut b, 20.0); + let (h1, p1) = rect_at(anchor, v.max(0.0)); + let ra = b.add(fa, h1, p1); + // B — slide: |extent| + re-pin, no mirror. + let fb = arm(&mut b, 175.0); + let (h2, p2) = rect_at(target.min(anchor), v.abs()); + let rb = b.add(fb, h2, p2); + // C — flip: built at rest, then driven by the REAL gesture op. + let fc = arm(&mut b, 330.0); + let (h3, p3) = rect_at(anchor, 60.0); + let rc = b.add(fc, h3, p3); + + let mut doc = b.build(); + let r = resolve(&doc, &opts()); + let drag = ResizeDrag::begin(&doc, &r, rc, Axis::X, AnchorEdge::Start).unwrap(); + ops::resize_drag(&mut doc, &r, rc, &drag, target).unwrap(); + + ( + doc, + vec![ + (fa, "wall"), + (ra, "R"), + (fb, "slide"), + (rb, "R"), + (fc, "flip"), + (rc, "R"), + ], + ) +} + +// --- driver ----------------------------------------------------------- + +fn main() { + let scenes: Vec = vec![ + Scene { id: "grow", name: "grow × rotation (E-A4)", build: s_grow, extra: "" }, + Scene { id: "stretch", name: "stretch × rotation", build: s_stretch, extra: "" }, + Scene { id: "text", name: "rotated text in flow", build: s_text, extra: "" }, + Scene { id: "rotframe", name: "rotated flex frame (rigid)", build: s_rotated_frame, extra: "" }, + Scene { id: "group", name: "group rotating in flow", build: s_group, extra: "" }, + Scene { id: "two", name: "two rotating siblings", build: s_two, extra: "" }, + Scene { id: "between", name: "space-between × rotation", build: s_between, extra: "" }, + Scene { id: "wrap", name: "wrap reflow mid-rotation", build: s_wrap, extra: "" }, + Scene { + id: "flipzero", + name: "resize across zero (flip)", + build: s_flipzero, + extra: r#","outlines":[0,2,4],"marker":true,"sweep":{"label":"drag","from":60,"to":-60,"unit":"px"}"#, + }, + ]; + + let mut json = String::from("{\"step\":3,\"scenes\":["); + for (si, sc) in scenes.iter().enumerate() { + if si > 0 { + json.push(','); + } + // node labels from θ=0 build + let (_, nodes0) = (sc.build)(0.0); + let labels: Vec = nodes0 + .iter() + .map(|(_, l)| format!("\"{}\"", l)) + .collect(); + let _ = write!( + json, + "{{\"id\":\"{}\",\"name\":\"{}\",\"labels\":[{}]{},\"frames\":[", + sc.id, + sc.name, + labels.join(","), + sc.extra + ); + let mut theta = 0.0f32; + let mut first = true; + while theta <= MAX_T { + let (doc, nodes) = (sc.build)(theta); + let r = resolve(&doc, &opts()); + if !first { + json.push(','); + } + first = false; + json.push('['); + for (ni, (id, _)) in nodes.iter().enumerate() { + if ni > 0 { + json.push(','); + } + let w = r.world_of(*id); + let bx = r.box_of(*id); + let _ = write!( + json, + "[{:.2},{:.3},{:.3},{:.3},{:.3},{:.2},{:.2},{:.2},{:.2}]", + w.a, w.b, w.c, w.d, w.e, w.f, bx.w, bx.h, 0.0 + ); + } + json.push(']'); + theta += STEP; + } + let _ = write!(json, "]}}"); + } + json.push_str("]}"); + fs::create_dir_all("../edge-cases").unwrap(); + fs::write("../edge-cases/frames.json", &json).unwrap(); + println!("wrote ../edge-cases/frames.json ({} bytes)", json.len()); +} diff --git a/model-v2/a/lab/src/bin/fork.rs b/model-v2/a/lab/src/bin/fork.rs new file mode 100644 index 0000000000..a168903aea --- /dev/null +++ b/model-v2/a/lab/src/bin/fork.rs @@ -0,0 +1,333 @@ +//! DEC-0 fork generator — the SAME scenes resolved under BOTH rotation +//! framings (layout-visible `AabbParticipates` vs CSS `VisualOnly`), +//! dumped side-by-side for the fork demo. Nothing here is a mockup: every +//! frame of both arms is real resolver output, and the metrics (sibling +//! overlap px², container breathing, ink escape px) are measured on the +//! resolved world AABBs — E1's method, applied to the whole scene battery. +//! +//! Output: ../dec0-fork/fork.json + +use anchor_lab::math::RectF; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved, RotationInFlow}; +use std::fmt::Write as _; +use std::fs; + +const STEP: f32 = 3.0; +const MAX_T: f32 = 180.0; + +type Built = (Document, Vec<(NodeId, &'static str)>); + +struct Scene { + id: &'static str, + name: &'static str, + build: fn(f32) -> Built, +} + +fn opts(arm: RotationInFlow) -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 600.0), + rotation_in_flow: arm, + } +} + +fn frame_flex(w: SizeIntent, h: SizeIntent, gap: f32, pad: f32) -> (Header, Payload) { + ( + Header::new(w, h), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + gap_main: gap, + padding: EdgeInsets::all(pad), + cross_align: CrossAlign::Center, + ..Default::default() + }, + clips_content: false, + }, + ) +} + +fn card(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +fn at(mut h: Header, x: f32, y: f32) -> Header { + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(y); + h +} + +// --- scenes (container first; measured siblings after) ----------------- + +/// grow: fixed row, middle card grow=1 rotating — DEC-1's home turf. +fn s_grow(theta: f32) -> Built { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex(SizeIntent::Fixed(460.0), SizeIntent::Fixed(170.0), 10.0, 10.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 1 { + ch.grow = 1.0; + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "grow+rot", "c"][i])); + } + (b.build(), out) +} + +/// stretch: cross-axis fill rotating. +fn s_stretch(theta: f32) -> Built { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex(SizeIntent::Fixed(460.0), SizeIntent::Fixed(190.0), 10.0, 10.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 1 { + ch.self_align = SelfAlign::Stretch; + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "stretch+rot", "c"][i])); + } + (b.build(), out) +} + +/// text: a label turning vertical inside a toolbar row (the writing-mode case). +fn s_text(theta: f32) -> Built { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex(SizeIntent::Auto, SizeIntent::Fixed(170.0), 10.0, 10.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + let (h1, p1) = card(60.0, 100.0); + out.push((b.add(f, h1, p1), "a")); + let mut th = Header::new(SizeIntent::Auto, SizeIntent::Auto); + th.rotation = theta; + let t = b.add( + f, + th, + Payload::Text { + content: "revenue".into(), + font_size: 18.0, + }, + ); + out.push((t, "text+rot")); + let (h2, p2) = card(60.0, 100.0); + out.push((b.add(f, h2, p2), "c")); + (b.build(), out) +} + +/// hug: auto-width row, LAST card rotating — the containment fork. +fn s_hug(theta: f32) -> Built { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex(SizeIntent::Auto, SizeIntent::Fixed(190.0), 10.0, 10.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container (hug)")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 2 { + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "b", "rot"][i])); + } + (b.build(), out) +} + +/// wrap: envelope growth pushes card d to line 2 — or never does. +fn s_wrap(theta: f32) -> Built { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex(SizeIntent::Fixed(380.0), SizeIntent::Auto, 10.0, 10.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + if let Payload::Frame { layout, .. } = &mut b.node_mut(f).payload { + layout.wrap = true; + layout.gap_cross = 10.0; + layout.cross_align = CrossAlign::Start; + } + let mut out = vec![(f, "container")]; + for i in 0..4 { + let (mut ch, cp) = card(70.0, 120.0); + if i == 1 { + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "rot", "c", "d"][i])); + } + (b.build(), out) +} + +/// between: pinned ends, envelope eats the free space — or overlaps. +fn s_between(theta: f32) -> Built { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex(SizeIntent::Fixed(460.0), SizeIntent::Fixed(170.0), 0.0, 10.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + if let Payload::Frame { layout, .. } = &mut b.node_mut(f).payload { + layout.main_align = MainAlign::SpaceBetween; + } + let mut out = vec![(f, "container")]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 1 { + ch.rotation = theta; + } + out.push((b.add(f, ch, cp), ["a", "rot", "c"][i])); + } + (b.build(), out) +} + +/// lens: the two-lane proof — header-rotate vs lens-rotate in ONE row. +/// In the anchor arm the header child makes room while the lens child +/// keeps CSS semantics; in the CSS arm both behave the same (overlap). +fn s_lens(theta: f32) -> Built { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex(SizeIntent::Fixed(520.0), SizeIntent::Fixed(190.0), 10.0, 10.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut out = vec![(f, "container")]; + let (h1, p1) = card(60.0, 100.0); + out.push((b.add(f, h1, p1), "a")); + let (mut h2, p2) = card(60.0, 100.0); + h2.rotation = theta; + out.push((b.add(f, h2, p2), "header-rot")); + let (h3, p3) = card(60.0, 100.0); + out.push((b.add(f, h3, p3), "b")); + let lens_h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + let l = b.add( + f, + lens_h, + Payload::Lens { + ops: vec![LensOp::Rotate { deg: theta }], + }, + ); + let (ch, cp) = card(60.0, 100.0); + // Emit the lens's CHILD, not the lens: the child's world composes + // through the ops chain, so the paint-lane rotation is visible. + let lc = b.add(l, ch, cp); + out.push((lc, "lens-rot")); + let (h4, p4) = card(60.0, 100.0); + out.push((b.add(f, h4, p4), "c")); + (b.build(), out) +} + +// --- metrics ------------------------------------------------------------ + +/// Max pairwise world-AABB intersection among the measured siblings +/// (E1's overlap metric, generalized). +fn overlap_of(r: &Resolved, nodes: &[(NodeId, &str)]) -> f32 { + let ids: Vec = nodes.iter().skip(1).map(|(id, _)| *id).collect(); + let mut worst = 0.0f32; + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + let a = r.aabb_of(ids[i]); + let b = r.aabb_of(ids[j]); + worst = worst.max(a.intersection_area(&b)); + } + } + worst +} + +/// Ink escape: how far (px) the siblings' ink runs past the container's +/// own painted rect. Containment truth = 0. +fn escape_of(r: &Resolved, nodes: &[(NodeId, &str)]) -> f32 { + let (cid, _) = nodes[0]; + let cb = r.box_of(cid); + let cw = r.world_of(cid); + let crect = RectF { + x: 0.0, + y: 0.0, + w: cb.w, + h: cb.h, + } + .transformed_aabb(&cw); + let mut esc = 0.0f32; + for (id, _) in nodes.iter().skip(1) { + let a = r.aabb_of(*id); + esc = esc + .max(crect.x - a.x) + .max(a.x + a.w - (crect.x + crect.w)) + .max(crect.y - a.y) + .max(a.y + a.h - (crect.y + crect.h)); + } + esc.max(0.0) +} + +// --- driver -------------------------------------------------------------- + +fn emit_arm(json: &mut String, arm: RotationInFlow, sc: &Scene) { + let mut first = true; + let mut frames = String::new(); + let mut metrics = String::new(); + let mut theta = 0.0f32; + while theta <= MAX_T { + let (doc, nodes) = (sc.build)(theta); + let r = resolve(&doc, &opts(arm)); + if !first { + frames.push(','); + metrics.push(','); + } + first = false; + frames.push('['); + for (ni, (id, _)) in nodes.iter().enumerate() { + if ni > 0 { + frames.push(','); + } + let w = r.world_of(*id); + let bx = r.box_of(*id); + let _ = write!( + frames, + "[{:.2},{:.3},{:.3},{:.3},{:.2},{:.2},{:.2},{:.2}]", + w.a, w.b, w.c, w.d, w.e, w.f, bx.w, bx.h + ); + } + frames.push(']'); + let cb = r.box_of(nodes[0].0); + let _ = write!( + metrics, + "[{:.1},{:.1},{:.1},{:.1}]", + overlap_of(&r, &nodes), + cb.w, + cb.h, + escape_of(&r, &nodes) + ); + theta += STEP; + } + let _ = write!(json, "{{\"frames\":[{frames}],\"m\":[{metrics}]}}"); +} + +fn main() { + let scenes: Vec = vec![ + Scene { id: "grow", name: "grow x rotation", build: s_grow }, + Scene { id: "stretch", name: "stretch x rotation", build: s_stretch }, + Scene { id: "text", name: "rotated text in a row", build: s_text }, + Scene { id: "hug", name: "hug containment", build: s_hug }, + Scene { id: "wrap", name: "wrap reflow", build: s_wrap }, + Scene { id: "between", name: "space-between", build: s_between }, + Scene { id: "lens", name: "two lanes in one row", build: s_lens }, + ]; + + let mut json = String::from("{\"step\":3,\"scenes\":["); + for (si, sc) in scenes.iter().enumerate() { + if si > 0 { + json.push(','); + } + let (_, nodes0) = (sc.build)(0.0); + let labels: Vec = nodes0.iter().map(|(_, l)| format!("\"{l}\"")).collect(); + let _ = write!( + json, + "{{\"id\":\"{}\",\"name\":\"{}\",\"labels\":[{}],\"anchor\":", + sc.id, + sc.name, + labels.join(",") + ); + emit_arm(&mut json, RotationInFlow::AabbParticipates, sc); + json.push_str(",\"css\":"); + emit_arm(&mut json, RotationInFlow::VisualOnly, sc); + json.push('}'); + } + json.push_str("]}"); + fs::create_dir_all("../dec0-fork").unwrap(); + fs::write("../dec0-fork/fork.json", &json).unwrap(); + println!("wrote ../dec0-fork/fork.json ({} bytes)", json.len()); +} diff --git a/model-v2/a/lab/src/lib.rs b/model-v2/a/lab/src/lib.rs new file mode 100644 index 0000000000..5138de42d5 --- /dev/null +++ b/model-v2/a/lab/src/lib.rs @@ -0,0 +1,18 @@ +//! anchor-lab — the model-v2 `anchor` proving lab. +//! +//! Implements the model of `model-v2/models/a.md` (lab subset) to run the +//! experiment ledger of `model-v2/a/README.md`: +//! - E1 rotation-in-flow (both semantics behind [`resolve::RotationInFlow`]) +//! - E3 agent text IR ([`textir`]) +//! - E4 resolver spike ([`resolve`]) +//! +//! Standalone by design; promoted into `crates/` only at phase 4. + +pub mod math; +pub mod measure; +pub mod model; +pub mod ops; +pub mod pick; +pub mod resolve; +pub mod svgout; +pub mod textir; diff --git a/model-v2/a/lab/src/math.rs b/model-v2/a/lab/src/math.rs new file mode 100644 index 0000000000..42d2cb6e18 --- /dev/null +++ b/model-v2/a/lab/src/math.rs @@ -0,0 +1,281 @@ +//! Minimal 2D affine math for the anchor lab. +//! +//! Row-major 2x3: [[a c e], [b d f]] applied as +//! `x' = a*x + c*y + e ; y' = b*x + d*y + f` (SVG matrix order a b c d e f). + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Affine { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub e: f32, + pub f: f32, +} + +impl Affine { + pub const IDENTITY: Affine = Affine { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, + }; + + pub fn translate(tx: f32, ty: f32) -> Self { + Affine { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: tx, + f: ty, + } + } + + pub fn rotate_deg(deg: f32) -> Self { + // Exact matrices at quadrant angles (R-E1: 90° multiples bit-clean). + let r = deg.rem_euclid(360.0); + let (sin, cos) = if r == 0.0 { + (0.0, 1.0) + } else if r == 90.0 { + (1.0, 0.0) + } else if r == 180.0 { + (0.0, -1.0) + } else if r == 270.0 { + (-1.0, 0.0) + } else { + deg.to_radians().sin_cos() + }; + Affine { + a: cos, + b: sin, + c: -sin, + d: cos, + e: 0.0, + f: 0.0, + } + } + + pub fn scale(sx: f32, sy: f32) -> Self { + Affine { + a: sx, + b: 0.0, + c: 0.0, + d: sy, + e: 0.0, + f: 0.0, + } + } + + /// Axis mirror as a matrix (E-A2 native flip). `true` mirrors that axis. + pub fn flip(fx: bool, fy: bool) -> Self { + Affine::scale(if fx { -1.0 } else { 1.0 }, if fy { -1.0 } else { 1.0 }) + } + + pub fn skew_deg(x_deg: f32, y_deg: f32) -> Self { + Affine { + a: 1.0, + b: y_deg.to_radians().tan(), + c: x_deg.to_radians().tan(), + d: 1.0, + e: 0.0, + f: 0.0, + } + } + + /// Inverse affine; None when singular (degenerate resolved matrices + /// are representable — pick and hit-chrome must not panic on them). + pub fn invert(&self) -> Option { + let det = self.a * self.d - self.b * self.c; + if det == 0.0 || !det.is_finite() { + return None; + } + let inv = 1.0 / det; + Some(Affine { + a: self.d * inv, + b: -self.b * inv, + c: -self.c * inv, + d: self.a * inv, + e: (self.c * self.f - self.d * self.e) * inv, + f: (self.b * self.e - self.a * self.f) * inv, + }) + } + + /// self ∘ other (apply `other` first, then `self`). + pub fn then(&self, other: &Affine) -> Affine { + Affine { + a: self.a * other.a + self.c * other.b, + b: self.b * other.a + self.d * other.b, + c: self.a * other.c + self.c * other.d, + d: self.b * other.c + self.d * other.d, + e: self.a * other.e + self.c * other.f + self.e, + f: self.b * other.e + self.d * other.f + self.f, + } + } + + pub fn apply(&self, p: (f32, f32)) -> (f32, f32) { + ( + self.a * p.0 + self.c * p.1 + self.e, + self.b * p.0 + self.d * p.1 + self.f, + ) + } + + /// The model's one transform constructor for boxed kinds: + /// T(x0,y0) · T(c) · R(θ) · T(−c), c = box center (models/a.md §6 Phase T). + pub fn from_box_center(x0: f32, y0: f32, w: f32, h: f32, deg: f32) -> Affine { + Affine::from_box_center_flip(x0, y0, w, h, deg, false, false) + } + + /// Boxed-kind transform with native flip (E-A2 + B1 pivot rule): + /// T(x0,y0) · T(c) · R(θ) · F · T(−c) — flip composes INNERMOST (local + /// space, then rotation), both about the box center. Declared order: + /// mirror first, then turn — a flipped card rotates the way its mirrored + /// content faces, which is also what a baked negative-scale matrix does. + pub fn from_box_center_flip( + x0: f32, + y0: f32, + w: f32, + h: f32, + deg: f32, + fx: bool, + fy: bool, + ) -> Affine { + let (cx, cy) = (w / 2.0, h / 2.0); + Affine::translate(x0 + cx, y0 + cy) + .then(&Affine::rotate_deg(deg)) + .then(&Affine::flip(fx, fy)) + .then(&Affine::translate(-cx, -cy)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RectF { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl RectF { + pub const EMPTY: RectF = RectF { + x: 0.0, + y: 0.0, + w: 0.0, + h: 0.0, + }; + + pub fn corners(&self) -> [(f32, f32); 4] { + [ + (self.x, self.y), + (self.x + self.w, self.y), + (self.x + self.w, self.y + self.h), + (self.x, self.y + self.h), + ] + } + + /// AABB of this rect's corners transformed by `t` (R-2 oriented envelope). + pub fn transformed_aabb(&self, t: &Affine) -> RectF { + let pts = self.corners().map(|p| t.apply(p)); + aabb_of(&pts) + } + + pub fn union(&self, o: &RectF) -> RectF { + let x0 = self.x.min(o.x); + let y0 = self.y.min(o.y); + let x1 = (self.x + self.w).max(o.x + o.w); + let y1 = (self.y + self.h).max(o.y + o.h); + RectF { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + } + } + + pub fn intersection_area(&self, o: &RectF) -> f32 { + let x0 = self.x.max(o.x); + let y0 = self.y.max(o.y); + let x1 = (self.x + self.w).min(o.x + o.w); + let y1 = (self.y + self.h).min(o.y + o.h); + ((x1 - x0).max(0.0)) * ((y1 - y0).max(0.0)) + } + + pub fn center(&self) -> (f32, f32) { + (self.x + self.w / 2.0, self.y + self.h / 2.0) + } +} + +pub fn aabb_of(pts: &[(f32, f32)]) -> RectF { + let mut x0 = f32::INFINITY; + let mut y0 = f32::INFINITY; + let mut x1 = f32::NEG_INFINITY; + let mut y1 = f32::NEG_INFINITY; + for (x, y) in pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + RectF { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + } +} + +/// Oriented AABB contribution of a rotated w×h box (models/a.md §5): +/// `w' = |w·cosθ| + |h·sinθ|`, `h' = |w·sinθ| + |h·cosθ|`. +/// Computed from resolved size only — never position (single-pass safe). +pub fn rotated_aabb_size(w: f32, h: f32, deg: f32) -> (f32, f32) { + let r = Affine::rotate_deg(deg); + // |cos|/|sin| extracted from the same matrix used for rendering, so the + // layout contribution and the painted envelope can never disagree. + let (cos, sin) = (r.a.abs(), r.b.abs()); + (w * cos + h * sin, w * sin + h * cos) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quadrant_rotations_are_exact() { + for (deg, exp) in [ + (0.0, (1.0, 0.0)), + (90.0, (0.0, 1.0)), + (180.0, (-1.0, 0.0)), + (270.0, (0.0, -1.0)), + (360.0, (1.0, 0.0)), + (-90.0, (0.0, -1.0)), + ] { + let r = Affine::rotate_deg(deg); + assert_eq!((r.a, r.b), exp, "deg={deg}"); + } + } + + #[test] + fn from_box_center_preserves_center() { + let t = Affine::from_box_center(10.0, 20.0, 120.0, 80.0, 33.0); + let c = t.apply((60.0, 40.0)); + assert!((c.0 - 70.0).abs() < 1e-4 && (c.1 - 60.0).abs() < 1e-4); + } + + #[test] + fn rotated_aabb_matches_corner_transform() { + let (w, h, deg) = (120.0, 80.0, 37.0); + let t = Affine::from_box_center(0.0, 0.0, w, h, deg); + let aabb = RectF { + x: 0.0, + y: 0.0, + w, + h, + } + .transformed_aabb(&t); + let (we, he) = rotated_aabb_size(w, h, deg); + assert!((aabb.w - we).abs() < 1e-3); + assert!((aabb.h - he).abs() < 1e-3); + } +} diff --git a/model-v2/a/lab/src/measure.rs b/model-v2/a/lab/src/measure.rs new file mode 100644 index 0000000000..b333ff79de --- /dev/null +++ b/model-v2/a/lab/src/measure.rs @@ -0,0 +1,76 @@ +//! Deterministic lab text metric — a stand-in for real shaping. +//! +//! The *contract* under test is the measurement seam (Phase M; re-measure at +//! layout-imposed extents), not typography. The metric is intentionally +//! trivial so humans and LLMs can compute it by hand (E3 probe): +//! +//! - every character advances `0.6 × font_size` +//! - line height is `1.2 × font_size` +//! - greedy word wrap on ASCII spaces; a word never breaks internally +//! (a word wider than the constraint overflows on its own line) +//! - trailing spaces on a wrapped line are dropped + +pub const CHAR_W: f32 = 0.6; +pub const LINE_H: f32 = 1.2; + +pub fn char_width(font_size: f32) -> f32 { + CHAR_W * font_size +} + +/// Returns (width, height) of `content` at `font_size`, wrapped at +/// `max_width` when given (Fixed width ⇒ wrap constraint; Auto ⇒ single line). +pub fn measure_text(content: &str, font_size: f32, max_width: Option) -> (f32, f32) { + let cw = char_width(font_size); + let lh = LINE_H * font_size; + if content.is_empty() { + return (0.0, lh); + } + match max_width { + None => (content.chars().count() as f32 * cw, lh), + Some(maxw) => { + let max_chars = if cw > 0.0 { + (maxw / cw).floor().max(0.0) as usize + } else { + usize::MAX + }; + let mut lines: Vec = vec![]; // char count per line + let mut current = 0usize; + for word in content.split(' ') { + let wlen = word.chars().count(); + if current == 0 { + current = wlen; + } else if current + 1 + wlen <= max_chars { + current += 1 + wlen; + } else { + lines.push(current); + current = wlen; + } + } + lines.push(current); + let widest = lines.iter().copied().max().unwrap_or(0); + (widest as f32 * cw, lines.len() as f32 * lh) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_line_auto() { + // "hello" @16 → 5 × 9.6 = 48 wide, 19.2 tall + let (w, h) = measure_text("hello", 16.0, None); + assert_eq!(w, 48.0); + assert!((h - 19.2).abs() < 1e-4); + } + + #[test] + fn wraps_greedy() { + // "aa bb cc" @10 (cw=6): max_width 30 → 5 chars/line → "aa bb" is 5 chars… + // per rule: "aa"(2) + 1 + "bb"(2) = 5 ≤ 5 → one line; "cc" wraps. + let (w, h) = measure_text("aa bb cc", 10.0, Some(30.0)); + assert_eq!(w, 30.0); // "aa bb" = 5 chars × 6 + assert_eq!(h, 24.0); // 2 lines × 12 + } +} diff --git a/model-v2/a/lab/src/model.rs b/model-v2/a/lab/src/model.rs new file mode 100644 index 0000000000..ac5c6db3c5 --- /dev/null +++ b/model-v2/a/lab/src/model.rs @@ -0,0 +1,493 @@ +//! The `anchor` document model — lab subset of `model-v2/models/a.md`. +//! +//! Kinds implemented: frame, shape (rect/ellipse/line), text, group, lens. +//! Omitted for the lab (noted in the report): tray, image, embed, vector, +//! bool — none of them exercise a geometry mechanism the five above don't. +//! +//! Lab simplifications, all declared: +//! - node ids are `u32`; children are an ordered `Vec` on the parent +//! (fractional-index ordering is not under test here); +//! - `SurfaceStyle` is reduced to an optional fill color used only by the +//! SVG snapshot renderer. + +use std::collections::BTreeMap; + +pub type NodeId = u32; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AnchorEdge { + Start, + Center, + End, +} + +/// Position as a relation, not a coordinate (a.md §2.1). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AxisBinding { + Pin { anchor: AnchorEdge, offset: f32 }, + Span { start: f32, end: f32 }, +} + +impl Default for AxisBinding { + fn default() -> Self { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset: 0.0, + } + } +} + +impl AxisBinding { + pub fn start(offset: f32) -> Self { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset, + } + } + pub fn end(offset: f32) -> Self { + AxisBinding::Pin { + anchor: AnchorEdge::End, + offset, + } + } + pub fn center(offset: f32) -> Self { + AxisBinding::Pin { + anchor: AnchorEdge::Center, + offset, + } + } +} + +/// Two values, not three (a.md §2.2). No Fill — growth via grow/self_align/Span. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SizeIntent { + Fixed(f32), + Auto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum Flow { + #[default] + InFlow, + Absolute, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum SelfAlign { + #[default] + Auto, + Start, + Center, + End, + Stretch, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Header { + pub name: Option, + pub active: bool, + pub x: AxisBinding, + pub y: AxisBinding, + pub width: SizeIntent, + pub height: SizeIntent, + pub min_width: Option, + pub max_width: Option, + pub min_height: Option, + pub max_height: Option, + pub aspect_ratio: Option<(f32, f32)>, + /// Degrees, clockwise, y-down. Pivot per a.md §5. + pub rotation: f32, + /// Native mirrors (E-A2). Pivot per kind exactly as rotation (B1): + /// box center for boxed/measured kinds, own origin for derived kinds. + /// Composition: innermost — local mirror first, then rotation. + pub flip_x: bool, + pub flip_y: bool, + pub flow: Flow, + pub grow: f32, + pub self_align: SelfAlign, + pub opacity: f32, +} + +impl Header { + pub fn new(width: SizeIntent, height: SizeIntent) -> Self { + Header { + name: None, + active: true, + x: AxisBinding::default(), + y: AxisBinding::default(), + width, + height, + min_width: None, + max_width: None, + min_height: None, + max_height: None, + aspect_ratio: None, + rotation: 0.0, + flip_x: false, + flip_y: false, + flow: Flow::default(), + grow: 0.0, + self_align: SelfAlign::default(), + opacity: 1.0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum LayoutMode { + #[default] + None, + Flex, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum Direction { + #[default] + Row, + Column, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum MainAlign { + #[default] + Start, + Center, + End, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum CrossAlign { + #[default] + Start, + Center, + End, + Stretch, +} + +/// EdgeInsets: top, right, bottom, left. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct EdgeInsets { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +impl EdgeInsets { + pub fn all(v: f32) -> Self { + EdgeInsets { + top: v, + right: v, + bottom: v, + left: v, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct LayoutBehavior { + pub mode: LayoutMode, + pub direction: Direction, + pub wrap: bool, + pub main_align: MainAlign, + pub cross_align: CrossAlign, + pub padding: EdgeInsets, + pub gap_main: f32, + pub gap_cross: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ShapeDesc { + Rect, + Ellipse, + /// The box's horizontal midline; height intent must be Fixed(0) (a.md §3.2). + Line, +} + +/// Ordered, applied in sequence, post-resolution (a.md §3.3). 2D subset. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LensOp { + Translate { x: f32, y: f32 }, + Rotate { deg: f32 }, + Scale { x: f32, y: f32 }, + Skew { x_deg: f32, y_deg: f32 }, + Matrix { m: [f32; 6] }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Payload { + Frame { + layout: LayoutBehavior, + clips_content: bool, + }, + Shape { + desc: ShapeDesc, + }, + Text { + content: String, + font_size: f32, + }, + Group, + Lens { + ops: Vec, + }, +} + +impl Payload { + /// Derived-box kinds never store size (a.md §3 box source column). + pub fn box_is_derived(&self) -> bool { + matches!(self, Payload::Group | Payload::Lens { .. }) + } + pub fn kind_name(&self) -> &'static str { + match self { + Payload::Frame { .. } => "frame", + Payload::Shape { .. } => "shape", + Payload::Text { .. } => "text", + Payload::Group => "group", + Payload::Lens { .. } => "lens", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + pub id: NodeId, + pub header: Header, + pub payload: Payload, + pub children: Vec, + pub fill: Option, // lab-only paint for SVG snapshots +} + +/// The document store is a **node arena**: `NodeId` IS the slot index, +/// deleted slots are tombstones, and parent links live in a parallel +/// column (the game-engine hot/cold shape — cold intent stays AoS per +/// node because it is edited field-wise and read whole; the hot resolved +/// tier is SOA in `resolve::Resolved`). This kills the O(n) `parent_of` +/// scan the map-based lab store had — the same defect class as the +/// legacy engine's pointer-chasing lookups. +#[derive(Debug, Clone)] +pub struct Document { + slots: Vec>, + /// Parent link column, index-aligned with `slots`. Maintained by the + /// structural APIs below — mutate children through them, not by hand. + parents: Vec>, + /// Scene root: a frame whose bindings span the viewport (a.md §3 — the + /// InitialContainer regularized). + pub root: NodeId, +} + +/// Semantic equality: same root, same alive nodes, same parenting. +/// Tombstones and arena capacity are storage artifacts, not document +/// content — MM-7 (add then delete restores) holds by this definition. +impl PartialEq for Document { + fn eq(&self, other: &Self) -> bool { + if self.root != other.root || self.len() != other.len() { + return false; + } + self.slots.iter().enumerate().all(|(i, s)| match s { + None => other + .slots + .get(i) + .map(|o| o.is_none()) + .unwrap_or(true), + Some(n) => { + other.get_opt(i as NodeId) == Some(n) + && self.parents[i] == other.parents[i] + } + }) + } +} + +impl Document { + pub fn get(&self, id: NodeId) -> &Node { + self.slots[id as usize].as_ref().expect("dead node id") + } + pub fn get_mut(&mut self, id: NodeId) -> &mut Node { + self.slots[id as usize].as_mut().expect("dead node id") + } + pub fn get_opt(&self, id: NodeId) -> Option<&Node> { + self.slots.get(id as usize).and_then(|s| s.as_ref()) + } + /// O(1) — the parent column, not a scan. + pub fn parent_of(&self, id: NodeId) -> Option { + self.parents.get(id as usize).copied().flatten() + } + /// Alive node count. + pub fn len(&self) -> usize { + self.slots.iter().filter(|s| s.is_some()).count() + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Arena extent — the sizing bound for index-aligned SOA columns. + pub fn capacity(&self) -> usize { + self.slots.len() + } + + fn ensure_slot(&mut self, id: NodeId) { + let need = id as usize + 1; + if self.slots.len() < need { + self.slots.resize_with(need, || None); + self.parents.resize(need, None); + } + } + + /// Structural insert: registers the node at `node.id` and attaches it + /// as the last child of `parent`. + pub fn add_child(&mut self, parent: NodeId, node: Node) -> NodeId { + let id = node.id; + self.ensure_slot(id); + debug_assert!(self.slots[id as usize].is_none(), "slot occupied"); + self.slots[id as usize] = Some(node); + self.parents[id as usize] = Some(parent); + self.get_mut(parent).children.push(id); + id + } + + /// Structural remove: detaches `id` from its parent and tombstones the + /// whole subtree. Returns the number of nodes removed. + pub fn remove_subtree(&mut self, id: NodeId) -> usize { + if let Some(p) = self.parent_of(id) { + self.get_mut(p).children.retain(|c| *c != id); + } + self.tombstone_rec(id) + } + fn tombstone_rec(&mut self, id: NodeId) -> usize { + let children = match self.get_opt(id) { + Some(n) => n.children.clone(), + None => return 0, + }; + let mut n = 1; + for c in children { + n += self.tombstone_rec(c); + } + self.slots[id as usize] = None; + self.parents[id as usize] = None; + n + } + + /// Tombstone a single slot without touching its (already re-homed) + /// children — the ungroup bake's final step. + pub fn remove_slot(&mut self, id: NodeId) { + self.slots[id as usize] = None; + self.parents[id as usize] = None; + } + + /// Replace `remove` children of `parent` at `idx` with `insert`, + /// re-homing the inserted nodes' parent links. + pub fn splice_children( + &mut self, + parent: NodeId, + idx: usize, + remove: usize, + insert: Vec, + ) { + for &c in &insert { + self.parents[c as usize] = Some(parent); + } + self.get_mut(parent) + .children + .splice(idx..idx + remove, insert); + } + + /// Build the arena from an id-keyed map (ids become slot indices); + /// parent links derive from the children lists once, at construction. + pub fn from_map(nodes: BTreeMap, root: NodeId) -> Document { + let cap = nodes.keys().max().map(|m| *m as usize + 1).unwrap_or(0); + let mut slots: Vec> = Vec::with_capacity(cap); + slots.resize_with(cap, || None); + let mut parents = vec![None; cap]; + for (id, node) in nodes { + for &c in &node.children { + if (c as usize) < cap { + parents[c as usize] = Some(id); + } + } + slots[id as usize] = Some(node); + } + Document { + slots, + parents, + root, + } + } +} + +/// Builder for terse test/document construction. +pub struct DocBuilder { + nodes: BTreeMap, + next: NodeId, + root: NodeId, +} + +impl DocBuilder { + /// Root frame spanning the given viewport (Span{0,0} both axes). + pub fn new() -> Self { + let mut nodes = BTreeMap::new(); + let mut header = Header::new(SizeIntent::Auto, SizeIntent::Auto); + header.x = AxisBinding::Span { + start: 0.0, + end: 0.0, + }; + header.y = AxisBinding::Span { + start: 0.0, + end: 0.0, + }; + nodes.insert( + 0, + Node { + id: 0, + header, + payload: Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + children: vec![], + fill: None, + }, + ); + DocBuilder { + nodes, + next: 1, + root: 0, + } + } + + pub fn add(&mut self, parent: NodeId, header: Header, payload: Payload) -> NodeId { + let id = self.next; + self.next += 1; + self.nodes.insert( + id, + Node { + id, + header, + payload, + children: vec![], + fill: None, + }, + ); + self.nodes.get_mut(&parent).unwrap().children.push(id); + id + } + + pub fn header_mut(&mut self, id: NodeId) -> &mut Header { + &mut self.nodes.get_mut(&id).unwrap().header + } + + pub fn node_mut(&mut self, id: NodeId) -> &mut Node { + self.nodes.get_mut(&id).unwrap() + } + + pub fn build(self) -> Document { + Document::from_map(self.nodes, self.root) + } +} + +impl Default for DocBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/model-v2/a/lab/src/ops.rs b/model-v2/a/lab/src/ops.rs new file mode 100644 index 0000000000..e568d1e096 --- /dev/null +++ b/model-v2/a/lab/src/ops.rs @@ -0,0 +1,410 @@ +//! The op layer — editor gestures as document writes (model-v2/editor.md). +//! +//! Doctrine under test: +//! - typed errors, never silent (`OpError`); a rejected op leaves the +//! document untouched (M-6); +//! - NaN/Inf rejected at the write boundary (N-2, R-E3); +//! - write-counts are a design signal: rotate(boxed)=1, move=2, +//! group-rotate(center-feel)=3, corner-resize=4; +//! - reads come from the resolved tier; writes re-target intent (a.md §6). + +use crate::math::Affine; +use crate::model::*; +use crate::resolve::Resolved; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpError { + /// Setting size on a spanned axis: Span owns the extent (§2.1). + AxisOwnedBySpan, + /// Setting x/y on an in-flow child under flex (§6 writes). + OwnedByLayout, + /// Setting size on a derived-box kind (group/bool/lens). + BoxDerived, + /// NaN/Inf/write-boundary rejection (N-2). + InvalidNumber, + /// A raw size write below zero. Extents are non-negative bedrock; the + /// gesture that legitimately crosses zero is `resize_drag`, which + /// RE-TARGETS (|extent| + flip toggle) instead of storing a sign. + NegativeExtent, + /// Op targets a kind it does not apply to. + WrongKind, +} + +pub type OpResult = Result; // Ok(number of field writes) + +fn guard_finite(v: f32) -> Result<(), OpError> { + if v.is_finite() { + Ok(()) + } else { + Err(OpError::InvalidNumber) + } +} + +fn in_flow_under_flex(doc: &Document, id: NodeId) -> bool { + match doc.parent_of(id) { + Some(pid) => { + let parent = doc.get(pid); + matches!( + &parent.payload, + Payload::Frame { layout, .. } if layout.mode == LayoutMode::Flex + ) && doc.get(id).header.flow == Flow::InFlow + } + None => false, + } +} + +/// Set resolved x to `value` by re-targeting the stored intent (a.md §6): +/// the binding kind is preserved; its offset is rewritten so the resolved +/// x becomes `value`. Delta form — `Δ = value − resolved.x` — works +/// uniformly for boxed kinds and derived kinds (whose bindings place the +/// origin, not the box). +pub fn set_x(doc: &mut Document, resolved: &Resolved, id: NodeId, value: f32) -> OpResult { + guard_finite(value)?; + if in_flow_under_flex(doc, id) { + return Err(OpError::OwnedByLayout); + } + let delta = value - resolved.box_of(id).x; + let node = doc.get_mut(id); + match &mut node.header.x { + AxisBinding::Pin { anchor, offset } => { + *offset += match anchor { + AnchorEdge::Start | AnchorEdge::Center => delta, + AnchorEdge::End => -delta, + }; + Ok(1) + } + AxisBinding::Span { .. } => Err(OpError::AxisOwnedBySpan), + } +} + +pub fn set_y(doc: &mut Document, resolved: &Resolved, id: NodeId, value: f32) -> OpResult { + guard_finite(value)?; + if in_flow_under_flex(doc, id) { + return Err(OpError::OwnedByLayout); + } + let delta = value - resolved.box_of(id).y; + let node = doc.get_mut(id); + match &mut node.header.y { + AxisBinding::Pin { anchor, offset } => { + *offset += match anchor { + AnchorEdge::Start | AnchorEdge::Center => delta, + AnchorEdge::End => -delta, + }; + Ok(1) + } + AxisBinding::Span { .. } => Err(OpError::AxisOwnedBySpan), + } +} + +/// OP-MOVE: drag by (dx,dy) → exactly two offset writes. +pub fn move_by( + doc: &mut Document, + resolved: &Resolved, + id: NodeId, + dx: f32, + dy: f32, +) -> OpResult { + let (x, y, _, _) = resolved.xywh(id); + let a = set_x(doc, resolved, id, x + dx)?; + let b = set_y(doc, resolved, id, y + dy)?; + Ok(a + b) +} + +pub fn set_width(doc: &mut Document, id: NodeId, value: f32) -> OpResult { + guard_finite(value)?; + if value < 0.0 { + return Err(OpError::NegativeExtent); + } + let node = doc.get(id); + if node.payload.box_is_derived() { + return Err(OpError::BoxDerived); + } + if matches!(node.header.x, AxisBinding::Span { .. }) { + return Err(OpError::AxisOwnedBySpan); + } + doc.get_mut(id).header.width = SizeIntent::Fixed(value); + Ok(1) +} + +pub fn set_height(doc: &mut Document, id: NodeId, value: f32) -> OpResult { + guard_finite(value)?; + if value < 0.0 { + return Err(OpError::NegativeExtent); + } + let node = doc.get(id); + if node.payload.box_is_derived() { + return Err(OpError::BoxDerived); + } + if matches!(node.header.y, AxisBinding::Span { .. }) { + return Err(OpError::AxisOwnedBySpan); + } + doc.get_mut(id).header.height = SizeIntent::Fixed(value); + Ok(1) +} + +/// OP-ROT-1: rotating a boxed/measured node — one field, no compensation +/// (center pivot keeps the box center put). −0.0 canonicalizes to +0.0 at +/// the boundary (R-E3: the document never stores a negative zero). +pub fn set_rotation(doc: &mut Document, id: NodeId, deg: f32) -> OpResult { + guard_finite(deg)?; + if doc.get(id).payload.box_is_derived() { + return Err(OpError::WrongKind); // use rotate_derived_center_feel + } + doc.get_mut(id).header.rotation = if deg == 0.0 { 0.0 } else { deg }; + Ok(1) +} + +/// OP-ROT-3: center-feel rotation of a derived-box node (group/lens). +/// Pivot is the node's own origin (§5), so the gesture compensates x/y to +/// keep the visual center fixed — the Figma trick over three legible +/// scalars instead of a matrix. Exactly 3 writes. +/// +/// Requires Start pins on both axes (the gesture's own precondition; other +/// anchors would re-target the same way `set_x` does). +pub fn rotate_derived_center_feel( + doc: &mut Document, + resolved: &Resolved, + id: NodeId, + new_deg: f32, +) -> OpResult { + guard_finite(new_deg)?; + if !doc.get(id).payload.box_is_derived() { + return Err(OpError::WrongKind); + } + let b = resolved.box_of(id); // union box (origin + union offset) + let local = resolved.local_of(id); // T(origin)·R(θ) → origin = (e, f) + let origin = (local.e, local.f); + let old = doc.get(id).header.rotation; + // union center in origin space + let d = (b.x - origin.0 + b.w / 2.0, b.y - origin.1 + b.h / 2.0); + let c_old = Affine::rotate_deg(old).apply(d); + let center = (origin.0 + c_old.0, origin.1 + c_old.1); + let c_new = Affine::rotate_deg(new_deg).apply(d); + let p2 = (center.0 - c_new.0, center.1 - c_new.1); + + let node = doc.get_mut(id); + let start_pinned = matches!( + node.header.x, + AxisBinding::Pin { + anchor: AnchorEdge::Start, + .. + } + ) && matches!( + node.header.y, + AxisBinding::Pin { + anchor: AnchorEdge::Start, + .. + } + ); + if !start_pinned { + return Err(OpError::WrongKind); + } + node.header.rotation = new_deg; + node.header.x = AxisBinding::start(p2.0); + node.header.y = AxisBinding::start(p2.1); + Ok(3) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + X, + Y, +} + +/// OP-SIZE-drag: gesture state for an edge-handle resize, captured once at +/// mousedown. Extents are non-negative bedrock (N-2), so dragging PAST the +/// fixed edge cannot store a sign — instead the op re-targets: extent +/// becomes |target − anchor| and the axis flip toggles relative to the +/// gesture baseline (Figma parity: fill/flip survive as intent; the typed +/// `set_width(−50)` stays a wall — `NegativeExtent`). +#[derive(Debug, Clone, Copy)] +pub struct ResizeDrag { + pub axis: Axis, + /// Parent-space coordinate of the FIXED edge, captured at gesture start. + pub anchor: f32, + /// Flip state on this axis at gesture start. + pub base_flip: bool, + /// True when the box initially extends to the positive side of anchor. + pub base_side_positive: bool, +} + +impl ResizeDrag { + /// `fixed_edge` names the edge that stays put (Start = min edge fixed, + /// the user drags the max handle; End = the reverse). Center handles + /// are a different gesture (symmetric resize) — not this op. + pub fn begin( + doc: &Document, + resolved: &Resolved, + id: NodeId, + axis: Axis, + fixed_edge: AnchorEdge, + ) -> Result { + if doc.get(id).payload.box_is_derived() { + return Err(OpError::BoxDerived); + } + let b = resolved.box_of(id); + let (min, extent, flip) = match axis { + Axis::X => (b.x, b.w, doc.get(id).header.flip_x), + Axis::Y => (b.y, b.h, doc.get(id).header.flip_y), + }; + let (anchor, side_positive) = match fixed_edge { + AnchorEdge::Start => (min, true), + AnchorEdge::End => (min + extent, false), + AnchorEdge::Center => return Err(OpError::WrongKind), + }; + Ok(ResizeDrag { + axis, + anchor, + base_flip: flip, + base_side_positive: side_positive, + }) + } +} + +/// Apply one mousemove of an edge-handle drag. `resolved` must be FRESH +/// (resolve of the current document) — position re-targets in delta form. +/// +/// Writes per call: extent (1) + position (1, free context only — under +/// flex, layout owns position and the write set shrinks to extent+flip) +/// + flip (1, only when the crossing state changed). Crossing back across +/// the anchor toggles the flip off again: a drag out and back is the +/// identity on the document. +pub fn resize_drag( + doc: &mut Document, + resolved: &Resolved, + id: NodeId, + drag: &ResizeDrag, + target: f32, +) -> OpResult { + guard_finite(target)?; + if doc.get(id).payload.box_is_derived() { + return Err(OpError::BoxDerived); + } + let crossed = if drag.base_side_positive { + target < drag.anchor + } else { + target > drag.anchor + }; + let new_extent = (target - drag.anchor).abs(); + let new_min = target.min(drag.anchor); + let mut writes = 0usize; + + writes += match drag.axis { + Axis::X => set_width(doc, id, new_extent)?, + Axis::Y => set_height(doc, id, new_extent)?, + }; + if !in_flow_under_flex(doc, id) { + writes += match drag.axis { + Axis::X => set_x(doc, resolved, id, new_min)?, + Axis::Y => set_y(doc, resolved, id, new_min)?, + }; + } + let want_flip = drag.base_flip ^ crossed; + let node = doc.get_mut(id); + let slot = match drag.axis { + Axis::X => &mut node.header.flip_x, + Axis::Y => &mut node.header.flip_y, + }; + if *slot != want_flip { + *slot = want_flip; + writes += 1; + } + Ok(writes) +} + +/// OP-SIZE-corner: top-left corner drag on a free node — 4 writes +/// (x, y, w, h), the documented maximum for a resize gesture. +pub fn resize_top_left( + doc: &mut Document, + resolved: &Resolved, + id: NodeId, + new_x: f32, + new_y: f32, + new_w: f32, + new_h: f32, +) -> OpResult { + let a = set_x(doc, resolved, id, new_x)?; + let b = set_y(doc, resolved, id, new_y)?; + let c = set_width(doc, id, new_w)?; + let d = set_height(doc, id, new_h)?; + Ok(a + b + c + d) +} + +/// OP-TREE-delete: remove a whole subtree. The scene root is not +/// deletable (the InitialContainer always exists). Returns the number of +/// nodes removed (a structural write count, one per dissolved node). +pub fn delete(doc: &mut Document, id: NodeId) -> OpResult { + if id == doc.root || doc.get_opt(id).is_none() { + return Err(OpError::WrongKind); + } + Ok(doc.remove_subtree(id)) +} + +/// OP-TREE-ungroup: one of the three sanctioned state→intent bake moments. +/// Children keep their world transforms (D-4); the group node dissolves. +pub fn ungroup(doc: &mut Document, resolved: &Resolved, group_id: NodeId) -> OpResult { + if !matches!(doc.get(group_id).payload, Payload::Group) { + return Err(OpError::WrongKind); + } + let parent_id = doc.parent_of(group_id).ok_or(OpError::WrongKind)?; + let group_local = resolved.local_of(group_id); + let group_theta = doc.get(group_id).header.rotation; + let (gfx, gfy) = (doc.get(group_id).header.flip_x, doc.get(group_id).header.flip_y); + // Mirror conjugation: F·R(θ) = R(σθ)·F with σ = −1 under a single-axis + // mirror (det −1) and σ = +1 under none/both (F_xy = R(180), det +1). + // So R(θg)·F_g·R(θc)·F_c = R(θg + σ·θc)·F_{g⊕c} — the bake stays three + // legible scalars plus flip XORs; no matrix leaks into the document. + let sigma = if gfx ^ gfy { -1.0 } else { 1.0 }; + let children = doc.get(group_id).children.clone(); + + let mut writes = 0usize; + for &cid in &children { + let child_theta = doc.get(cid).header.rotation; + let new_theta = group_theta + sigma * child_theta; + if doc.get(cid).payload.box_is_derived() { + // Derived child (nested group/lens): its pins place the ORIGIN + // and its pivot is the origin, so the bake maps the origin + // through the dissolving group (census fix: the boxed-center + // formula was wrong for derived children). + let o = resolved.local_of(cid); + let o_parent = group_local.apply((o.e, o.f)); + let node = doc.get_mut(cid); + node.header.rotation = new_theta; + node.header.x = AxisBinding::start(o_parent.0); + node.header.y = AxisBinding::start(o_parent.1); + } else { + // Boxed child: rigid motion — the box center maps through the + // group local transform; center pivot keeps the rest exact. + let cb = resolved.box_of(cid); + let c_local = (cb.x + cb.w / 2.0, cb.y + cb.h / 2.0); + let c_parent = group_local.apply(c_local); + let node = doc.get_mut(cid); + node.header.rotation = new_theta; + node.header.x = AxisBinding::start(c_parent.0 - cb.w / 2.0); + node.header.y = AxisBinding::start(c_parent.1 - cb.h / 2.0); + } + writes += 3; + if gfx { + let n = doc.get_mut(cid); + n.header.flip_x = !n.header.flip_x; + writes += 1; + } + if gfy { + let n = doc.get_mut(cid); + n.header.flip_y = !n.header.flip_y; + writes += 1; + } + } + // Tree surgery: splice children into the parent at the group's slot + // (re-homing their parent links), then tombstone the group. + let idx = doc + .get(parent_id) + .children + .iter() + .position(|c| *c == group_id) + .unwrap(); + doc.splice_children(parent_id, idx, 1, children); + doc.remove_slot(group_id); + Ok(writes) +} + diff --git a/model-v2/a/lab/src/pick.rs b/model-v2/a/lab/src/pick.rs new file mode 100644 index 0000000000..4998919fa0 --- /dev/null +++ b/model-v2/a/lab/src/pick.rs @@ -0,0 +1,59 @@ +//! Hit-testing over the resolved tier — a MODEL concern, not host chrome: +//! oriented boxes (inverse world transform, not AABB), lens children hit +//! post-ops, and derived kinds are transparent-select (clicking group +//! content selects the OUTERMOST group — GROUP.md / Figma parity). +//! +//! `pick` returns the topmost hit in paint order (later siblings paint on +//! top). Hitting only the scene root means "background" — hosts treat it +//! as deselect. + +use crate::model::*; +use crate::resolve::Resolved; + +/// Hairline kinds (a zero-height line, a zero-extent box mid-gesture) get +/// a symmetric hit slop in LOCAL px so they stay grabbable. +const HAIRLINE_SLOP: f32 = 3.0; + +pub fn pick(doc: &Document, resolved: &Resolved, x: f32, y: f32) -> Option { + hit_subtree(doc, resolved, doc.root, (x, y)).map(|hit| promote(doc, hit)) +} + +fn hit_subtree(doc: &Document, r: &Resolved, id: NodeId, p: (f32, f32)) -> Option { + if r.world_opt(id).is_none() { + return None; // hidden subtree + } + let node = doc.get(id); + // Children first, topmost-first (paint order = document order). + for &c in node.children.iter().rev() { + if let Some(hit) = hit_subtree(doc, r, c, p) { + return Some(hit); + } + } + // Own ink: derived kinds have none (their bounds come from children). + if node.payload.box_is_derived() { + return None; + } + let inv = r.world_of(id).invert()?; + let (lx, ly) = inv.apply(p); + let b = r.box_of(id); + let sx = if b.w < 2.0 * HAIRLINE_SLOP { HAIRLINE_SLOP } else { 0.0 }; + let sy = if b.h < 2.0 * HAIRLINE_SLOP { HAIRLINE_SLOP } else { 0.0 }; + if lx >= -sx && lx <= b.w + sx && ly >= -sy && ly <= b.h + sy { + Some(id) + } else { + None + } +} + +/// Transparent-select: the OUTERMOST derived ancestor claims the hit. +fn promote(doc: &Document, hit: NodeId) -> NodeId { + let mut chosen = hit; + let mut cur = hit; + while let Some(parent) = doc.parent_of(cur) { + if doc.get(parent).payload.box_is_derived() { + chosen = parent; + } + cur = parent; + } + chosen +} diff --git a/model-v2/a/lab/src/resolve.rs b/model-v2/a/lab/src/resolve.rs new file mode 100644 index 0000000000..21dce35438 --- /dev/null +++ b/model-v2/a/lab/src/resolve.rs @@ -0,0 +1,1089 @@ +//! The four-phase resolver (models/a.md §6): +//! +//! `resolve(document, viewport) → Resolved` — a pure function. +//! +//! - Phase M — measure (text natural size; frame hug; derived unions) +//! - Phase L — layout (taffy flex over AABB contributions; bindings elsewhere) +//! - Phase T — transforms (`from_box_center` for boxed; origin pivot for derived) +//! - Phase B — bounds (oriented corners → world AABBs) +//! +//! The E1 experiment flag [`RotationInFlow`] selects how a rotated in-flow +//! child participates in flex: by its oriented AABB (the spec's §5 tilt) or +//! not at all (CSS post-layout semantics). Everything else is identical +//! between the two modes. + +use crate::math::{rotated_aabb_size, Affine, RectF}; +use crate::measure::measure_text; +use crate::model::*; +use std::collections::HashMap; +use taffy::prelude::{auto, length, AvailableSpace, TaffyTree}; +use taffy::style::{ + AlignItems, AlignSelf, Display, FlexDirection, FlexWrap, JustifyContent, Style, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RotationInFlow { + /// Rotated child contributes |w·cosθ|+|h·sinθ|; box center is placed + /// at the slot center; siblings make room. The E1 arm — kept + /// implemented and tested as the documented alternative (R-3 anchor + /// column), NOT the default since DEC-0's second lock. + AabbParticipates, + /// THE DEFAULT (DEC-0, owner framing): CSS `transform` semantics — + /// sizing never reads rotation/flips (flex contributions, hug, + /// derived unions are CSS-pure per dec0-visual-only.md); rotation is + /// paint-only; the read tier (world AABBs, pick) stays oriented. + #[default] + VisualOnly, +} + +#[derive(Debug, Clone, Copy)] +pub struct ResolveOptions { + pub viewport: (f32, f32), + pub rotation_in_flow: RotationInFlow, +} + +impl Default for ResolveOptions { + fn default() -> Self { + ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::default(), + } + } +} + +/// §8 applicability-matrix outcomes, reported — never silent (H12 at the +/// resolver: the document stays valid; the report says what a field did). +#[derive(Debug, Clone, PartialEq)] +pub enum Report { + IgnoredByRule { + node: NodeId, + field: &'static str, + rule: &'static str, + }, + ErrorByRule { + node: NodeId, + field: &'static str, + rule: &'static str, + }, + Clamped { + node: NodeId, + field: &'static str, + from: f32, + to: f32, + }, +} + +/// The resolved tier is **SOA**: index-aligned columns over the node +/// arena (`NodeId` = index), written once per resolve, read every frame +/// by paint/HUD/pick — the hot half of the hot/cold split (cold intent +/// stays AoS in the arena). `None` = not resolved (hidden subtree). +#[derive(Debug, Default, Clone)] +pub struct Resolved { + /// Unrotated box in parent space (derived kinds: the placed union box). + pub(crate) box_in_parent: Vec>, + /// parent space ← node space. + pub(crate) local: Vec>, + pub(crate) world: Vec>, + pub(crate) world_aabb: Vec>, + pub reports: Vec, +} + +impl Resolved { + fn with_capacity(cap: usize) -> Resolved { + Resolved { + box_in_parent: vec![None; cap], + local: vec![None; cap], + world: vec![None; cap], + world_aabb: vec![None; cap], + reports: Vec::new(), + } + } + pub fn box_of(&self, id: NodeId) -> RectF { + self.box_in_parent[id as usize].expect("unresolved node") + } + pub fn local_of(&self, id: NodeId) -> Affine { + self.local[id as usize].expect("unresolved node") + } + pub fn world_of(&self, id: NodeId) -> Affine { + self.world[id as usize].expect("unresolved node") + } + pub fn world_opt(&self, id: NodeId) -> Option { + self.world.get(id as usize).copied().flatten() + } + /// Number of nodes that resolved (hidden subtrees are absent). + pub fn resolved_count(&self) -> usize { + self.world.iter().filter(|w| w.is_some()).count() + } + pub fn aabb_of(&self, id: NodeId) -> RectF { + self.world_aabb[id as usize].expect("unresolved node") + } + /// The always-readable resolved scalars (a.md §6 reads). + pub fn xywh(&self, id: NodeId) -> (f32, f32, f32, f32) { + let b = self.box_of(id); + (b.x, b.y, b.w, b.h) + } +} + +struct Ctx<'a> { + doc: &'a Document, + opts: ResolveOptions, + out: Resolved, + /// Derived-box kinds: union rect of children in node-local space, + /// cached once children are committed (children commit exactly once). + union_cache: HashMap, + /// Lens nodes: the folded post-resolution ops transform. + ops_cache: HashMap, +} + +pub fn resolve(doc: &Document, opts: &ResolveOptions) -> Resolved { + let mut cx = Ctx { + doc, + opts: *opts, + out: Resolved::with_capacity(doc.capacity()), + union_cache: HashMap::new(), + ops_cache: HashMap::new(), + }; + let vp = RectF { + x: 0.0, + y: 0.0, + w: opts.viewport.0, + h: opts.viewport.1, + }; + // The root is an ordinary frame resolved against the viewport pseudo-box + // (the InitialContainer special regime, regularized — X-SELF-5 break). + let root_box = place_by_bindings(doc.root, (vp.w, vp.h), &mut cx); + commit(doc.root, root_box, &mut cx); + + // Phase T (world composition) + Phase B (bounds), after the tree of + // boxes and locals is complete. + compose_world(doc.root, Affine::IDENTITY, &mut cx); + compute_world_aabb(doc.root, &mut cx); + cx.out +} + +// --------------------------------------------------------------------------- +// Phase M/L helpers +// --------------------------------------------------------------------------- + +/// Resolved extent of a node (its unrotated box size), independent of +/// position. `parent_extent` is needed only by Span bindings. +fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f32, f32) { + let node = cx.doc.get(id); + + if node.payload.box_is_derived() { + // width/height intents are ignored-by-rule on derived kinds (§8). + if !matches!(node.header.width, SizeIntent::Auto) + || !matches!(node.header.height, SizeIntent::Auto) + { + cx.out.reports.push(Report::IgnoredByRule { + node: id, + field: "width/height", + rule: "box derived from children", + }); + } + let u = union_of_derived(id, cx); + return (u.w, u.h); + } + + // Span owns the axis extent (§2.1); SizeIntent on a spanned axis is + // ignored-by-rule. + let span_w = span_extent(id, node.header.x, parent_extent.map(|e| e.0), "x", cx); + let span_h = span_extent(id, node.header.y, parent_extent.map(|e| e.1), "y", cx); + + let mut w = span_w; + let mut h = span_h; + + if w.is_none() { + w = intent_extent_x(id, cx); + } + if h.is_none() { + // A span-resolved width IS a wrap constraint (census finding: the + // canonical Span{0,0} fill must re-wrap like Fixed/stretched widths). + h = intent_extent_y(id, span_w, cx); + } + + // aspect_ratio resolves an under-specified axis only (G-5). + if let Some((ar_w, ar_h)) = node.header.aspect_ratio { + if ar_w > 0.0 && ar_h > 0.0 { + match (w, h) { + (Some(wv), None) => h = Some(wv * ar_h / ar_w), + (None, Some(hv)) => w = Some(hv * ar_w / ar_h), + _ => {} + } + } + } + + let mut wv = w.unwrap_or_else(|| { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field: "width", + rule: "Auto size on a kind with no natural size", + }); + 0.0 + }); + let mut hv = h.unwrap_or_else(|| { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field: "height", + rule: "Auto size on a kind with no natural size", + }); + 0.0 + }); + + // min/max clamp last; min beats max (G-4 declared rule). + wv = clamp_axis(id, "width", wv, node.header.min_width, node.header.max_width, cx); + hv = clamp_axis( + id, + "height", + hv, + node.header.min_height, + node.header.max_height, + cx, + ); + (wv, hv) +} + +fn clamp_axis( + id: NodeId, + field: &'static str, + v: f32, + min: Option, + max: Option, + cx: &mut Ctx, +) -> f32 { + let mut out = v; + if let Some(mx) = max { + out = out.min(mx); + } + if let Some(mn) = min { + out = out.max(mn); + } + if out != v { + cx.out.reports.push(Report::Clamped { + node: id, + field, + from: v, + to: out, + }); + } + out +} + +fn span_extent( + id: NodeId, + binding: AxisBinding, + parent: Option, + field: &'static str, + cx: &mut Ctx, +) -> Option { + match binding { + AxisBinding::Span { start, end } => match parent { + Some(e) => { + let raw = e - start - end; + if raw < 0.0 { + // G-E2 declared rule: clamp-to-zero, reported. + cx.out.reports.push(Report::Clamped { + node: id, + field, + from: raw, + to: 0.0, + }); + } + Some(raw.max(0.0)) + } + None => { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field, + rule: "Span binding without a resolvable parent extent", + }); + Some(0.0) + } + }, + _ => None, + } +} + +/// Natural width per kind, None when the kind has no natural width. +fn intent_extent_x(id: NodeId, cx: &mut Ctx) -> Option { + let node = cx.doc.get(id); + match node.header.width { + SizeIntent::Fixed(v) => Some(v), + SizeIntent::Auto => match &node.payload { + Payload::Text { content, font_size } => { + Some(measure_text(content, *font_size, None).0) + } + Payload::Frame { .. } => Some(hug_size(id, cx).0), + _ => None, + }, + } +} + +/// Natural height per kind; text height depends on the resolved width +/// (Fixed width or a Span-resolved width ⇒ wrap constraint; Auto ⇒ +/// unconstrained single line). The census found the Span arm missing — +/// Span{0,0} is the canonical free-context fill and must re-wrap. +fn intent_extent_y(id: NodeId, span_w: Option, cx: &mut Ctx) -> Option { + let node = cx.doc.get(id); + match node.header.height { + SizeIntent::Fixed(v) => Some(v), + SizeIntent::Auto => match &node.payload { + Payload::Text { content, font_size } => { + let constraint = span_w.or(match node.header.width { + SizeIntent::Fixed(w) => Some(w), + SizeIntent::Auto => None, + }); + Some(measure_text(content, *font_size, constraint).1) + } + Payload::Frame { .. } => Some(hug_size(id, cx).1), + _ => None, + }, + } +} + +/// Hug (frame + Auto): the frame's natural content size (L-E1: padding box). +fn hug_size(id: NodeId, cx: &mut Ctx) -> (f32, f32) { + let node = cx.doc.get(id); + let layout = match &node.payload { + Payload::Frame { layout, .. } => *layout, + _ => unreachable!("hug_size on non-frame"), + }; + match layout.mode { + LayoutMode::Flex => { + let definite_w = match node.header.width { + SizeIntent::Fixed(v) => Some(v), + SizeIntent::Auto => None, + }; + let definite_h = match node.header.height { + SizeIntent::Fixed(v) => Some(v), + SizeIntent::Auto => None, + }; + let out = flex_layout(id, layout, (definite_w, definite_h), cx); + out.container + } + LayoutMode::None => { + // Hug of a free frame: children must be Start-pinned; End/Center/ + // Span pins are underdetermined under an Auto parent (declared: + // error-by-rule, contribute at their offset-as-start). + let children: Vec = node.children.clone(); + let pad = layout.padding; + let mut max_x: f32 = 0.0; + let mut max_y: f32 = 0.0; + for child_id in children { + if !cx.doc.get(child_id).header.active { + continue; + } + let (cw, ch) = extent_of(child_id, None, cx); + let is_derived = cx.doc.get(child_id).payload.box_is_derived(); + let u = if is_derived { + cx.union_cache.get(&child_id).copied().unwrap_or(RectF::EMPTY) + } else { + RectF { x: 0.0, y: 0.0, w: cw, h: ch } + }; + let child = cx.doc.get(child_id); + let ox = start_offset_or_report(child_id, child.header.x, "x", cx); + let oy = start_offset_or_report(child_id, child.header.y, "y", cx); + let aabb = if cx.opts.rotation_in_flow == RotationInFlow::VisualOnly { + // V-3 (dec0-visual-only.md): CSS-pure hug — measurement + // ignores rotation/flips; the child contributes its + // untransformed box at its pins. + RectF { + x: ox + u.x, + y: oy + u.y, + w: u.w, + h: u.h, + } + } else { + // AabbParticipates: the true local AABB under the + // child's own pivot (census fix: derived kinds pivot at + // the origin, not the box center), composition mirroring + // commit exactly (flip included). + let theta = child.header.rotation; + let (cfx, cfy) = (child.header.flip_x, child.header.flip_y); + let local = if is_derived { + Affine::translate(ox, oy) + .then(&Affine::rotate_deg(theta)) + .then(&Affine::flip(cfx, cfy)) + } else { + Affine::from_box_center_flip(ox, oy, cw, ch, theta, cfx, cfy) + }; + u.transformed_aabb(&local) + }; + max_x = max_x.max(aabb.x + aabb.w); + max_y = max_y.max(aabb.y + aabb.h); + } + (max_x + pad.left + pad.right, max_y + pad.top + pad.bottom) + } + } +} + +fn start_offset_or_report( + id: NodeId, + binding: AxisBinding, + field: &'static str, + cx: &mut Ctx, +) -> f32 { + match binding { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset, + } => offset, + AxisBinding::Pin { offset, .. } => { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field, + rule: "End/Center pin underdetermined (no parent extent); treated as Start", + }); + offset + } + AxisBinding::Span { start, .. } => { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field, + rule: "Span underdetermined (no parent extent); treated as Start", + }); + start + } + } +} + +/// Bindings table (§2.1) — position within a definite parent box. +/// +/// Derived-box kinds (§8: x/y "places the **space**"): bindings place the +/// node's local origin, never the union box — the union must not feed back +/// into placement, or editing child A would move sibling B in world space +/// (D-2, the P6 instability). Reads report the box (origin + union offset); +/// writes re-target (ops::set_x works in deltas). +fn place_by_bindings(id: NodeId, parent_extent: (f32, f32), cx: &mut Ctx) -> RectF { + let (w, h) = extent_of(id, Some(parent_extent), cx); + let node = cx.doc.get(id); + if node.payload.box_is_derived() { + let u = cx.union_cache.get(&id).copied().unwrap_or(RectF::EMPTY); + let ox = match node.header.x { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset, + } => offset, + AxisBinding::Pin { + anchor: AnchorEdge::End, + offset, + } => parent_extent.0 - offset, + AxisBinding::Pin { + anchor: AnchorEdge::Center, + offset, + } => parent_extent.0 / 2.0 + offset, + AxisBinding::Span { start, .. } => { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field: "x", + rule: "Span on a derived-box kind; treated as Start", + }); + start + } + }; + let oy = match node.header.y { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset, + } => offset, + AxisBinding::Pin { + anchor: AnchorEdge::End, + offset, + } => parent_extent.1 - offset, + AxisBinding::Pin { + anchor: AnchorEdge::Center, + offset, + } => parent_extent.1 / 2.0 + offset, + AxisBinding::Span { start, .. } => { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field: "y", + rule: "Span on a derived-box kind; treated as Start", + }); + start + } + }; + // The *box* (read tier) = origin + union offset. + return RectF { + x: ox + u.x, + y: oy + u.y, + w, + h, + }; + } + let x = match node.header.x { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset, + } => offset, + AxisBinding::Pin { + anchor: AnchorEdge::End, + offset, + } => parent_extent.0 - offset - w, + AxisBinding::Pin { + anchor: AnchorEdge::Center, + offset, + } => (parent_extent.0 - w) / 2.0 + offset, + AxisBinding::Span { start, .. } => start, + }; + let y = match node.header.y { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset, + } => offset, + AxisBinding::Pin { + anchor: AnchorEdge::End, + offset, + } => parent_extent.1 - offset - h, + AxisBinding::Pin { + anchor: AnchorEdge::Center, + offset, + } => (parent_extent.1 - h) / 2.0 + offset, + AxisBinding::Span { start, .. } => start, + }; + RectF { x, y, w, h } +} + +// --------------------------------------------------------------------------- +// Derived boxes (group / lens) +// --------------------------------------------------------------------------- + +/// Resolve a derived-box node's children in node-local space (committing +/// them), returning the union rect. Cached — children commit exactly once. +fn union_of_derived(id: NodeId, cx: &mut Ctx) -> RectF { + if let Some(u) = cx.union_cache.get(&id) { + return *u; + } + let children: Vec = cx.doc.get(id).children.clone(); + let mut union: Option = None; + for child_id in children { + if !cx.doc.get(child_id).header.active { + continue; + } + // Children of derived-box parents resolve against no extent: + // Start pins only (declared; others reported by start_offset_or_report). + let (cw, ch) = extent_of(child_id, None, cx); + // Derived children: pins place the ORIGIN; the box = origin + own + // union offset (census fix: nested unions must not swallow it). + let derived_off = if cx.doc.get(child_id).payload.box_is_derived() { + let u = cx.union_cache.get(&child_id).copied().unwrap_or(RectF::EMPTY); + (u.x, u.y) + } else { + (0.0, 0.0) + }; + let child = cx.doc.get(child_id); + let ox = start_offset_or_report(child_id, child.header.x, "x", cx) + derived_off.0; + let oy = start_offset_or_report(child_id, child.header.y, "y", cx) + derived_off.1; + let child_box = RectF { + x: ox, + y: oy, + w: cw, + h: ch, + }; + commit(child_id, child_box, cx); + let aabb = if cx.opts.rotation_in_flow == RotationInFlow::VisualOnly { + // V-4 (dec0-visual-only.md): the derived box is SIZING-tier — + // union of members' untransformed boxes at their pins. An + // oriented union would smuggle the envelope back into layout + // one nesting level deep. Ink bounds remain `world_aabb`. + child_box + } else { + // AabbParticipates (D-1): union of oriented corners — each + // child's local AABB under its own local transform. + let local = cx.out.local[child_id as usize].unwrap(); + RectF { + x: 0.0, + y: 0.0, + w: cw, + h: ch, + } + .transformed_aabb(&local) + }; + union = Some(match union { + None => aabb, + Some(u) => u.union(&aabb), + }); + } + // D-E1 declared: empty/hidden-only derived box = zero rect at origin. + let u = union.unwrap_or(RectF::EMPTY); + cx.union_cache.insert(id, u); + u +} + +fn fold_lens_ops(ops: &[LensOp], u: RectF) -> Affine { + let (ucx, ucy) = u.center(); + let about_center = |m: Affine| { + Affine::translate(ucx, ucy) + .then(&m) + .then(&Affine::translate(-ucx, -ucy)) + }; + let mut acc = Affine::IDENTITY; + for op in ops { + let m = match op { + LensOp::Translate { x, y } => Affine::translate(*x, *y), + LensOp::Rotate { deg } => about_center(Affine::rotate_deg(*deg)), + LensOp::Scale { x, y } => about_center(Affine::scale(*x, *y)), + LensOp::Skew { x_deg, y_deg } => about_center(Affine::skew_deg(*x_deg, *y_deg)), + LensOp::Matrix { m } => Affine { + a: m[0], + b: m[1], + c: m[2], + d: m[3], + e: m[4], + f: m[5], + }, + }; + acc = acc.then(&m); + } + acc +} + +// --------------------------------------------------------------------------- +// Commit: box known → store local transform, lay out subtree +// --------------------------------------------------------------------------- + +/// `box_rect` is the node's unrotated box in parent space (derived kinds: +/// where the union box lands). +fn commit(id: NodeId, box_rect: RectF, cx: &mut Ctx) { + let node = cx.doc.get(id); + let theta = node.header.rotation; + let (fx, fy) = (node.header.flip_x, node.header.flip_y); + + let local = if node.payload.box_is_derived() { + // Pivot = the node's own local origin (§5); the origin is recovered + // from the box by subtracting the union offset. Children live in + // origin space, so no further correction term exists — which is + // exactly why child edits never move siblings (D-2). + // Flip shares the pivot (B1) and composes innermost: T·R·F. + let u = cx.union_cache.get(&id).copied().unwrap_or(RectF::EMPTY); + let t = Affine::translate(box_rect.x - u.x, box_rect.y - u.y) + .then(&Affine::rotate_deg(theta)) + .then(&Affine::flip(fx, fy)); + if let Payload::Lens { ops } = &node.payload { + let folded = fold_lens_ops(ops, u); + cx.ops_cache.insert(id, folded); + } + t + } else { + // Boxed/measured kinds: center pivot (§5) — the one pivot where the + // box and its rotated AABB stay concentric. Flip is center-applied + // too, so it never changes the AABB: layout-invisible by construction. + Affine::from_box_center_flip( + box_rect.x, box_rect.y, box_rect.w, box_rect.h, theta, fx, fy, + ) + }; + + cx.out.box_in_parent[id as usize] = Some(box_rect); + cx.out.local[id as usize] = Some(local); + + // Recurse into frame children (group/lens children were committed by + // union_of_derived; leaves have none). + if let Payload::Frame { layout, .. } = &cx.doc.get(id).payload { + let layout = *layout; + layout_frame_children(id, layout, (box_rect.w, box_rect.h), cx); + } +} + +fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), cx: &mut Ctx) { + let children: Vec = cx.doc.get(id).children.clone(); + match layout.mode { + LayoutMode::None => { + for child_id in children { + if !cx.doc.get(child_id).header.active { + continue; + } + report_flow_fields_inert(child_id, cx); + let b = place_by_bindings(child_id, extent, cx); + commit(child_id, b, cx); + } + } + LayoutMode::Flex => { + let out = flex_layout(id, layout, (Some(extent.0), Some(extent.1)), cx); + for (child_id, slot, basis) in out.slots { + let child = cx.doc.get(child_id); + let theta = child.header.rotation; + let rotated = theta.rem_euclid(360.0) != 0.0; + let is_derived = child.payload.box_is_derived(); + let b = if is_derived { + // Derived kinds keep their union dims; under AABB mode + // the *post-rotation* union center lands on the slot + // center (pivot is the origin, so the center swings). + let u = cx.union_cache.get(&child_id).copied().unwrap_or(RectF::EMPTY); + if cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates { + let (scx, scy) = slot.center(); + // Post-transform union center (R·F — same composition + // as commit) lands on the slot center. + let d = Affine::rotate_deg(theta) + .then(&Affine::flip(child.header.flip_x, child.header.flip_y)) + .apply((u.x + u.w / 2.0, u.y + u.h / 2.0)); + RectF { + x: scx - d.0 + u.x, + y: scy - d.1 + u.y, + w: u.w, + h: u.h, + } + } else { + RectF { + x: slot.x, + y: slot.y, + w: u.w, + h: u.h, + } + } + } else if rotated + && cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates + { + // Box center := slot center; box keeps its basis dims. + let (bw, bh) = basis; + let (cx_, cy_) = slot.center(); + RectF { + x: cx_ - bw / 2.0, + y: cy_ - bh / 2.0, + w: bw, + h: bh, + } + } else { + // Unrotated (or VisualOnly): the slot is the box. + slot + }; + commit(child_id, b, cx); + } + // Absolute children: excluded from flow, resolve against the + // parent box (L-4). + for child_id in children { + let child = cx.doc.get(child_id); + if !child.header.active || child.header.flow != Flow::Absolute { + continue; + } + let b = place_by_bindings(child_id, extent, cx); + commit(child_id, b, cx); + } + } + } +} + +/// §8: x/y and grow/self_align are inert outside their contexts — report +/// non-default values so tests can assert the matrix is enforced. +fn report_flow_fields_inert(id: NodeId, cx: &mut Ctx) { + let h = &cx.doc.get(id).header; + if h.grow != 0.0 { + cx.out.reports.push(Report::IgnoredByRule { + node: id, + field: "grow", + rule: "no layout parent", + }); + } + if h.self_align != SelfAlign::Auto { + cx.out.reports.push(Report::IgnoredByRule { + node: id, + field: "self_align", + rule: "no layout parent", + }); + } +} + +// --------------------------------------------------------------------------- +// Flex via taffy (per-container run) +// --------------------------------------------------------------------------- + +struct TextCtx { + content: String, + font_size: f32, +} + +struct FlexOut { + container: (f32, f32), + /// (child, slot rect in container space, basis dims) + slots: Vec<(NodeId, RectF, (f32, f32))>, +} + +fn flex_layout( + id: NodeId, + layout: LayoutBehavior, + definite: (Option, Option), + cx: &mut Ctx, +) -> FlexOut { + let children: Vec = cx.doc.get(id).children.clone(); + + let mut tree: TaffyTree = TaffyTree::new(); + // L-7 (declared POL): resolution is unquantized; pixel snapping is a + // paint concern. Taffy rounds by default — turned off deliberately. + tree.disable_rounding(); + + // L-3 (declared INV): hug uses basis sizes — grow distributes only + // *definite* free space. Taffy 0.9 deviates: in its intrinsic pass a + // growable item's contribution is floored by the container's own + // padding (`.max(main_content_box_inset)`, flexbox.rs), inflating hug + // sizes vs both the spec and Chromium. Enforce the spec by stripping + // grow when the main axis is indefinite; the definite re-run applies + // the real grow factors. (E-finding: oracle deviation in the layout dep.) + let main_is_definite = match layout.direction { + Direction::Row => definite.0.is_some(), + Direction::Column => definite.1.is_some(), + }; + let mut entries: Vec<(NodeId, taffy::NodeId, (f32, f32))> = Vec::new(); + + for child_id in &children { + let child = cx.doc.get(*child_id); + if !child.header.active || child.header.flow == Flow::Absolute { + continue; + } + // §8: x/y are ignored-by-rule under flow (layout owns them). + if child.header.x != AxisBinding::default() || child.header.y != AxisBinding::default() { + cx.out.reports.push(Report::IgnoredByRule { + node: *child_id, + field: "x/y", + rule: "in-flow under flex: layout owns position", + }); + } + let theta = child.header.rotation; + let rotated = theta.rem_euclid(360.0) != 0.0; + let contribute_aabb = + rotated && cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates; + + let mut style = Style { + // leaf display: taffy default (flex); Block distorts intrinsic sizing + flex_shrink: 0.0, // X-SELF-8: canvas items don't shrink implicitly + flex_grow: if main_is_definite { + child.header.grow + } else { + 0.0 + }, + align_self: match child.header.self_align { + SelfAlign::Auto => None, + SelfAlign::Start => Some(AlignSelf::Start), + SelfAlign::Center => Some(AlignSelf::Center), + SelfAlign::End => Some(AlignSelf::End), + SelfAlign::Stretch => Some(AlignSelf::Stretch), + }, + ..Style::default() + }; + if let Some(v) = child.header.min_width { + style.min_size.width = length(v); + } + if let Some(v) = child.header.max_width { + style.max_size.width = length(v); + } + if let Some(v) = child.header.min_height { + style.min_size.height = length(v); + } + if let Some(v) = child.header.max_height { + style.max_size.height = length(v); + } + + let is_text = matches!(child.payload, Payload::Text { .. }); + let basis: (f32, f32); + let taffy_child = if contribute_aabb { + // Fixed AABB contribution computed from resolved size only (§5). + let b = extent_of(*child_id, None, cx); + basis = b; + let (aw, ah) = rotated_aabb_size(b.0, b.1, theta); + style.size.width = length(aw); + style.size.height = length(ah); + tree.new_leaf(style).unwrap() + } else if is_text { + // Measured kind: taffy drives re-measure at layout-imposed + // extents (L-5) through the measure closure. + let (content, font_size) = match &child.payload { + Payload::Text { content, font_size } => (content.clone(), *font_size), + _ => unreachable!(), + }; + match child.header.width { + SizeIntent::Fixed(v) => style.size.width = length(v), + SizeIntent::Auto => style.size.width = auto(), + } + match child.header.height { + SizeIntent::Fixed(v) => style.size.height = length(v), + SizeIntent::Auto => style.size.height = auto(), + } + basis = (0.0, 0.0); // unused for θ=0 slots + tree.new_leaf_with_context(style, TextCtx { content, font_size }) + .unwrap() + } else { + let b = extent_of(*child_id, None, cx); + basis = b; + style.size.width = length(b.0); + style.size.height = length(b.1); + // Stretch must be able to override the cross-axis basis (§2.2: + // cross-axis fill := self_align Stretch). + if child.header.self_align == SelfAlign::Stretch { + match layout.direction { + Direction::Row => style.size.height = auto(), + Direction::Column => style.size.width = auto(), + } + } + tree.new_leaf(style).unwrap() + }; + entries.push((*child_id, taffy_child, basis)); + } + + let container_style = Style { + display: Display::Flex, + flex_direction: match layout.direction { + Direction::Row => FlexDirection::Row, + Direction::Column => FlexDirection::Column, + }, + flex_wrap: if layout.wrap { + FlexWrap::Wrap + } else { + FlexWrap::NoWrap + }, + justify_content: Some(match layout.main_align { + MainAlign::Start => JustifyContent::Start, + MainAlign::Center => JustifyContent::Center, + MainAlign::End => JustifyContent::End, + MainAlign::SpaceBetween => JustifyContent::SpaceBetween, + MainAlign::SpaceAround => JustifyContent::SpaceAround, + MainAlign::SpaceEvenly => JustifyContent::SpaceEvenly, + }), + align_items: Some(match layout.cross_align { + CrossAlign::Start => AlignItems::Start, + CrossAlign::Center => AlignItems::Center, + CrossAlign::End => AlignItems::End, + CrossAlign::Stretch => AlignItems::Stretch, + }), + gap: taffy::geometry::Size { + width: length(match layout.direction { + Direction::Row => layout.gap_main, + Direction::Column => layout.gap_cross, + }), + height: length(match layout.direction { + Direction::Row => layout.gap_cross, + Direction::Column => layout.gap_main, + }), + }, + padding: taffy::geometry::Rect { + left: length(layout.padding.left), + right: length(layout.padding.right), + top: length(layout.padding.top), + bottom: length(layout.padding.bottom), + }, + size: taffy::geometry::Size { + width: match definite.0 { + Some(v) => length(v), + None => auto(), + }, + height: match definite.1 { + Some(v) => length(v), + None => auto(), + }, + }, + ..Style::default() + }; + + let kids: Vec = entries.iter().map(|(_, t, _)| *t).collect(); + let container = tree.new_with_children(container_style, &kids).unwrap(); + + let avail = taffy::geometry::Size { + width: match definite.0 { + Some(v) => AvailableSpace::Definite(v), + None => AvailableSpace::MaxContent, + }, + height: match definite.1 { + Some(v) => AvailableSpace::Definite(v), + None => AvailableSpace::MaxContent, + }, + }; + + tree.compute_layout_with_measure( + container, + avail, + |known, avail_space, _node, ctx, _style| { + if std::env::var("ANCHOR_DBG").is_ok() { + eprintln!("measure ctx={} known={:?} avail={:?}", ctx.is_some(), known, avail_space); + } + match ctx { + Some(t) => { + let constraint = known.width.or(match avail_space.width { + AvailableSpace::Definite(w) => Some(w), + _ => None, + }); + let (w, h) = measure_text(&t.content, t.font_size, constraint); + taffy::geometry::Size { + width: known.width.unwrap_or(w), + height: known.height.unwrap_or(h), + } + } + None => taffy::geometry::Size { + width: known.width.unwrap_or(0.0), + height: known.height.unwrap_or(0.0), + }, + }}, + ) + .unwrap(); + + let csize = tree.layout(container).unwrap().size; + let mut slots = Vec::new(); + for (child_id, taffy_id, basis) in entries { + let l = tree.layout(taffy_id).unwrap(); + slots.push(( + child_id, + RectF { + x: l.location.x, + y: l.location.y, + w: l.size.width, + h: l.size.height, + }, + basis, + )); + } + FlexOut { + container: (csize.width, csize.height), + slots, + } +} + +// --------------------------------------------------------------------------- +// Phase T + B +// --------------------------------------------------------------------------- + +fn compose_world(id: NodeId, parent_world: Affine, cx: &mut Ctx) { + let local = cx.out.local[id as usize].unwrap(); + let world = parent_world.then(&local); + cx.out.world[id as usize] = Some(world); + // Children of a lens compose through the folded ops: + // world = parent ∘ local ∘ eval(ops) (a.md §6 Phase T). + let child_basis = match cx.ops_cache.get(&id) { + Some(ops) => world.then(ops), + None => world, + }; + let children: Vec = cx.doc.get(id).children.clone(); + for c in children { + if cx.out.local[c as usize].is_some() { + compose_world(c, child_basis, cx); + } + } +} + +fn compute_world_aabb(id: NodeId, cx: &mut Ctx) -> Option { + let node = cx.doc.get(id); + if cx.out.world[id as usize].is_none() { + return None; // hidden subtree + } + let world = cx.out.world[id as usize].unwrap(); + let own_box = cx.out.box_in_parent[id as usize].unwrap(); + let local_rect = RectF { + x: 0.0, + y: 0.0, + w: own_box.w, + h: own_box.h, + }; + + let mut aabb = match node.payload { + // Derived kinds: bounds come from children only (D-1). + Payload::Group | Payload::Lens { .. } => None, + _ => Some(local_rect.transformed_aabb(&world)), + }; + let children: Vec = node.children.clone(); + for c in children { + if let Some(child_aabb) = compute_world_aabb(c, cx) { + aabb = Some(match aabb { + None => child_aabb, + Some(a) => a.union(&child_aabb), + }); + } + } + let result = aabb.unwrap_or(RectF::EMPTY); + cx.out.world_aabb[id as usize] = Some(result); + Some(result) +} diff --git a/model-v2/a/lab/src/svgout.rs b/model-v2/a/lab/src/svgout.rs new file mode 100644 index 0000000000..f15f6cffe1 --- /dev/null +++ b/model-v2/a/lab/src/svgout.rs @@ -0,0 +1,128 @@ +//! SVG snapshots of resolved documents — E1 visual evidence. +//! Renders each node with its world matrix; optionally overlays world AABBs. + +use crate::math::Affine; +use crate::model::*; +use crate::resolve::Resolved; +use std::fmt::Write as _; + +fn mat(t: &Affine) -> String { + format!( + "matrix({} {} {} {} {} {})", + t.a, t.b, t.c, t.d, t.e, t.f + ) +} + +pub struct SvgOptions { + pub show_aabb: bool, + pub width: f32, + pub height: f32, +} + +pub fn render(doc: &Document, resolved: &Resolved, opts: &SvgOptions) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + r#""#, + w = opts.width, + h = opts.height + ); + paint(doc, doc.root, resolved, &mut out); + if opts.show_aabb { + for (i, slot) in resolved.world_aabb.iter().enumerate() { + let (id, aabb) = match slot { + Some(a) => (i as NodeId, a), + None => continue, + }; + if id == doc.root { + continue; + } + let _ = writeln!( + out, + r##" "##, + aabb.x, aabb.y, aabb.w, aabb.h + ); + } + } + let _ = writeln!(out, ""); + out +} + +fn paint(doc: &Document, id: NodeId, resolved: &Resolved, out: &mut String) { + let node = doc.get(id); + let Some(world) = resolved.world_opt(id) else { + return; // hidden + }; + let world = &world; + let b = resolved.box_of(id); + let fill = node.fill.as_deref(); + + match &node.payload { + Payload::Frame { .. } => { + let f = fill.unwrap_or("#f6f6f6"); + let _ = writeln!( + out, + r##" "##, + b.w, + b.h, + mat(world), + f + ); + } + Payload::Shape { desc } => { + let f = fill.unwrap_or("#4a90d9"); + match desc { + ShapeDesc::Rect => { + let _ = writeln!( + out, + r#" "#, + b.w, + b.h, + mat(world), + f + ); + } + ShapeDesc::Ellipse => { + let _ = writeln!( + out, + r#" "#, + b.w / 2.0, + b.h / 2.0, + b.w / 2.0, + b.h / 2.0, + mat(world), + f + ); + } + ShapeDesc::Line => { + let _ = writeln!( + out, + r#" "#, + b.w, + mat(world), + f + ); + } + } + } + Payload::Text { content, font_size } => { + let _ = writeln!( + out, + r#" {}"#, + font_size, + mat(world), + font_size, + fill.unwrap_or("#222"), + content + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + ); + } + Payload::Group | Payload::Lens { .. } => {} + } + + for c in &node.children { + paint(doc, *c, resolved, out); + } +} diff --git a/model-v2/a/lab/src/textir.rs b/model-v2/a/lab/src/textir.rs new file mode 100644 index 0000000000..1199d6512f --- /dev/null +++ b/model-v2/a/lab/src/textir.rs @@ -0,0 +1,640 @@ +//! The agent text IR — an XML-ish projection of the anchor document +//! (triage amendment 3). Audience: LLMs. The grammar is specified in +//! `model-v2/a/e3-text-ir/grammar.md`; this module is the reference +//! parser + canonical printer. +//! +//! Round-trip law: `parse(print(doc)) == doc` for documents whose ids were +//! assigned in document order (the IR does not carry ids — id stability is +//! a binary-format concern; recorded as an E3 finding). + +use crate::model::*; +use quick_xml::events::Event; +use quick_xml::Reader; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +#[derive(Debug, Clone, PartialEq)] +pub struct ParseError(pub String); + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "textir: {}", self.0) + } +} + +fn err(msg: impl Into) -> Result { + Err(ParseError(msg.into())) +} + +fn parse_num(s: &str, what: &str) -> Result { + let v: f32 = s + .trim() + .parse() + .map_err(|_| ParseError(format!("bad number `{s}` in {what}")))?; + if !v.is_finite() { + return err(format!("non-finite number `{s}` in {what} (N-2)")); + } + Ok(v) +} + +fn parse_binding(s: &str, what: &str) -> Result { + let parts: Vec<&str> = s.split_whitespace().collect(); + match parts.as_slice() { + [n] if n.chars().next().map(|c| c.is_ascii_digit() || c == '-' || c == '.') == Some(true) => { + Ok(AxisBinding::start(parse_num(n, what)?)) + } + ["start", n] => Ok(AxisBinding::start(parse_num(n, what)?)), + ["end", n] => Ok(AxisBinding::end(parse_num(n, what)?)), + ["center"] => Ok(AxisBinding::center(0.0)), + ["center", n] => Ok(AxisBinding::center(parse_num(n, what)?)), + ["span", a, b] => Ok(AxisBinding::Span { + start: parse_num(a, what)?, + end: parse_num(b, what)?, + }), + _ => err(format!("bad binding `{s}` in {what}")), + } +} + +fn parse_size(s: &str, what: &str) -> Result { + if s.trim() == "auto" { + Ok(SizeIntent::Auto) + } else { + Ok(SizeIntent::Fixed(parse_num(s, what)?)) + } +} + +fn parse_lens_ops(s: &str) -> Result, ParseError> { + let mut ops = vec![]; + for raw in s.split(')') { + let raw = raw.trim().trim_start_matches(',').trim(); + if raw.is_empty() { + continue; + } + let (name, args) = raw + .split_once('(') + .ok_or_else(|| ParseError(format!("bad lens op `{raw}`")))?; + let nums: Vec = args + .split(',') + .filter(|a| !a.trim().is_empty()) + .map(|a| parse_num(a, "lens ops")) + .collect::>()?; + let op = match (name.trim(), nums.as_slice()) { + ("translate", [x, y]) => LensOp::Translate { x: *x, y: *y }, + ("rotate", [d]) => LensOp::Rotate { deg: *d }, + ("scale", [s]) => LensOp::Scale { x: *s, y: *s }, + ("scale", [x, y]) => LensOp::Scale { x: *x, y: *y }, + ("skew-x", [d]) => LensOp::Skew { + x_deg: *d, + y_deg: 0.0, + }, + ("skew-y", [d]) => LensOp::Skew { + x_deg: 0.0, + y_deg: *d, + }, + ("skew", [x, y]) => LensOp::Skew { + x_deg: *x, + y_deg: *y, + }, + ("matrix", [a, b, c, d, e, f]) => LensOp::Matrix { + m: [*a, *b, *c, *d, *e, *f], + }, + _ => return err(format!("bad lens op `{raw}`")), + }; + ops.push(op); + } + Ok(ops) +} + +struct Pending { + id: NodeId, + text_content: String, + is_text: bool, +} + +pub fn parse(input: &str) -> Result { + let mut reader = Reader::from_str(input); + reader.config_mut().trim_text(true); + + let mut nodes: BTreeMap = BTreeMap::new(); + let mut stack: Vec = vec![]; + let mut next_id: NodeId = 0; + let mut root: Option = None; + + loop { + match reader.read_event() { + Err(e) => return err(format!("xml: {e}")), + Ok(Event::Eof) => break, + Ok(ev @ (Event::Start(_) | Event::Empty(_))) => { + let is_empty = matches!(ev, Event::Empty(_)); + let el = match ev { + Event::Start(e) | Event::Empty(e) => e, + _ => unreachable!(), + }; + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + let mut header = Header::new(SizeIntent::Auto, SizeIntent::Auto); + let mut layout = LayoutBehavior::default(); + let mut shape_kind: Option = None; + let mut font_size = 16.0f32; + let mut lens_ops: Vec = vec![]; + let mut clips = false; + let mut fill: Option = None; + + for attr in el.attributes() { + let attr = attr.map_err(|e| ParseError(format!("attr: {e}")))?; + let key = String::from_utf8_lossy(attr.key.as_ref()).to_string(); + let val = attr + .unescape_value() + .map_err(|e| ParseError(format!("attr value: {e}")))? + .to_string(); + match key.as_str() { + "name" => header.name = Some(val), + "x" => header.x = parse_binding(&val, "x")?, + "y" => header.y = parse_binding(&val, "y")?, + "w" => header.width = parse_size(&val, "w")?, + "h" => header.height = parse_size(&val, "h")?, + "min-w" => header.min_width = Some(parse_num(&val, "min-w")?), + "max-w" => header.max_width = Some(parse_num(&val, "max-w")?), + "min-h" => header.min_height = Some(parse_num(&val, "min-h")?), + "max-h" => header.max_height = Some(parse_num(&val, "max-h")?), + "aspect" => { + let (a, b) = val + .split_once(':') + .ok_or_else(|| ParseError("aspect needs `w:h`".into()))?; + header.aspect_ratio = + Some((parse_num(a, "aspect")?, parse_num(b, "aspect")?)); + } + "rotation" => header.rotation = parse_num(&val, "rotation")?, + "flip-x" => header.flip_x = val == "true", + "flip-y" => header.flip_y = val == "true", + "flow" => { + header.flow = match val.as_str() { + "absolute" => Flow::Absolute, + "in" => Flow::InFlow, + _ => return err(format!("bad flow `{val}`")), + } + } + "grow" => header.grow = parse_num(&val, "grow")?, + "align" => { + header.self_align = match val.as_str() { + "start" => SelfAlign::Start, + "center" => SelfAlign::Center, + "end" => SelfAlign::End, + "stretch" => SelfAlign::Stretch, + _ => return err(format!("bad align `{val}`")), + } + } + "opacity" => header.opacity = parse_num(&val, "opacity")?, + "hidden" => header.active = val != "true", + "layout" => { + layout.mode = match val.as_str() { + "flex" => LayoutMode::Flex, + "none" => LayoutMode::None, + _ => return err(format!("bad layout `{val}`")), + } + } + "direction" => { + layout.direction = match val.as_str() { + "row" => Direction::Row, + "column" => Direction::Column, + _ => return err(format!("bad direction `{val}`")), + } + } + "wrap" => layout.wrap = val == "true", + "main" => { + layout.main_align = match val.as_str() { + "start" => MainAlign::Start, + "center" => MainAlign::Center, + "end" => MainAlign::End, + "space-between" => MainAlign::SpaceBetween, + "space-around" => MainAlign::SpaceAround, + "space-evenly" => MainAlign::SpaceEvenly, + _ => return err(format!("bad main `{val}`")), + } + } + "cross" => { + layout.cross_align = match val.as_str() { + "start" => CrossAlign::Start, + "center" => CrossAlign::Center, + "end" => CrossAlign::End, + "stretch" => CrossAlign::Stretch, + _ => return err(format!("bad cross `{val}`")), + } + } + "gap" => { + let parts: Vec<&str> = val.split_whitespace().collect(); + match parts.as_slice() { + [g] => { + layout.gap_main = parse_num(g, "gap")?; + layout.gap_cross = layout.gap_main; + } + [m, c] => { + layout.gap_main = parse_num(m, "gap")?; + layout.gap_cross = parse_num(c, "gap")?; + } + _ => return err("gap takes 1 or 2 numbers"), + } + } + "padding" => { + let nums: Vec = val + .split_whitespace() + .map(|p| parse_num(p, "padding")) + .collect::>()?; + layout.padding = match nums.as_slice() { + [a] => EdgeInsets::all(*a), + [t, r, b, l] => EdgeInsets { + top: *t, + right: *r, + bottom: *b, + left: *l, + }, + _ => return err("padding takes 1 or 4 numbers"), + }; + } + "clips" => clips = val == "true", + "kind" => { + shape_kind = Some(match val.as_str() { + "rect" => ShapeDesc::Rect, + "ellipse" => ShapeDesc::Ellipse, + "line" => ShapeDesc::Line, + _ => return err(format!("bad shape kind `{val}`")), + }) + } + "size" => font_size = parse_num(&val, "size")?, + "ops" => lens_ops = parse_lens_ops(&val)?, + "fill" => fill = Some(val), + _ => return err(format!("unknown attribute `{key}` on <{tag}>")), + } + } + + let payload = match tag.as_str() { + "frame" => { + // Kind defaults (§4): frame sizes are Fixed-required; + // the IR treats missing w/h as auto (hug) which is + // legal for frames. + Payload::Frame { + layout, + clips_content: clips, + } + } + "shape" => { + let desc = shape_kind + .ok_or_else(|| ParseError(" requires kind".into()))?; + if matches!(desc, ShapeDesc::Line) { + header.height = SizeIntent::Fixed(0.0); // §3.2 locked + } + Payload::Shape { desc } + } + "text" => Payload::Text { + content: String::new(), // filled at End + font_size, + }, + "group" => Payload::Group, + "lens" => Payload::Lens { ops: lens_ops }, + _ => return err(format!("unknown element <{tag}>")), + }; + + let id = next_id; + next_id += 1; + let node = Node { + id, + header, + payload, + children: vec![], + fill, + }; + nodes.insert(id, node); + if let Some(parent) = stack.last() { + let pid = parent.id; + nodes.get_mut(&pid).unwrap().children.push(id); + } else if root.is_none() { + root = Some(id); + } else { + return err("multiple root elements"); + } + + let is_text = tag == "text"; + stack.push(Pending { + id, + text_content: String::new(), + is_text, + }); + // Self-closing (`Event::Empty`) has no matching End event. + if is_empty { + finish(&mut stack, &mut nodes)?; + } + } + Ok(Event::Text(t)) => { + if let Some(p) = stack.last_mut() { + let txt = t + .unescape() + .map_err(|e| ParseError(format!("text: {e}")))?; + p.text_content.push_str(&txt); + } + } + Ok(Event::End(_)) => { + finish(&mut stack, &mut nodes)?; + } + Ok(_) => {} + } + } + + if !stack.is_empty() { + return err("unclosed elements"); + } + let root = root.ok_or_else(|| ParseError("empty document".into()))?; + // The scene root spans the viewport unless bindings were given. + Ok(Document::from_map(nodes, root)) +} + +fn finish(stack: &mut Vec, nodes: &mut BTreeMap) -> Result<(), ParseError> { + let p = stack.pop().ok_or_else(|| ParseError("unbalanced end".into()))?; + if p.is_text { + if let Payload::Text { content, .. } = &mut nodes.get_mut(&p.id).unwrap().payload { + *content = p.text_content; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Canonical printer — shortest honest form, defaults omitted +// --------------------------------------------------------------------------- + +fn fmt_num(v: f32) -> String { + if v == v.trunc() && v.abs() < 1e7 { + format!("{}", v as i64) + } else { + format!("{v}") + } +} + +fn fmt_binding(b: AxisBinding) -> Option { + match b { + AxisBinding::Pin { + anchor: AnchorEdge::Start, + offset, + } => { + if offset == 0.0 { + None // default + } else { + Some(fmt_num(offset)) + } + } + AxisBinding::Pin { + anchor: AnchorEdge::End, + offset, + } => Some(format!("end {}", fmt_num(offset))), + AxisBinding::Pin { + anchor: AnchorEdge::Center, + offset, + } => Some(if offset == 0.0 { + "center".to_string() + } else { + format!("center {}", fmt_num(offset)) + }), + AxisBinding::Span { start, end } => { + Some(format!("span {} {}", fmt_num(start), fmt_num(end))) + } + } +} + +fn push_attr(out: &mut String, key: &str, val: &str) { + let _ = write!(out, " {key}=\"{val}\""); +} + +pub fn print(doc: &Document) -> String { + let mut out = String::new(); + print_node(doc, doc.root, 0, &mut out); + out +} + +fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { + let node = doc.get(id); + let indent = " ".repeat(depth); + let tag = node.payload.kind_name(); + let _ = write!(out, "{indent}<{tag}"); + + if let Some(name) = &node.header.name { + push_attr(out, "name", name); + } + if let Some(b) = fmt_binding(node.header.x) { + push_attr(out, "x", &b); + } + if let Some(b) = fmt_binding(node.header.y) { + push_attr(out, "y", &b); + } + if !node.payload.box_is_derived() { + match node.header.width { + SizeIntent::Fixed(v) => push_attr(out, "w", &fmt_num(v)), + SizeIntent::Auto => { + if matches!(node.payload, Payload::Frame { .. }) { + push_attr(out, "w", "auto"); + } + // text: auto is the kind default — omitted + } + } + let is_line = matches!( + node.payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ); + match node.header.height { + SizeIntent::Fixed(v) => { + if !is_line { + push_attr(out, "h", &fmt_num(v)); + } + } + SizeIntent::Auto => { + if matches!(node.payload, Payload::Frame { .. }) { + push_attr(out, "h", "auto"); + } + } + } + } + if let Some(v) = node.header.min_width { + push_attr(out, "min-w", &fmt_num(v)); + } + if let Some(v) = node.header.max_width { + push_attr(out, "max-w", &fmt_num(v)); + } + if let Some(v) = node.header.min_height { + push_attr(out, "min-h", &fmt_num(v)); + } + if let Some(v) = node.header.max_height { + push_attr(out, "max-h", &fmt_num(v)); + } + if let Some((a, b)) = node.header.aspect_ratio { + push_attr(out, "aspect", &format!("{}:{}", fmt_num(a), fmt_num(b))); + } + if node.header.rotation != 0.0 { + push_attr(out, "rotation", &fmt_num(node.header.rotation)); + } + if node.header.flip_x { + push_attr(out, "flip-x", "true"); + } + if node.header.flip_y { + push_attr(out, "flip-y", "true"); + } + if node.header.flow == Flow::Absolute { + push_attr(out, "flow", "absolute"); + } + if node.header.grow != 0.0 { + push_attr(out, "grow", &fmt_num(node.header.grow)); + } + match node.header.self_align { + SelfAlign::Auto => {} + SelfAlign::Start => push_attr(out, "align", "start"), + SelfAlign::Center => push_attr(out, "align", "center"), + SelfAlign::End => push_attr(out, "align", "end"), + SelfAlign::Stretch => push_attr(out, "align", "stretch"), + } + if node.header.opacity != 1.0 { + push_attr(out, "opacity", &fmt_num(node.header.opacity)); + } + if !node.header.active { + push_attr(out, "hidden", "true"); + } + + match &node.payload { + Payload::Frame { + layout, + clips_content, + } => { + if layout.mode == LayoutMode::Flex { + push_attr(out, "layout", "flex"); + if layout.direction != Direction::Row { + push_attr(out, "direction", "column"); + } + if layout.wrap { + push_attr(out, "wrap", "true"); + } + match layout.main_align { + MainAlign::Start => {} + MainAlign::Center => push_attr(out, "main", "center"), + MainAlign::End => push_attr(out, "main", "end"), + MainAlign::SpaceBetween => push_attr(out, "main", "space-between"), + MainAlign::SpaceAround => push_attr(out, "main", "space-around"), + MainAlign::SpaceEvenly => push_attr(out, "main", "space-evenly"), + } + match layout.cross_align { + CrossAlign::Start => {} + CrossAlign::Center => push_attr(out, "cross", "center"), + CrossAlign::End => push_attr(out, "cross", "end"), + CrossAlign::Stretch => push_attr(out, "cross", "stretch"), + } + if layout.gap_main != 0.0 || layout.gap_cross != 0.0 { + if layout.gap_main == layout.gap_cross { + push_attr(out, "gap", &fmt_num(layout.gap_main)); + } else { + push_attr( + out, + "gap", + &format!( + "{} {}", + fmt_num(layout.gap_main), + fmt_num(layout.gap_cross) + ), + ); + } + } + } + let p = layout.padding; + if p != EdgeInsets::default() { + if p.top == p.right && p.right == p.bottom && p.bottom == p.left { + push_attr(out, "padding", &fmt_num(p.top)); + } else { + push_attr( + out, + "padding", + &format!( + "{} {} {} {}", + fmt_num(p.top), + fmt_num(p.right), + fmt_num(p.bottom), + fmt_num(p.left) + ), + ); + } + } + if *clips_content { + push_attr(out, "clips", "true"); + } + } + Payload::Shape { desc } => { + let kind = match desc { + ShapeDesc::Rect => "rect", + ShapeDesc::Ellipse => "ellipse", + ShapeDesc::Line => "line", + }; + push_attr(out, "kind", kind); + } + Payload::Text { font_size, .. } => { + if *font_size != 16.0 { + push_attr(out, "size", &fmt_num(*font_size)); + } + } + Payload::Group => {} + Payload::Lens { ops } => { + let parts: Vec = ops + .iter() + .map(|op| match op { + LensOp::Translate { x, y } => { + format!("translate({},{})", fmt_num(*x), fmt_num(*y)) + } + LensOp::Rotate { deg } => format!("rotate({})", fmt_num(*deg)), + LensOp::Scale { x, y } => { + if x == y { + format!("scale({})", fmt_num(*x)) + } else { + format!("scale({},{})", fmt_num(*x), fmt_num(*y)) + } + } + LensOp::Skew { x_deg, y_deg } => { + if *y_deg == 0.0 { + format!("skew-x({})", fmt_num(*x_deg)) + } else if *x_deg == 0.0 { + format!("skew-y({})", fmt_num(*y_deg)) + } else { + format!("skew({},{})", fmt_num(*x_deg), fmt_num(*y_deg)) + } + } + LensOp::Matrix { m } => format!( + "matrix({},{},{},{},{},{})", + fmt_num(m[0]), + fmt_num(m[1]), + fmt_num(m[2]), + fmt_num(m[3]), + fmt_num(m[4]), + fmt_num(m[5]) + ), + }) + .collect(); + push_attr(out, "ops", &parts.join(" ")); + } + } + if let Some(fill) = &node.fill { + push_attr(out, "fill", fill); + } + + let is_text = matches!(node.payload, Payload::Text { .. }); + if is_text { + let content = match &node.payload { + Payload::Text { content, .. } => content.clone(), + _ => unreachable!(), + }; + let escaped = content + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + let _ = writeln!(out, ">{escaped}"); + } else if node.children.is_empty() { + let _ = writeln!(out, "/>"); + } else { + let _ = writeln!(out, ">"); + for c in &node.children { + print_node(doc, *c, depth + 1, out); + } + let _ = writeln!(out, "{indent}"); + } +} diff --git a/model-v2/a/lab/tests/arena_pick.rs b/model-v2/a/lab/tests/arena_pick.rs new file mode 100644 index 0000000000..e197bee1ef --- /dev/null +++ b/model-v2/a/lab/tests/arena_pick.rs @@ -0,0 +1,216 @@ +//! Part-1 spike surface: the node arena's structural APIs, the delete op, +//! and hit-testing (`pick`) over the resolved SOA tier. + +use anchor_lab::model::*; +use anchor_lab::ops; +use anchor_lab::pick::pick; +use anchor_lab::resolve::{resolve, ResolveOptions}; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 800.0), + ..Default::default() + } +} + +fn card(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +fn at(mut h: Header, x: f32, y: f32) -> Header { + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(y); + h +} + +fn flex_row(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + gap_main: 10.0, + padding: EdgeInsets::all(10.0), + cross_align: CrossAlign::Center, + ..Default::default() + }, + clips_content: false, + }, + ) +} + +// ---- arena structural APIs ------------------------------------------- + +#[test] +fn parent_links_survive_structural_ops() { + let mut b = DocBuilder::new(); + let (fh, fp) = flex_row(460.0, 170.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(10.0); + gh.y = AxisBinding::start(10.0); + let g = b.add(0, gh, Payload::Group); + let (ch, cp) = card(40.0, 40.0); + let a = b.add(g, ch, cp); + let mut doc = b.build(); + + assert_eq!(doc.parent_of(f), Some(0)); + assert_eq!(doc.parent_of(a), Some(g)); + + // ungroup re-homes the child's parent link to the group's parent + let r = resolve(&doc, &opts()); + ops::ungroup(&mut doc, &r, g).unwrap(); + assert_eq!(doc.parent_of(a), Some(0)); + assert!(doc.get_opt(g).is_none()); +} + +#[test] +fn delete_removes_subtree_and_reflows() { + let mut b = DocBuilder::new(); + let (fh, fp) = flex_row(460.0, 170.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut ids = vec![]; + for _ in 0..3 { + let (ch, cp) = card(60.0, 100.0); + ids.push(b.add(f, ch, cp)); + } + let mut doc = b.build(); + let r0 = resolve(&doc, &opts()); + let slot1_x = r0.box_of(ids[1]).x; + + let n = ops::delete(&mut doc, ids[1]).unwrap(); + assert_eq!(n, 1); + assert!(doc.get_opt(ids[1]).is_none()); + + // the row reflows: c takes the deleted slot + let r1 = resolve(&doc, &opts()); + assert_eq!(r1.box_of(ids[2]).x, slot1_x); + + // the root is a wall + let root = doc.root; + assert_eq!(ops::delete(&mut doc, root), Err(ops::OpError::WrongKind)); + // and double-delete is typed, not a panic + assert_eq!(ops::delete(&mut doc, ids[1]), Err(ops::OpError::WrongKind)); +} + +#[test] +fn delete_is_subtree_deep() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(100.0); + gh.y = AxisBinding::start(100.0); + let g = b.add(0, gh, Payload::Group); + let (c1h, c1p) = card(40.0, 40.0); + let a = b.add(g, c1h, c1p); + let (mut c2h, c2p) = card(40.0, 40.0); + c2h.x = AxisBinding::start(60.0); + let s = b.add(g, c2h, c2p); + let mut doc = b.build(); + + let n = ops::delete(&mut doc, g).unwrap(); + assert_eq!(n, 3, "group + both members"); + for id in [g, a, s] { + assert!(doc.get_opt(id).is_none()); + } +} + +// ---- pick -------------------------------------------------------------- + +#[test] +fn pick_is_oriented_not_aabb() { + let mut b = DocBuilder::new(); + let (mut ch, cp) = card(120.0, 24.0); + ch = at(ch, 200.0, 200.0); + ch.rotation = 45.0; + let id = b.add(0, ch, cp); + let doc = b.build(); + let r = resolve(&doc, &opts()); + + // box center always hits + let (cx, cy) = (260.0, 212.0); + assert_eq!(pick(&doc, &r, cx, cy), Some(id)); + // a point inside the AABB but OUTSIDE the oriented box must miss + // (thin 45deg bar: the AABB corner region is empty space) + let aabb = r.aabb_of(id); + let corner = (aabb.x + 4.0, aabb.y + 4.0); + assert_eq!(pick(&doc, &r, corner.0, corner.1), Some(doc.root)); +} + +#[test] +fn pick_topmost_wins() { + let mut b = DocBuilder::new(); + let (c1, p1) = card(100.0, 100.0); + let under = b.add(0, at(c1, 100.0, 100.0), p1); + let (c2, p2) = card(100.0, 100.0); + let over = b.add(0, at(c2, 150.0, 150.0), p2); + let doc = b.build(); + let r = resolve(&doc, &opts()); + // overlap region: later sibling paints on top and wins the pick + assert_eq!(pick(&doc, &r, 175.0, 175.0), Some(over)); + // non-overlapped part of the lower one still hits it + assert_eq!(pick(&doc, &r, 110.0, 110.0), Some(under)); +} + +#[test] +fn pick_promotes_to_outermost_group() { + let mut b = DocBuilder::new(); + let mut outer_h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + outer_h.x = AxisBinding::start(100.0); + outer_h.y = AxisBinding::start(100.0); + let outer = b.add(0, outer_h, Payload::Group); + let inner_h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + let inner = b.add(outer, inner_h, Payload::Group); + let (ch, cp) = card(50.0, 50.0); + let leaf = b.add(inner, ch, cp); + let doc = b.build(); + let r = resolve(&doc, &opts()); + let _ = (inner, leaf); + // clicking the leaf's ink selects the OUTERMOST group + assert_eq!(pick(&doc, &r, 120.0, 120.0), Some(outer)); +} + +#[test] +fn pick_hits_lens_content_post_ops() { + let mut b = DocBuilder::new(); + let mut lh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + lh.x = AxisBinding::start(300.0); + lh.y = AxisBinding::start(300.0); + let l = b.add( + 0, + lh, + Payload::Lens { + ops: vec![LensOp::Translate { x: 200.0, y: 0.0 }], + }, + ); + let (ch, cp) = card(60.0, 60.0); + b.add(l, ch, cp); + let doc = b.build(); + let r = resolve(&doc, &opts()); + + // the PRE-ops position is empty space (ops moved the ink away)... + assert_eq!(pick(&doc, &r, 330.0, 330.0), Some(doc.root)); + // ...the POST-ops position hits, and promotes to the lens + assert_eq!(pick(&doc, &r, 530.0, 330.0), Some(l)); +} + +#[test] +fn pick_hairline_line_is_grabbable() { + let mut b = DocBuilder::new(); + let mut lh = Header::new(SizeIntent::Fixed(200.0), SizeIntent::Fixed(0.0)); + lh = at(lh, 100.0, 400.0); + let line = b.add( + 0, + lh, + Payload::Shape { + desc: ShapeDesc::Line, + }, + ); + let doc = b.build(); + let r = resolve(&doc, &opts()); + assert_eq!(pick(&doc, &r, 180.0, 401.5), Some(line)); +} diff --git a/model-v2/a/lab/tests/common/mod.rs b/model-v2/a/lab/tests/common/mod.rs new file mode 100644 index 0000000000..a3a4ead377 --- /dev/null +++ b/model-v2/a/lab/tests/common/mod.rs @@ -0,0 +1,81 @@ +//! Shared helpers for the conformance-derived suites. + +use anchor_lab::math::RectF; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, Resolved, ResolveOptions, RotationInFlow}; + +pub const EPS: f32 = 1e-3; // N-3 within-platform tolerance for the lab + +pub fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::AabbParticipates, + } +} + +pub fn opts_visual() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +pub fn run(doc: &Document) -> Resolved { + resolve(doc, &opts()) +} + +pub fn assert_close(a: f32, b: f32, msg: &str) { + assert!( + (a - b).abs() < EPS, + "{msg}: {a} vs {b} (Δ={})", + (a - b).abs() + ); +} + +pub fn assert_rect(r: RectF, x: f32, y: f32, w: f32, h: f32, msg: &str) { + assert_close(r.x, x, &format!("{msg}.x")); + assert_close(r.y, y, &format!("{msg}.y")); + assert_close(r.w, w, &format!("{msg}.w")); + assert_close(r.h, h, &format!("{msg}.h")); +} + +pub fn shape(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +pub fn frame_free(w: SizeIntent, h: SizeIntent) -> (Header, Payload) { + ( + Header::new(w, h), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ) +} + +pub fn frame_flex( + w: SizeIntent, + h: SizeIntent, + direction: Direction, + gap: f32, + padding: f32, +) -> (Header, Payload) { + ( + Header::new(w, h), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction, + gap_main: gap, + padding: EdgeInsets::all(padding), + ..Default::default() + }, + clips_content: false, + }, + ) +} diff --git a/model-v2/a/lab/tests/derived.rs b/model-v2/a/lab/tests/derived.rs new file mode 100644 index 0000000000..67a5f38129 --- /dev/null +++ b/model-v2/a/lab/tests/derived.rs @@ -0,0 +1,166 @@ +//! D-* — derived boxes (groups, lens): the P6 instability fixes. + +mod common; +use common::*; + +use anchor_lab::model::*; +use anchor_lab::resolve::resolve; + +fn rotated_group_doc() -> (Document, NodeId, NodeId, NodeId) { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(100.0); + gh.y = AxisBinding::start(50.0); + gh.rotation = 30.0; + let g = b.add(0, gh, Payload::Group); + let (s1h, s1p) = shape(40.0, 40.0); + let s1 = b.add(g, s1h, s1p); + let (mut s2h, s2p) = shape(40.0, 40.0); + s2h.x = AxisBinding::start(56.0); + let s2 = b.add(g, s2h, s2p); + (b.build(), g, s1, s2) +} + +/// D-1: group bounds = union of children's *oriented* corners. +#[test] +fn d1_union_of_oriented_corners() { + let mut b = DocBuilder::new(); + let g = b.add(0, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (mut s1h, s1p) = shape(100.0, 20.0); + s1h.rotation = 90.0; // oriented AABB: 20×100 about center (50,10) + let s1 = b.add(g, s1h, s1p); + let doc = b.build(); + let r = run(&doc); + let _ = s1; + // rotated about center (50,10): AABB x 40..60, y −40..60 + let u = r.box_of(g); + assert_close(u.w, 20.0, "union w from oriented corners"); + assert_close(u.h, 100.0, "union h from oriented corners"); +} + +/// D-2: editing child A of a rotated group does not move child B in world +/// space — the P6 instability test. +#[test] +fn d2_sibling_stability_under_rotated_group() { + let (doc, _, s1, s2) = rotated_group_doc(); + let r1 = run(&doc); + let s2_world_before = r1.world_of(s2); + let s2_corner_before = s2_world_before.apply((0.0, 0.0)); + + // edit child A: move it (grows the union leftward/upward) + let mut doc2 = doc.clone(); + doc2.get_mut(s1).header.x = AxisBinding::start(-30.0); + doc2.get_mut(s1).header.y = AxisBinding::start(-10.0); + let r2 = run(&doc2); + let s2_corner_after = r2.world_of(s2).apply((0.0, 0.0)); + + assert_close(s2_corner_before.0, s2_corner_after.0, "sibling B fixed x"); + assert_close(s2_corner_before.1, s2_corner_after.1, "sibling B fixed y"); +} + +/// D-3: child edits never require a write to the group node. +#[test] +fn d3_child_edit_writes_nothing_to_group() { + let (doc, g, s1, _) = rotated_group_doc(); + let mut doc2 = doc.clone(); + doc2.get_mut(s1).header.x = AxisBinding::start(-30.0); + // group node bytes identical + assert_eq!(doc.get(g), doc2.get(g), "group untouched by child edit"); +} + +/// D-6 (declared): a group participates in parent flex via its derived +/// AABB (rotated per the E1 flag). +#[test] +fn d6_group_in_flex_participates_via_derived_aabb() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(200.0), + Direction::Row, + 10.0, + 0.0, + ); + let f = b.add(0, h, p); + let (s0h, s0p) = shape(50.0, 50.0); + let s0 = b.add(f, s0h, s0p); + let g = b.add(f, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (g1h, g1p) = shape(30.0, 30.0); + b.add(g, g1h, g1p); + let (mut g2h, g2p) = shape(30.0, 30.0); + g2h.x = AxisBinding::start(40.0); + b.add(g, g2h, g2p); + let (s3h, s3p) = shape(50.0, 50.0); + let s3 = b.add(f, s3h, s3p); + let doc = b.build(); + let r = run(&doc); + let _ = s0; + // group union = 70×30 → next sibling at 50+10+70+10 + assert_close(r.box_of(s3).x, 140.0, "sibling after group slot"); + assert_close(r.box_of(g).w, 70.0, "derived extent"); +} + +/// D-E1 (declared): empty group → zero bounds, resolves, renders nothing. +#[test] +fn de1_empty_group_zero_bounds() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(10.0); + let g = b.add(0, gh, Payload::Group); + let doc = b.build(); + let r = run(&doc); + assert_rect(r.box_of(g), 10.0, 0.0, 0.0, 0.0, "empty union at placement"); +} + +/// Lens: layout-transparent quarantine — ops affect paint (world of +/// children), never the box the parent lays out (a.md §3.3). +#[test] +fn lens_ops_are_layout_transparent() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(200.0), + Direction::Row, + 10.0, + 0.0, + ); + let f = b.add(0, h, p); + let (s0h, s0p) = shape(50.0, 50.0); + b.add(f, s0h, s0p); + let lens = b.add( + f, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { + ops: vec![LensOp::Skew { + x_deg: 20.0, + y_deg: 0.0, + }], + }, + ); + let (ch, cp) = shape(240.0, 150.0); + let c = b.add(lens, ch, cp); + let (s3h, s3p) = shape(50.0, 50.0); + let s3 = b.add(f, s3h, s3p); + let doc = b.build(); + let r = run(&doc); + + // parent flex sees the pre-ops box (240 wide), not the skewed envelope + assert_close(r.box_of(lens).w, 240.0, "pre-ops box participates"); + assert_close(r.box_of(s3).x, 50.0 + 10.0 + 240.0 + 10.0, "sibling slot"); + // …but the child's world transform carries the skew + let w = r.world_of(c); + assert!((w.c - (20.0f32).to_radians().tan()).abs() < 1e-4, "skew in world"); + // and the render bounds (world AABB) are wider than the box + assert!(r.aabb_of(lens).w > 240.0, "post-ops render bounds"); +} + +/// Both E1 modes agree on everything outside flow rotation (guard: the flag +/// touches exactly one semantic). +#[test] +fn flag_scope_is_limited_to_flow_rotation() { + let (doc, g, s1, s2) = rotated_group_doc(); + let ra = resolve(&doc, &opts()); + let rv = resolve(&doc, &opts_visual()); + for id in [g, s1, s2] { + assert_eq!(ra.world_of(id), rv.world_of(id), "free tree identical"); + } +} diff --git a/model-v2/a/lab/tests/edge_census.rs b/model-v2/a/lab/tests/edge_census.rs new file mode 100644 index 0000000000..ee512c8dab --- /dev/null +++ b/model-v2/a/lab/tests/edge_census.rs @@ -0,0 +1,531 @@ +//! Edge census — the ship-readiness sweep across implemented kinds +//! (text, group, lens, rotation extremes, degenerate boxes). +//! Every test either proves an edge or would have become a finding. + +mod common; +use common::*; + +use anchor_lab::math::Affine; +use anchor_lab::model::*; + +// ---------- text edges ---------- + +/// Empty text: zero width, one line-height tall — never a NaN, never 0×0. +#[test] +fn text_empty_string() { + let mut b = DocBuilder::new(); + let t = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: String::new(), + font_size: 16.0, + }, + ); + let r = run(&b.build()); + let bx = r.box_of(t); + assert_close(bx.w, 0.0, "empty text w"); + assert_close(bx.h, 19.2, "empty text keeps line height"); +} + +/// A single word wider than the fixed width: no mid-word break — the box +/// keeps the fixed width, content overflows in paint (declared). +#[test] +fn text_giant_word_no_break() { + let mut b = DocBuilder::new(); + let t = b.add( + 0, + Header::new(SizeIntent::Fixed(30.0), SizeIntent::Auto), + Payload::Text { + content: "unbreakable".into(), // 11 chars × 6 = 66 > 30 + font_size: 10.0, + }, + ); + let r = run(&b.build()); + let bx = r.box_of(t); + assert_close(bx.w, 30.0, "fixed width kept"); + assert_close(bx.h, 12.0, "single line despite overflow"); +} + +/// Fixed height smaller than content: the box is the intent; overflow is +/// a paint concern (T-E2 positioning math is renderer work). +#[test] +fn text_fixed_height_clips_not_grows() { + let mut b = DocBuilder::new(); + let t = b.add( + 0, + Header::new(SizeIntent::Fixed(36.0), SizeIntent::Fixed(10.0)), + Payload::Text { + content: "hello world".into(), // wraps to 2 lines at 36 + font_size: 10.0, + }, + ); + let r = run(&b.build()); + assert_close(r.box_of(t).h, 10.0, "declared height wins the box"); +} + +/// Text as the only child of a 3-deep hug chain: measurement propagates +/// bottom-up through every hug without a cycle. +#[test] +fn text_hug_chain_3_deep() { + let mut b = DocBuilder::new(); + let mut parent = 0; + let mut frames = vec![]; + for _ in 0..3 { + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Auto, + Direction::Row, + 0.0, + 5.0, + ); + parent = b.add(parent, h, p); + frames.push(parent); + } + let t = b.add( + parent, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: "hi".into(), + font_size: 10.0, + }, + ); + let r = run(&b.build()); + assert_close(r.box_of(t).w, 12.0, "text natural"); + // each hug adds 10 (padding 5×2) + assert_close(r.box_of(frames[2]).w, 22.0, "inner hug"); + assert_close(r.box_of(frames[1]).w, 32.0, "middle hug"); + assert_close(r.box_of(frames[0]).w, 42.0, "outer hug"); +} + +/// min clamps beat the measured size (measured kind × constraint). +#[test] +fn text_min_width_beats_measured() { + let mut b = DocBuilder::new(); + let mut h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + h.min_width = Some(100.0); + let t = b.add( + 0, + h, + Payload::Text { + content: "hi".into(), // natural 12 + font_size: 10.0, + }, + ); + let r = run(&b.build()); + assert_close(r.box_of(t).w, 100.0, "min wins"); +} + +// ---------- group edges ---------- + +/// Empty group in flex: zero-extent slot; siblings separated by gaps only. +#[test] +fn empty_group_in_flex() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(100.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, h, p); + let (h1, p1) = shape(50.0, 50.0); + b.add(f, h1, p1); + b.add(f, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (h2, p2) = shape(50.0, 50.0); + let c = b.add(f, h2, p2); + let r = run(&b.build()); + // 10 + 50 + 10 + 0 + 10 = 80 + assert_close(r.box_of(c).x, 80.0, "zero slot + both gaps"); +} + +/// Group whose only child is hidden ≡ empty group (union skips inactive). +#[test] +fn group_of_hidden_children_is_empty() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(40.0); + let g = b.add(0, gh, Payload::Group); + let (mut h1, p1) = shape(50.0, 50.0); + h1.active = false; + b.add(g, h1, p1); + let r = run(&b.build()); + assert_rect(r.box_of(g), 40.0, 0.0, 0.0, 0.0, "empty union at origin"); +} + +/// Nested groups 3 deep, each rotated: composition stays rigid and +/// editing the innermost child never moves the outer sibling (D-E2). +#[test] +fn nested_groups_3_deep_rotated() { + let build = |inner_x: f32| { + let mut b = DocBuilder::new(); + let mut g1h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + g1h.x = AxisBinding::start(100.0); + g1h.rotation = 10.0; + let g1 = b.add(0, g1h, Payload::Group); + let (sh, sp) = shape(30.0, 30.0); // outer sibling + let s = b.add(g1, sh, sp); + let mut g2h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + g2h.x = AxisBinding::start(50.0); + g2h.rotation = 15.0; + let g2 = b.add(g1, g2h, Payload::Group); + let mut g3h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + g3h.rotation = 20.0; + let g3 = b.add(g2, g3h, Payload::Group); + let (mut ch, cp) = shape(20.0, 20.0); + ch.x = AxisBinding::start(inner_x); + let c = b.add(g3, ch, cp); + (b.build(), s, c) + }; + let (d1, s1, c1) = build(0.0); + let (d2, s2, _) = build(-25.0); // move the innermost child + let r1 = run(&d1); + let r2 = run(&d2); + // outer sibling world-stable under inner edit (origin placement at depth) + let p1 = r1.world_of(s1).apply((0.0, 0.0)); + let p2 = r2.world_of(s2).apply((0.0, 0.0)); + assert_close(p1.0, p2.0, "sibling stable x at depth"); + assert_close(p1.1, p2.1, "sibling stable y at depth"); + // and rotations compose (10+15+20 = 45 on the innermost child) + let w = r1.world_of(c1); + let ang = w.b.atan2(w.a).to_degrees(); + assert_close(ang, 45.0, "rotations compose at depth"); +} + +/// Union of rotated members uses oriented corners at every level. +#[test] +fn group_union_of_rotated_members() { + let mut b = DocBuilder::new(); + let g = b.add(0, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (mut h1, p1) = shape(40.0, 40.0); + h1.rotation = 45.0; // envelope 56.57 about center (20,20) + b.add(g, h1, p1); + let r = run(&b.build()); + let u = r.box_of(g); + let d = 40.0 * (2.0f32).sqrt(); + assert_close(u.w, d, "oriented union w"); + assert_close(u.h, d, "oriented union h"); +} + +// ---------- lens edges ---------- + +/// Lens-in-lens: ops compose outer∘inner (nesting = composition, declared). +#[test] +fn lens_in_lens_composes() { + let mut b = DocBuilder::new(); + let l1 = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { + ops: vec![LensOp::Translate { x: 10.0, y: 0.0 }], + }, + ); + let l2 = b.add( + l1, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { + ops: vec![LensOp::Translate { x: 0.0, y: 5.0 }], + }, + ); + let (sh, sp) = shape(20.0, 20.0); + let s = b.add(l2, sh, sp); + let r = run(&b.build()); + let p = r.world_of(s).apply((0.0, 0.0)); + assert_close(p.0, 10.0, "outer op applied"); + assert_close(p.1, 5.0, "inner op applied"); +} + +/// Ordered ops: rotate∘scale ≠ scale∘rotate — the list order is honored. +#[test] +fn lens_ops_order_matters() { + let world_of = |ops: Vec| { + let mut b = DocBuilder::new(); + let l = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { ops }, + ); + let (sh, sp) = shape(20.0, 10.0); + let s = b.add(l, sh, sp); + let r = run(&b.build()); + r.world_of(s) + }; + let a = world_of(vec![ + LensOp::Rotate { deg: 90.0 }, + LensOp::Scale { x: 2.0, y: 1.0 }, + ]); + let bm = world_of(vec![ + LensOp::Scale { x: 2.0, y: 1.0 }, + LensOp::Rotate { deg: 90.0 }, + ]); + assert!( + (a.a - bm.a).abs() > 1e-3 || (a.b - bm.b).abs() > 1e-3 || (a.e - bm.e).abs() > 1e-3, + "op order is semantic" + ); +} + +/// Matrix op with negative determinant: a flip smuggled through the +/// quarantine renders as given (the lens is the anything-goes tier). +#[test] +fn lens_matrix_negative_determinant() { + let mut b = DocBuilder::new(); + let l = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { + ops: vec![LensOp::Matrix { + m: [-1.0, 0.0, 0.0, 1.0, 40.0, 0.0], // mirror-x then shift + }], + }, + ); + let (sh, sp) = shape(20.0, 10.0); + let s = b.add(l, sh, sp); + let r = run(&b.build()); + let w = r.world_of(s); + let det = w.a * w.d - w.b * w.c; + assert!(det < 0.0, "negative determinant preserved"); +} + +/// Scale(0) collapses paint but never poisons layout: the pre-ops box +/// still participates; world AABB degenerates to a point, finitely. +#[test] +fn lens_scale_zero_degenerate() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(100.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, h, p); + let l = b.add( + f, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { + ops: vec![LensOp::Scale { x: 0.0, y: 0.0 }], + }, + ); + let (sh, sp) = shape(50.0, 50.0); + b.add(l, sh, sp); + let (h2, p2) = shape(50.0, 50.0); + let c = b.add(f, h2, p2); + let r = run(&b.build()); + assert_close(r.box_of(c).x, 70.0, "layout uses pre-ops box (10+50+10)"); + let aabb = r.aabb_of(l); + assert!(aabb.w.is_finite() && aabb.w < 1e-3, "paint collapsed, finite"); +} + +// ---------- rotation extremes ---------- + +/// Winding is authored intent: rotation=450 resolves like 90° but the +/// document keeps 450 (R-E2's stored-as-authored half). +#[test] +fn rotation_winding_stored_as_authored() { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(100.0, 50.0); + h.rotation = 450.0; + let s = b.add(0, h, p); + let doc = b.build(); + let r = run(&doc); + let w90 = Affine::rotate_deg(90.0); + let w = r.world_of(s); + assert_close(w.a, w90.a, "resolves as 90°"); + assert_close(w.b, w90.b, "resolves as 90°"); + assert_eq!(doc.get(s).header.rotation, 450.0, "document keeps 450"); +} + +/// Full-bleed Span child rotated: the box stays edge-bound; the corners +/// swing outside the parent (declared: clipping is paint, never geometry). +#[test] +fn span_full_bleed_rotated_overflows() { + let mut b = DocBuilder::new(); + let (mut fh, fp) = frame_free(SizeIntent::Fixed(400.0), SizeIntent::Fixed(300.0)); + fh.x = AxisBinding::start(0.0); + let f = b.add(0, fh, fp); + let mut h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + h.x = AxisBinding::Span { start: 0.0, end: 0.0 }; + h.y = AxisBinding::Span { start: 0.0, end: 0.0 }; + h.rotation = 30.0; + let s = b.add( + f, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let r = run(&b.build()); + assert_rect(r.box_of(s), 0.0, 0.0, 400.0, 300.0, "span box unbent by rotation"); + let aabb = r.aabb_of(s); + assert!(aabb.w > 400.0 && aabb.h > 300.0, "corners swing outside"); +} + +/// Accumulation: 120 × 3° successive writes vs one 360° — same resolved +/// geometry within the platform tolerance (R-E4). +#[test] +fn rotation_accumulation_bounded() { + let mut b = DocBuilder::new(); + let (h, p) = shape(100.0, 50.0); + let s = b.add(0, h, p); + let mut doc = b.build(); + for i in 1..=120 { + anchor_lab::ops::set_rotation(&mut doc, s, i as f32 * 3.0).unwrap(); + } + let r = run(&doc); + let w = r.world_of(s); + // 360°: bit-clean identity rotation per R-E1 quadrant rule + assert_close(w.a, 1.0, "a"); + assert_close(w.b, 0.0, "b"); + // set-rotation is absolute, not incremental — no accumulation by design + assert_eq!(doc.get(s).header.rotation, 360.0); +} + +/// aspect_ratio resolves the axis, THEN rotation wraps the result — +/// the two features compose without ordering ambiguity. +#[test] +fn aspect_ratio_then_rotation() { + let mut b = DocBuilder::new(); + let mut h = Header::new(SizeIntent::Fixed(160.0), SizeIntent::Auto); + h.aspect_ratio = Some((16.0, 9.0)); + h.rotation = 90.0; + let s = b.add( + 0, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let r = run(&b.build()); + let bx = r.box_of(s); + assert_close(bx.w, 160.0, "aspect resolved w"); + assert_close(bx.h, 90.0, "aspect resolved h"); + let aabb = r.aabb_of(s); + assert_close(aabb.w, 90.0, "then envelope swaps"); + assert_close(aabb.h, 160.0, "then envelope swaps"); +} + +/// Far-canvas: rotation at 1e6 offsets keeps the center exact within +/// f32 tolerance at that scale (N-1 smoke). +#[test] +fn far_canvas_rotation_center() { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::start(1.0e6); + h.y = AxisBinding::start(1.0e6); + h.rotation = 33.0; + let s = b.add(0, h, p); + let r = run(&b.build()); + let c = r.world_of(s).apply((60.0, 40.0)); + // f32 ULP at 1e6 is 0.0625 — allow a scaled tolerance + assert!((c.0 - 1_000_060.0).abs() < 0.25, "center x at far canvas: {}", c.0); + assert!((c.1 - 1_000_040.0).abs() < 0.25, "center y at far canvas: {}", c.1); +} + +/// Census blocker fix: Span{0,0} (the canonical free-context fill) is a +/// wrap constraint — fill text re-wraps like Fixed/stretched widths. +#[test] +fn span_fill_text_rewraps() { + let mut b = DocBuilder::new(); + let (mut fh, fp) = frame_free(SizeIntent::Fixed(100.0), SizeIntent::Fixed(200.0)); + fh.x = AxisBinding::start(0.0); + let f = b.add(0, fh, fp); + let mut th = Header::new(SizeIntent::Auto, SizeIntent::Auto); + th.x = AxisBinding::Span { start: 0.0, end: 0.0 }; + let t = b.add( + f, + th, + Payload::Text { + content: "aaaa bbbb cccc dddd".into(), // natural 114 @fs10 + font_size: 10.0, + }, + ); + let r = run(&b.build()); + let bx = r.box_of(t); + assert_close(bx.w, 100.0, "span fill width"); + // wraps at 100 (16 chars max): "aaaa bbbb cccc"(14) / "dddd" → 2 lines + assert_close(bx.h, 24.0, "fill text re-wraps"); +} + +/// Census fix: nested derived unions keep the inner union offset — a +/// group-in-group's content lands where the coordinates say (D-E2 depth). +#[test] +fn nested_group_union_offset_exact() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(100.0); + gh.y = AxisBinding::start(50.0); + let g = b.add(0, gh, Payload::Group); + let h = b.add(g, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (mut sh, sp) = shape(40.0, 40.0); + sh.x = AxisBinding::start(20.0); + let s = b.add(h, sh, sp); + let r = run(&b.build()); + let p = r.world_of(s).apply((0.0, 0.0)); + assert_close(p.0, 120.0, "inner offset survives nesting"); + assert_close(p.1, 50.0, "y exact"); +} + +/// Census fix: ungroup with a NESTED group child preserves world +/// transforms (D-4 for derived children — origin bake, not center bake). +#[test] +fn ungroup_nested_group_preserves_world() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(100.0); + gh.y = AxisBinding::start(50.0); + gh.rotation = 30.0; + let g = b.add(0, gh, Payload::Group); + let h = b.add(g, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (mut s1h, s1p) = shape(40.0, 40.0); + s1h.x = AxisBinding::start(20.0); + let s1 = b.add(h, s1h, s1p); + let (mut s2h, s2p) = shape(40.0, 40.0); + s2h.x = AxisBinding::start(80.0); + let s2 = b.add(h, s2h, s2p); + let mut doc = b.build(); + let before = run(&doc); + let w1 = before.world_of(s1); + let w2 = before.world_of(s2); + anchor_lab::ops::ungroup(&mut doc, &before, g).unwrap(); + let after = run(&doc); + for (id, w) in [(s1, w1), (s2, w2)] { + let v = after.world_of(id); + for (a, e) in [(v.a,w.a),(v.b,w.b),(v.c,w.c),(v.d,w.d),(v.e,w.e),(v.f,w.f)] { + assert!((a - e).abs() < 1e-3, "nested bake exact: {a} vs {e}"); + } + } +} + +/// Census fix: a hug frame wraps a rotated GROUP by its true origin-pivot +/// envelope, not a center-concentric approximation. +#[test] +fn hug_wraps_rotated_group_exactly() { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_free(SizeIntent::Auto, SizeIntent::Auto); + let f = b.add(0, fh, fp); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.rotation = 90.0; + let g = b.add(f, gh, Payload::Group); + let (sh, sp) = shape(100.0, 20.0); + b.add(g, sh, sp); + let doc = b.build(); + let r = run(&doc); + // origin pivot at (0,0): the 100×20 bar rotates into x∈[−20,0], y∈[0,100] + // → hug covers max extents (clamped at 0 origin): w = 0, h = 100… + // declared: hug covers positive extents; the frame is 0-wide? No — + // the AABB max_x = 0 → w=0 is degenerate but honest; assert h. + assert_close(r.box_of(f).h, 100.0, "hug height = true envelope"); + assert!(r.box_of(f).w < 1.0, "no phantom width from the wrong pivot"); +} + +/// R-E3: −0.0 canonicalizes at the write boundary. +#[test] +fn negative_zero_rotation_canonicalized() { + let mut b = DocBuilder::new(); + let (h, p) = shape(50.0, 50.0); + let s = b.add(0, h, p); + let mut doc = b.build(); + anchor_lab::ops::set_rotation(&mut doc, s, -0.0).unwrap(); + assert!(doc.get(s).header.rotation.is_sign_positive(), "−0.0 never stored"); +} diff --git a/model-v2/a/lab/tests/escape.rs b/model-v2/a/lab/tests/escape.rs new file mode 100644 index 0000000000..bb50279960 --- /dev/null +++ b/model-v2/a/lab/tests/escape.rs @@ -0,0 +1,124 @@ +//! The escape hatches from layout-visible rotation (owner question, +//! 2026-07-07): can a user opt OUT of "AABB participates" per node? +//! +//! Three structural escapes, no mode flag: +//! 1. lens-rotate → paint-only rotation; slot = unrotated box +//! (the CSS-semantics twin, exactly) +//! 2. flow="absolute" → out of flow entirely +//! 3. wrapper frame → custom slot reservation (incl. the +//! diagonal-stable wrapper: zero breathing at any θ) + +mod common; +use common::*; + +use anchor_lab::model::*; +use anchor_lab::resolve::resolve; + +fn flex_row_of_cards() -> DocBuilder { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(140.0), + Direction::Row, + 10.0, + 10.0, + ); + b.add(0, h, p); + b +} + +/// Escape 1: a lens with a Rotate op reproduces the visual-only arm +/// EXACTLY — unrotated box holds the slot, rotation is paint-only. +#[test] +fn lens_rotate_is_the_visual_only_twin() { + // Document A: header rotation, resolved under the CSS control flag. + let mut ba = flex_row_of_cards(); + let f_a = 1; + let mut cards_a = vec![]; + for _ in 0..3 { + let (h, p) = shape(60.0, 100.0); + cards_a.push(ba.add(f_a, h, p)); + } + let mut doc_a = ba.build(); + doc_a.get_mut(cards_a[1]).header.rotation = 90.0; + let ra = resolve(&doc_a, &opts_visual()); + + // Document B: lens-wrapped middle card, resolved under the LOCKED + // default (AABB participates). The lens is layout-transparent, so + // its pre-ops (unrotated) box takes the slot. + let mut bb = flex_row_of_cards(); + let f_b = 1; + let (h0, p0) = shape(60.0, 100.0); + let c0 = bb.add(f_b, h0, p0); + let lens = bb.add( + f_b, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { + ops: vec![LensOp::Rotate { deg: 90.0 }], + }, + ); + let (h1, p1) = shape(60.0, 100.0); + let inner = bb.add(lens, h1, p1); + let (h2, p2) = shape(60.0, 100.0); + let c2 = bb.add(f_b, h2, p2); + let doc_b = bb.build(); + let rb = resolve(&doc_b, &opts()); // default: AabbParticipates + + // identical layout: container width, sibling slots + assert_close(ra.box_of(f_a).w, 220.0, "control container"); + assert_close(rb.box_of(f_b).w, 220.0, "lens escape container"); + assert_close( + ra.box_of(cards_a[2]).x, + rb.box_of(c2).x, + "third card position identical", + ); + // identical paint: the rotated card's world AABB matches + let aabb_a = ra.aabb_of(cards_a[1]); + let aabb_b = rb.aabb_of(inner); + assert_close(aabb_a.w, aabb_b.w, "painted envelope w"); + assert_close(aabb_a.h, aabb_b.h, "painted envelope h"); + assert_close(aabb_a.x, aabb_b.x, "painted envelope x"); + // and the overlap the user asked for is present in both + let ov = rb.aabb_of(c0).intersection_area(&rb.aabb_of(inner)); + assert_close(ov, 600.0, "paint-only overlap, opted into deliberately"); +} + +/// Escape 3: a fixed wrapper frame owns the slot; the rotated child +/// paints beyond it. Sized to the diagonal, the slot is θ-stable — +/// zero container breathing at ANY angle (the animation-friendly form). +#[test] +fn diagonal_wrapper_gives_theta_stable_slot() { + let diag = (60.0f32 * 60.0 + 100.0 * 100.0).sqrt(); // 116.62 + let mut widths = vec![]; + for theta in [0.0f32, 30.0, 59.0, 90.0, 137.0] { + let mut b = flex_row_of_cards(); + let f = 1; + let (h0, p0) = shape(60.0, 100.0); + b.add(f, h0, p0); + // wrapper: fixed diagonal-sized free frame, no layout, no clip + let wrap = b.add( + f, + Header::new(SizeIntent::Fixed(diag), SizeIntent::Fixed(diag)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let (mut hc, pc) = shape(60.0, 100.0); + hc.x = AxisBinding::center(0.0); + hc.y = AxisBinding::center(0.0); + hc.rotation = theta; + b.add(wrap, hc, pc); + let (h2, p2) = shape(60.0, 100.0); + b.add(f, h2, p2); + let doc = b.build(); + let r = resolve(&doc, &opts()); + widths.push(r.box_of(f).w); + } + // container width identical at every angle: the wrapper absorbed + // the envelope; nothing breathes, nothing overlaps siblings + for w in &widths { + assert_close(*w, widths[0], "θ-stable container"); + } + assert_close(widths[0], 10.0 + 60.0 + 10.0 + 116.61903 + 10.0 + 60.0 + 10.0, "diagonal slot"); +} diff --git a/model-v2/a/lab/tests/flip.rs b/model-v2/a/lab/tests/flip.rs new file mode 100644 index 0000000000..2a8ee31d6f --- /dev/null +++ b/model-v2/a/lab/tests/flip.rs @@ -0,0 +1,345 @@ +//! Flip conformance — E-A2 semantics + B1 pivot rule + the cross-zero +//! resize gesture (owner-raised red flag, 2026-07-07). +//! +//! Laws under test: +//! - F-1: flip on boxed kinds is center-applied → AABB-invariant → +//! layout-invisible (the one transform that never pops layout). +//! - F-2: flip composes innermost (T·R·F): a flipped node's local equals +//! the unflipped local pre-composed with the center mirror. +//! - F-3 (B1): flip pivot per kind follows rotation — origin for derived; +//! D-2 sibling stability holds under a flipped group. +//! - F-4: cross-zero drag re-targets (|extent| + flip toggle + re-pin), +//! tracks the gesture, and is an identity when dragged out and back. +//! - F-5: the typed write stays a wall — set_width(−x) = NegativeExtent, +//! document untouched (M-6). +//! - F-6: ungroup bakes flips exactly (σ conjugation): world transforms +//! of children are preserved. + +use anchor_lab::math::Affine; +use anchor_lab::model::*; +use anchor_lab::ops; +use anchor_lab::ops::{Axis, OpError, ResizeDrag}; +use anchor_lab::resolve::{resolve, ResolveOptions}; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 800.0), + ..Default::default() + } +} + +fn approx(a: f32, b: f32, eps: f32) -> bool { + (a - b).abs() <= eps +} + +fn affine_approx(a: &Affine, b: &Affine, eps: f32) -> bool { + approx(a.a, b.a, eps) + && approx(a.b, b.b, eps) + && approx(a.c, b.c, eps) + && approx(a.d, b.d, eps) + && approx(a.e, b.e, eps) + && approx(a.f, b.f, eps) +} + +fn card(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +fn flex_row(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + gap_main: 10.0, + padding: EdgeInsets::all(10.0), + cross_align: CrossAlign::Center, + ..Default::default() + }, + clips_content: false, + }, + ) +} + +fn at(mut h: Header, x: f32, y: f32) -> Header { + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(y); + h +} + +// --- F-1: layout invisibility ------------------------------------------ + +#[test] +fn f1_flip_is_layout_invisible_in_flow() { + let build = |flip: bool| { + let mut b = DocBuilder::new(); + let (fh, fp) = flex_row(460.0, 170.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut ids = vec![]; + for i in 0..3 { + let (mut ch, cp) = card(60.0, 100.0); + if i == 1 && flip { + ch.flip_x = true; + ch.flip_y = true; + } + ids.push(b.add(f, ch, cp)); + } + (b.build(), ids) + }; + let (d0, ids0) = build(false); + let (d1, ids1) = build(true); + let r0 = resolve(&d0, &opts()); + let r1 = resolve(&d1, &opts()); + for (a, b) in ids0.iter().zip(ids1.iter()) { + // Boxes identical: flip is center-applied, the AABB cannot change. + assert_eq!(r0.box_of(*a), r1.box_of(*b)); + let wa = r0.aabb_of(*a); + let wb = r1.aabb_of(*b); + assert!(approx(wa.x, wb.x, 1e-4) && approx(wa.w, wb.w, 1e-4)); + } + // ...and the flipped node's world really mirrors (det < 0 is only + // possible with an even/odd flip count — here x·y = det positive again, + // so check the axes swapped sign instead). + let w = r1.world_of(ids1[1]); + assert!(w.a < 0.0 && w.d < 0.0, "both axes mirrored: {w:?}"); +} + +// --- F-2: composition order -------------------------------------------- + +#[test] +fn f2_flip_composes_innermost_about_center() { + let (w, h, theta) = (100.0f32, 60.0f32, 30.0f32); + let base = Affine::from_box_center(10.0, 20.0, w, h, theta); + let flipped = Affine::from_box_center_flip(10.0, 20.0, w, h, theta, true, false); + // T·R·F_c means: flipped(p) == base(mirror_c(p)). + for p in [(0.0, 0.0), (w, 0.0), (30.0, 45.0), (w / 2.0, h / 2.0)] { + let mirrored = (w - p.0, p.1); + let lhs = flipped.apply(p); + let rhs = base.apply(mirrored); + assert!( + approx(lhs.0, rhs.0, 1e-3) && approx(lhs.1, rhs.1, 1e-3), + "p={p:?} lhs={lhs:?} rhs={rhs:?}" + ); + } + // Center is a fixed point of the mirror. + let c = flipped.apply((w / 2.0, h / 2.0)); + assert!(approx(c.0, 60.0, 1e-3) && approx(c.1, 50.0, 1e-3)); + // Single-axis mirror ⇒ negative determinant. + let det = flipped.a * flipped.d - flipped.b * flipped.c; + assert!(det < 0.0); +} + +// --- F-3: B1 pivot rule + D-2 under flip -------------------------------- + +#[test] +fn f3_d2_sibling_stability_under_flipped_group() { + let build = |ax: f32| { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(200.0); + gh.y = AxisBinding::start(100.0); + gh.flip_x = true; + gh.rotation = 15.0; + let g = b.add(0, gh, Payload::Group); + let (c1h, c1p) = card(50.0, 50.0); + let a = b.add(g, at(c1h, ax, 0.0), c1p); + let (c2h, c2p) = card(40.0, 40.0); + let s = b.add(g, at(c2h, 120.0, 80.0), c2p); + (b.build(), a, s) + }; + // Move child A (its stored offset changes); sibling S must not move in + // world space — origin pivot means the union never feeds back (E-A1), + // and B1 gives flip the same pivot. + let (d0, _a0, s0) = build(0.0); + let (d1, _a1, s1) = build(-60.0); + let r0 = resolve(&d0, &opts()); + let r1 = resolve(&d1, &opts()); + assert!( + affine_approx(&r0.world_of(s0), &r1.world_of(s1), 1e-4), + "sibling moved under flipped group: {:?} vs {:?}", + r0.world_of(s0), + r1.world_of(s1) + ); +} + +// --- F-4: the cross-zero gesture ---------------------------------------- + +/// Free frame + one rect; returns (doc, rect id). +fn free_rect() -> (Document, NodeId) { + let mut b = DocBuilder::new(); + let (ch, cp) = card(140.0, 80.0); + let id = b.add(0, at(ch, 60.0, 40.0), cp); + (b.build(), id) +} + +#[test] +fn f4_cross_zero_drag_flips_and_tracks_anchor() { + let (mut doc, id) = free_rect(); + let r = resolve(&doc, &opts()); + // Left edge fixed at 60; user drags the right handle. + let drag = ResizeDrag::begin(&doc, &r, id, Axis::X, AnchorEdge::Start).unwrap(); + assert_eq!(drag.anchor, 60.0); + + // Still on the base side: plain resize, no flip. + let n = ops::resize_drag(&mut doc, &r, id, &drag, 260.0).unwrap(); + assert_eq!(n, 2); // extent + position (x unchanged numerically but re-written) + let r = resolve(&doc, &opts()); + assert_eq!(r.xywh(id), (60.0, 40.0, 200.0, 80.0)); + assert!(!doc.get(id).header.flip_x); + + // Cross the anchor: the box mirrors and keeps tracking the hand. + let n = ops::resize_drag(&mut doc, &r, id, &drag, -20.0).unwrap(); + assert_eq!(n, 3); // extent + position + flip toggle + let r = resolve(&doc, &opts()); + assert_eq!(r.xywh(id), (-20.0, 40.0, 80.0, 80.0)); + assert!(doc.get(id).header.flip_x); + // The fixed edge is still at 60 — now the box's max edge. + let b = r.box_of(id); + assert_eq!(b.x + b.w, 60.0); + // Content is genuinely mirrored: negative determinant. + let w = r.world_of(id); + assert!(w.a * w.d - w.b * w.c < 0.0); +} + +#[test] +fn f4_cross_zero_round_trip_is_identity() { + let (mut doc, id) = free_rect(); + let original = doc.clone(); + let r0 = resolve(&doc, &opts()); + let drag = ResizeDrag::begin(&doc, &r0, id, Axis::X, AnchorEdge::Start).unwrap(); + + // Out past zero... + let r = resolve(&doc, &opts()); + ops::resize_drag(&mut doc, &r, id, &drag, -80.0).unwrap(); + // ...through a second move on the far side... + let r = resolve(&doc, &opts()); + ops::resize_drag(&mut doc, &r, id, &drag, -140.0).unwrap(); + // ...and back to the exact starting handle position (60 + 140). + let r = resolve(&doc, &opts()); + ops::resize_drag(&mut doc, &r, id, &drag, 200.0).unwrap(); + + assert_eq!(doc, original, "drag out and back must be the identity"); +} + +#[test] +fn f4_zero_width_moment_is_legal_and_unflipped() { + let (mut doc, id) = free_rect(); + let r = resolve(&doc, &opts()); + let drag = ResizeDrag::begin(&doc, &r, id, Axis::X, AnchorEdge::Start).unwrap(); + // Target exactly at the anchor: extent 0, no flip, no error (N-2 allows + // zero extents; the sign only appears strictly past the edge). + ops::resize_drag(&mut doc, &r, id, &drag, 60.0).unwrap(); + let r = resolve(&doc, &opts()); + assert_eq!(r.xywh(id).2, 0.0); + assert!(!doc.get(id).header.flip_x); +} + +#[test] +fn f4_end_edge_drag_crosses_negative_side() { + let (mut doc, id) = free_rect(); + let r = resolve(&doc, &opts()); + // Right edge fixed at 200; user drags the LEFT handle rightward past it. + let drag = ResizeDrag::begin(&doc, &r, id, Axis::X, AnchorEdge::End).unwrap(); + assert_eq!(drag.anchor, 200.0); + let n = ops::resize_drag(&mut doc, &r, id, &drag, 250.0).unwrap(); + assert_eq!(n, 3); + let r = resolve(&doc, &opts()); + assert_eq!(r.xywh(id), (200.0, 40.0, 50.0, 80.0)); + assert!(doc.get(id).header.flip_x); +} + +#[test] +fn f4_in_flow_drag_writes_extent_and_flip_only() { + let mut b = DocBuilder::new(); + let (fh, fp) = flex_row(460.0, 170.0); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut mid = 0; + for i in 0..3 { + let (ch, cp) = card(60.0, 100.0); + let id = b.add(f, ch, cp); + if i == 1 { + mid = id; + } + } + let mut doc = b.build(); + let r = resolve(&doc, &opts()); + let b0 = r.box_of(mid); + let drag = ResizeDrag::begin(&doc, &r, mid, Axis::X, AnchorEdge::Start).unwrap(); + // Cross past the anchor: under flex the position write is skipped — + // layout owns it — so the set shrinks to extent + flip. + let n = ops::resize_drag(&mut doc, &r, mid, &drag, b0.x - 30.0).unwrap(); + assert_eq!(n, 2); + assert!(doc.get(mid).header.flip_x); + assert_eq!(doc.get(mid).header.x, AxisBinding::default()); + // And the row re-lays out around the new 30px mirrored box. + let r = resolve(&doc, &opts()); + assert_eq!(r.xywh(mid).2, 30.0); +} + +// --- F-5: the typed wall ------------------------------------------------ + +#[test] +fn f5_typed_negative_size_is_rejected_untouched() { + let (mut doc, id) = free_rect(); + let before = doc.clone(); + assert_eq!(ops::set_width(&mut doc, id, -50.0), Err(OpError::NegativeExtent)); + assert_eq!(ops::set_height(&mut doc, id, -0.5), Err(OpError::NegativeExtent)); + assert_eq!(doc, before, "rejected op must leave the document byte-identical"); +} + +// --- F-6: ungroup bakes flips exactly ------------------------------------ + +#[test] +fn f6_ungroup_flipped_group_preserves_world() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(180.0); + gh.y = AxisBinding::start(120.0); + gh.rotation = 25.0; + gh.flip_x = true; + let g = b.add(0, gh, Payload::Group); + // A rotated boxed child and a flipped boxed child — both bake paths. + let (mut c1h, c1p) = card(70.0, 40.0); + c1h.rotation = 10.0; + let a = b.add(g, at(c1h, 10.0, 5.0), c1p); + let (mut c2h, c2p) = card(50.0, 50.0); + c2h.flip_y = true; + let s = b.add(g, at(c2h, 90.0, 60.0), c2p); + let mut doc = b.build(); + + let r0 = resolve(&doc, &opts()); + let wa0 = r0.world_of(a); + let ws0 = r0.world_of(s); + + ops::ungroup(&mut doc, &r0, g).unwrap(); + let r1 = resolve(&doc, &opts()); + assert!( + affine_approx(&r1.world_of(a), &wa0, 1e-3), + "rotated child drifted: {:?} vs {wa0:?}", + r1.world_of(a) + ); + assert!( + affine_approx(&r1.world_of(s), &ws0, 1e-3), + "flipped child drifted: {:?} vs {ws0:?}", + r1.world_of(s) + ); +} + +// --- IR round trip -------------------------------------------------------- + +#[test] +fn flip_survives_text_ir_round_trip() { + let (mut doc, id) = free_rect(); + doc.get_mut(id).header.flip_x = true; + doc.get_mut(id).header.rotation = 12.0; + let printed = anchor_lab::textir::print(&doc); + assert!(printed.contains("flip-x=\"true\"")); + let parsed = anchor_lab::textir::parse(&printed).unwrap(); + let reprinted = anchor_lab::textir::print(&parsed); + assert_eq!(printed, reprinted); +} diff --git a/model-v2/a/lab/tests/geometry.rs b/model-v2/a/lab/tests/geometry.rs new file mode 100644 index 0000000000..25d0a7b768 --- /dev/null +++ b/model-v2/a/lab/tests/geometry.rs @@ -0,0 +1,168 @@ +//! G-* — geometry resolution across the binding/size vocabulary. + +mod common; +use common::*; + +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, Report}; + +fn one_child(parent_w: f32, parent_h: f32, h: Header, p: Payload) -> (Document, NodeId) { + let mut b = DocBuilder::new(); + let (mut fh, fp) = frame_free(SizeIntent::Fixed(parent_w), SizeIntent::Fixed(parent_h)); + fh.x = AxisBinding::start(0.0); + fh.y = AxisBinding::start(0.0); + let f = b.add(0, fh, fp); + let c = b.add(f, h, p); + (b.build(), c) +} + +/// G-1: the §2.1 resolution table, exact arithmetic. +#[test] +fn g1_binding_table_exact() { + // Pin{Start, 10} → x0 = 10 + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::start(10.0); + let (doc, c) = one_child(400.0, 300.0, h, p.clone()); + assert_close(run(&doc).box_of(c).x, 10.0, "start"); + + // Pin{End, 24} → x0 = 400 − 24 − 120 = 256 + let (mut h, _) = shape(120.0, 80.0); + h.x = AxisBinding::end(24.0); + let (doc, c) = one_child(400.0, 300.0, h, p.clone()); + assert_close(run(&doc).box_of(c).x, 256.0, "end"); + + // Pin{Center, 6} → x0 = (400 − 120)/2 + 6 = 146 + let (mut h, _) = shape(120.0, 80.0); + h.x = AxisBinding::center(6.0); + let (doc, c) = one_child(400.0, 300.0, h, p.clone()); + assert_close(run(&doc).box_of(c).x, 146.0, "center"); + + // Span{30, 50} → x0 = 30, w = 400 − 30 − 50 = 320 (SizeIntent ignored) + let (mut h, _) = shape(120.0, 80.0); + h.x = AxisBinding::Span { + start: 30.0, + end: 50.0, + }; + let (doc, c) = one_child(400.0, 300.0, h, p); + let b = run(&doc).box_of(c); + assert_close(b.x, 30.0, "span x"); + assert_close(b.w, 320.0, "span extent owns axis"); +} + +/// G-2: end-anchored intent survives parent resize — "right: 24" stays 24. +#[test] +fn g2_end_intent_survives_parent_resize() { + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::end(24.0); + let (mut doc, c) = one_child(400.0, 300.0, h, p); + let r1 = run(&doc); + assert_close(r1.box_of(c).x + 120.0 + 24.0, 400.0, "gap=24 @400"); + + // parent grows: same document field, re-resolved + let f = doc.parent_of(c).unwrap(); + doc.get_mut(f).header.width = SizeIntent::Fixed(640.0); + let r2 = run(&doc); + assert_close(r2.box_of(c).x + 120.0 + 24.0, 640.0, "gap=24 @640"); +} + +/// G-5: aspect-ratio resolves the under-specified axis only. +#[test] +fn g5_aspect_resolves_underspecified_axis() { + let mut h = Header::new(SizeIntent::Fixed(160.0), SizeIntent::Auto); + h.aspect_ratio = Some((16.0, 9.0)); + let (doc, c) = one_child( + 400.0, + 300.0, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let r = run(&doc); + assert_close(r.box_of(c).h, 90.0, "h from 16:9"); + // and no error-by-rule fired for the auto axis + assert!( + !r.reports.iter().any(|rep| matches!( + rep, + Report::ErrorByRule { node, .. } if *node == c + )), + "aspect resolved the axis" + ); +} + +/// G-E1: zero-size parent — end/center anchors resolve, no NaN anywhere. +#[test] +fn ge1_zero_size_parent_no_nan() { + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::end(24.0); + h.y = AxisBinding::center(0.0); + let (doc, c) = one_child(0.0, 0.0, h, p); + let b = run(&doc).box_of(c); + assert!(b.x.is_finite() && b.y.is_finite()); + assert_close(b.x, -144.0, "end vs zero parent"); // 0 − 24 − 120 + assert_close(b.y, -40.0, "center vs zero parent"); // (0−80)/2 +} + +/// G-E2: span offsets exceeding the parent — declared clamp-to-zero, +/// reported, never a negative-width box downstream. +#[test] +fn ge2_negative_span_clamps_reported() { + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::Span { + start: 300.0, + end: 200.0, + }; + let (doc, c) = one_child(400.0, 300.0, h, p); + let r = run(&doc); + assert_close(r.box_of(c).w, 0.0, "clamped to zero"); + assert!( + r.reports + .iter() + .any(|rep| matches!(rep, Report::Clamped { node, .. } if *node == c)), + "clamp reported, not silent" + ); +} + +/// G-E3: Auto size on a kind with no natural size — declared error-by-rule, +/// never a silent 0-vs-unset ambiguity. +#[test] +fn ge3_auto_on_shape_is_error_by_rule() { + let h = Header::new(SizeIntent::Auto, SizeIntent::Fixed(80.0)); + let (doc, c) = one_child( + 400.0, + 300.0, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let r = run(&doc); + assert!( + r.reports.iter().any(|rep| matches!( + rep, + Report::ErrorByRule { node, field: "width", .. } if *node == c + )), + "typed error surfaced" + ); +} + +/// G-4: min/max clamp applied after size resolution; min beats max. +#[test] +fn g4_min_max_clamp_declared() { + let (mut h, p) = shape(120.0, 80.0); + h.min_width = Some(200.0); + h.max_width = Some(150.0); + let (doc, c) = one_child(400.0, 300.0, h, p); + // min wins over max (declared G-4 rule) + assert_close(run(&doc).box_of(c).w, 200.0, "min beats max"); +} + +/// Root regularization (X-SELF-5 break): the scene root is an ordinary +/// frame; Span{0,0} bindings make it exactly the viewport. +#[test] +fn root_is_viewport_bound_frame() { + let b = DocBuilder::new(); + let doc = b.build(); + let r = resolve(&doc, &opts()); + assert_rect(r.box_of(doc.root), 0.0, 0.0, 1000.0, 1000.0, "root=viewport"); +} diff --git a/model-v2/a/lab/tests/layout.rs b/model-v2/a/lab/tests/layout.rs new file mode 100644 index 0000000000..c38efe7ccd --- /dev/null +++ b/model-v2/a/lab/tests/layout.rs @@ -0,0 +1,307 @@ +//! L-* / T-* — flex behavior, hug, measured content, the §7 quartet (c). + +mod common; +use common::*; + +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, Report}; + +/// L-3: hug container with mixed fixed/grow children — no cycle; grow +/// distributes only definite free space (of which a hug container has none). +#[test] +fn l3_hug_with_grow_no_cycle() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(100.0), + Direction::Row, + 0.0, + 0.0, + ); + let f = b.add(0, h, p); + let (h1, p1) = shape(50.0, 50.0); + let a = b.add(f, h1, p1); + let (mut h2, p2) = shape(70.0, 50.0); + h2.grow = 1.0; + let g = b.add(f, h2, p2); + let doc = b.build(); + let r = run(&doc); + // hug = sum of basis sizes; grow had no free space to distribute + assert_close(r.box_of(f).w, 120.0, "hug from basis sizes"); + assert_close(r.box_of(a).w, 50.0, "fixed child"); + assert_close(r.box_of(g).w, 70.0, "grow child stays at basis under hug"); +} + +/// grow distributes definite free space (fixed container). +#[test] +fn grow_distributes_definite_free_space() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Fixed(300.0), + SizeIntent::Fixed(100.0), + Direction::Row, + 0.0, + 0.0, + ); + let f = b.add(0, h, p); + let (h1, p1) = shape(50.0, 50.0); + b.add(f, h1, p1); + let (mut h2, p2) = shape(70.0, 50.0); + h2.grow = 1.0; + let g = b.add(f, h2, p2); + let doc = b.build(); + let r = run(&doc); + assert_close(r.box_of(g).w, 250.0, "70 + (300−120) free"); +} + +/// L-4: absolute child inside a flex parent — excluded from flow, +/// resolves against the parent box. +#[test] +fn l4_absolute_child_in_flex() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Fixed(300.0), + SizeIntent::Fixed(100.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, h, p); + let (h1, p1) = shape(50.0, 50.0); + let a = b.add(f, h1, p1); + let (mut h2, p2) = shape(40.0, 40.0); + h2.flow = Flow::Absolute; + h2.x = AxisBinding::end(8.0); + h2.y = AxisBinding::start(4.0); + let abs = b.add(f, h2, p2); + let (h3, p3) = shape(50.0, 50.0); + let c = b.add(f, h3, p3); + let doc = b.build(); + let r = run(&doc); + // flow children ignore the absolute one + assert_close(r.box_of(a).x, 10.0, "first flow child"); + assert_close(r.box_of(c).x, 70.0, "second flow child (no slot for abs)"); + // absolute resolves against the parent box + assert_close(r.box_of(abs).x, 300.0 - 8.0 - 40.0, "end-pinned in parent"); + assert_close(r.box_of(abs).y, 4.0, "start-pinned in parent"); +} + +/// L-5 / §7(c): measured child re-measures at the layout-imposed width — +/// text stretched by cross-align re-wraps. +#[test] +fn l5_text_rewraps_at_stretched_width() { + let mut b = DocBuilder::new(); + let (h, p) = { + let (mut h, p) = frame_flex( + SizeIntent::Fixed(100.0), + SizeIntent::Auto, + Direction::Column, + 8.0, + 0.0, + ); + if let Payload::Frame { layout, .. } = &p { + let mut l = *layout; + l.cross_align = CrossAlign::Stretch; + ( + h.clone(), + Payload::Frame { + layout: l, + clips_content: false, + }, + ) + } else { + unreachable!() + } + }; + let f = b.add(0, h, p); + // 10 chars/word world: "aaaa bbbb cccc" @10 → cw 6 + let th = Header::new(SizeIntent::Auto, SizeIntent::Auto); + let t = b.add( + f, + th, + Payload::Text { + content: "aaaa bbbb cccc".into(), + font_size: 10.0, + }, + ); + let doc = b.build(); + let r = run(&doc); + // stretched to container width 100 → 16 chars max → "aaaa bbbb cccc" is + // 14 chars → fits one line? 14*6=84 ≤ 100 → 1 line, h=12 + assert_close(r.box_of(t).w, 100.0, "stretched to container"); + assert_close(r.box_of(t).h, 12.0, "single line at 100"); + + // narrower container → re-wrap + let mut doc2 = doc.clone(); + doc2.get_mut(f).header.width = SizeIntent::Fixed(60.0); + let r2 = run(&doc2); + // 60/6 = 10 chars: "aaaa bbbb"(9) / "cccc" → 2 lines + assert_close(r2.box_of(t).h, 24.0, "re-wrapped at 60"); +} + +/// T-1: fixed-width text wraps; auto-width text measures single-line. +#[test] +fn t1_text_measurement_modes() { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_free(SizeIntent::Fixed(500.0), SizeIntent::Fixed(500.0)); + let f = b.add(0, fh, fp); + + let auto_h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + let t_auto = b.add( + f, + auto_h, + Payload::Text { + content: "hello world".into(), + font_size: 10.0, + }, + ); + let fixed_h = Header::new(SizeIntent::Fixed(36.0), SizeIntent::Auto); + let t_fixed = b.add( + f, + fixed_h, + Payload::Text { + content: "hello world".into(), + font_size: 10.0, + }, + ); + let doc = b.build(); + let r = run(&doc); + assert_close(r.box_of(t_auto).w, 66.0, "11 chars × 6"); + assert_close(r.box_of(t_auto).h, 12.0, "one line"); + assert_close(r.box_of(t_fixed).w, 36.0, "fixed width kept"); + assert_close(r.box_of(t_fixed).h, 24.0, "wrapped to two lines"); +} + +/// L-E1: empty container with padding + hug → size = padding box. +#[test] +fn le1_empty_hug_is_padding_box() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Auto, + Direction::Row, + 12.0, + 16.0, + ); + let f = b.add(0, h, p); + let doc = b.build(); + let r = run(&doc); + assert_rect(r.box_of(f), 0.0, 0.0, 32.0, 32.0, "padding box"); +} + +/// L-E3/L-E4: overflow — children exceed the fixed parent; no implicit +/// shrink (X-SELF-8 keep: flex_shrink = 0), overflow geometry exact. +#[test] +fn le3_no_implicit_shrink_on_overflow() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Fixed(100.0), + SizeIntent::Fixed(60.0), + Direction::Row, + 0.0, + 0.0, + ); + let f = b.add(0, h, p); + let mut ids = vec![]; + for _ in 0..3 { + let (h1, p1) = shape(50.0, 50.0); + ids.push(b.add(f, h1, p1)); + } + let doc = b.build(); + let r = run(&doc); + for (i, id) in ids.iter().enumerate() { + assert_close(r.box_of(*id).w, 50.0, "authored size kept"); + assert_close(r.box_of(*id).x, 50.0 * i as f32, "exact overflow geometry"); + } +} + +/// §8 matrix enforcement: x/y writes are ignored-by-rule under flow and the +/// resolver *reports* it (never silent). +#[test] +fn applicability_x_ignored_under_flow_reported() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Fixed(300.0), + SizeIntent::Fixed(100.0), + Direction::Row, + 0.0, + 0.0, + ); + let f = b.add(0, h, p); + let (mut h1, p1) = shape(50.0, 50.0); + h1.x = AxisBinding::start(77.0); // will be ignored: layout owns position + let a = b.add(f, h1, p1); + let doc = b.build(); + let r = run(&doc); + assert_close(r.box_of(a).x, 0.0, "layout owns x"); + assert!( + r.reports.iter().any(|rep| matches!( + rep, + Report::IgnoredByRule { node, field: "x/y", .. } if *node == a + )), + "ignored-by-rule reported" + ); +} + +/// Nested flex: row-in-column, fixed sizes, exact positions. +#[test] +fn nested_flex_exact() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Fixed(300.0), + SizeIntent::Auto, + Direction::Column, + 10.0, + 0.0, + ); + let outer = b.add(0, h, p); + let (h2, p2) = frame_flex( + SizeIntent::Auto, + SizeIntent::Auto, + Direction::Row, + 5.0, + 0.0, + ); + let inner = b.add(outer, h2, p2); + let (s1h, s1p) = shape(40.0, 30.0); + let s1 = b.add(inner, s1h, s1p); + let (s2h, s2p) = shape(40.0, 30.0); + let s2 = b.add(inner, s2h, s2p); + let (s3h, s3p) = shape(100.0, 20.0); + let s3 = b.add(outer, s3h, s3p); + let doc = b.build(); + let r = run(&doc); + assert_rect(r.box_of(inner), 0.0, 0.0, 85.0, 30.0, "inner hug 40+5+40"); + assert_close(r.box_of(s1).x, 0.0, "s1"); + assert_close(r.box_of(s2).x, 45.0, "s2"); + assert_close(r.box_of(s3).y, 40.0, "s3 after inner + gap"); + assert_close(r.box_of(outer).h, 60.0, "outer hug 30+10+20"); +} + +/// MM-8 analogue is N/A (no camera in the lab); viewport-bound root reacts, +/// free content doesn't — covered in mm_laws. This test pins the L-7 POL: +/// resolution is unquantized (no pixel snapping in the resolved tier). +#[test] +fn l7_resolution_unquantized() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Fixed(100.0), + SizeIntent::Fixed(30.0), + Direction::Row, + 0.0, + 0.0, + ); + let f = b.add(0, h, p); + for _ in 0..3 { + let (mut h1, p1) = shape(10.0, 10.0); + h1.grow = 1.0; + b.add(f, h1, p1); + } + let doc = b.build(); + let r = run(&doc); + let kids: Vec<_> = doc.get(f).children.clone(); + // 100 − 30 = 70 free / 3 = 23.333… — fractional, unsnapped + let w = r.box_of(kids[0]).w; + assert!((w - 33.333332).abs() < 1e-3, "fractional width kept: {w}"); + let _ = resolve(&doc, &opts_visual()); // both modes agree here +} diff --git a/model-v2/a/lab/tests/mm_laws.rs b/model-v2/a/lab/tests/mm_laws.rs new file mode 100644 index 0000000000..3ae8da0bfc --- /dev/null +++ b/model-v2/a/lab/tests/mm_laws.rs @@ -0,0 +1,223 @@ +//! Metamorphic laws (conformance.md §1) — properties over pairs of documents. + +mod common; +use common::*; + +use anchor_lab::model::*; +use anchor_lab::ops; +use anchor_lab::resolve::resolve; + +fn sample_doc() -> (Document, NodeId, NodeId, NodeId) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_free(SizeIntent::Fixed(400.0), SizeIntent::Fixed(300.0)); + let mut fh = fh; + fh.x = AxisBinding::start(50.0); + fh.y = AxisBinding::start(60.0); + let f = b.add(0, fh, fp); + let (mut sh, sp) = shape(120.0, 80.0); + sh.x = AxisBinding::start(10.0); + sh.y = AxisBinding::start(20.0); + sh.rotation = 15.0; + let s = b.add(f, sh, sp); + let (mut th, tp) = shape(40.0, 40.0); + th.x = AxisBinding::end(24.0); + th.y = AxisBinding::center(0.0); + let t = b.add(f, th, tp); + (b.build(), f, s, t) +} + +/// MM-1: translating a parent translates every descendant world transform +/// by exactly (dx,dy) and nothing else. +#[test] +fn mm1_parent_translation_propagates_exactly() { + let (doc, f, s, t) = sample_doc(); + let r1 = run(&doc); + + let mut doc2 = doc.clone(); + let r0 = run(&doc2); + ops::move_by(&mut doc2, &r0, f, 7.0, -13.0).unwrap(); + let r2 = run(&doc2); + + for id in [f, s, t] { + let w1 = r1.world_of(id); + let w2 = r2.world_of(id); + assert_close(w2.e - w1.e, 7.0, "dx"); + assert_close(w2.f - w1.f, -13.0, "dy"); + assert_close(w1.a, w2.a, "a unchanged"); + assert_close(w1.b, w2.b, "b unchanged"); + assert_close(w1.c, w2.c, "c unchanged"); + assert_close(w1.d, w2.d, "d unchanged"); + } +} + +/// MM-2: rotate(θ) then rotate(−θ) restores identical resolved geometry. +#[test] +fn mm2_rotation_roundtrip_restores_geometry() { + let (doc, _, s, _) = sample_doc(); + let r1 = run(&doc); + + let mut doc2 = doc.clone(); + ops::set_rotation(&mut doc2, s, 15.0 + 33.0).unwrap(); + ops::set_rotation(&mut doc2, s, 15.0).unwrap(); + let r2 = run(&doc2); + + assert_eq!(doc, doc2, "document restored byte-for-byte"); + let (w1, w2) = (r1.world_of(s), r2.world_of(s)); + assert_eq!( + (w1.a, w1.b, w1.c, w1.d, w1.e, w1.f), + (w2.a, w2.b, w2.c, w2.d, w2.e, w2.f), + "resolved world identical" + ); +} + +/// MM-3: resolution is a pure function — identical output run-to-run. +#[test] +fn mm3_determinism() { + let (doc, _, s, t) = sample_doc(); + let r1 = run(&doc); + let r2 = run(&doc); + for id in [s, t] { + assert_eq!(r1.world_of(id), r2.world_of(id)); + assert_eq!(r1.box_of(id), r2.box_of(id)); + assert_eq!(r1.aabb_of(id), r2.aabb_of(id)); + } +} + +/// MM-4: a free node's geometry is viewport-independent (only the +/// viewport-bound root's subtree sizing paths may react). +#[test] +fn mm4_viewport_independence_of_fixed_free_nodes() { + let (doc, f, s, _) = sample_doc(); + let r1 = resolve(&doc, &opts()); + let mut o2 = opts(); + o2.viewport = (640.0, 480.0); + let r2 = resolve(&doc, &o2); + // f is Start-pinned with Fixed size: unaffected by viewport. + assert_eq!(r1.box_of(f), r2.box_of(f)); + assert_eq!(r1.world_of(s), r2.world_of(s)); +} + +/// MM-5: writes to independent fields commute. +#[test] +fn mm5_independent_writes_commute() { + let (doc, _, s, t) = sample_doc(); + + let mut d1 = doc.clone(); + let r = run(&d1); + ops::set_rotation(&mut d1, s, 45.0).unwrap(); + ops::set_width(&mut d1, t, 55.0).unwrap(); + let _ = r; + + let mut d2 = doc.clone(); + ops::set_width(&mut d2, t, 55.0).unwrap(); + ops::set_rotation(&mut d2, s, 45.0).unwrap(); + + assert_eq!(d1, d2, "A;B ≡ B;A on the document"); +} + +/// MM-6 (declared POL): hidden children do not affect siblings' geometry. +#[test] +fn mm6_hidden_child_does_not_move_siblings() { + let mut b = DocBuilder::new(); + let f = b.add( + 0, + frame_flex( + SizeIntent::Fixed(500.0), + SizeIntent::Fixed(100.0), + Direction::Row, + 10.0, + 0.0, + ) + .0, + frame_flex( + SizeIntent::Fixed(500.0), + SizeIntent::Fixed(100.0), + Direction::Row, + 10.0, + 0.0, + ) + .1, + ); + let (h1, p1) = shape(50.0, 50.0); + let a = b.add(f, h1, p1); + let (h2, p2) = shape(50.0, 50.0); + let hidden = b.add(f, h2, p2); + let (h3, p3) = shape(50.0, 50.0); + let c = b.add(f, h3, p3); + let doc_visible = b.build(); + + let mut doc_hidden = doc_visible.clone(); + doc_hidden.get_mut(hidden).header.active = false; + + let rv = run(&doc_visible); + let rh = run(&doc_hidden); + // with the middle child hidden, c moves to where the hidden one was + assert_close(rv.box_of(a).x, rh.box_of(a).x, "a stable"); + assert_close(rv.box_of(hidden).x, rh.box_of(c).x, "c takes the slot"); + assert!(rh.world_opt(hidden).is_none(), "hidden not resolved"); +} + +/// MM-7: adding then deleting a node restores prior resolved geometry. +#[test] +fn mm7_add_delete_restores() { + let (doc, f, s, t) = sample_doc(); + let r1 = run(&doc); + + let mut doc2 = doc.clone(); + let new_id = 999; // sparse id: the arena grows, and semantic + // equality ignores the tombstoned tail (MM-7). + doc2.add_child( + f, + Node { + id: new_id, + header: shape(10.0, 10.0).0, + payload: shape(10.0, 10.0).1, + children: vec![], + fill: None, + }, + ); + let _mid = run(&doc2); + doc2.remove_subtree(new_id); + assert_eq!(doc, doc2); + let r2 = run(&doc2); + for id in [s, t] { + assert_eq!(r1.world_of(id), r2.world_of(id)); + } +} + +/// MM-9 (via the sanctioned ungroup bake): composed local placement +/// preserves world transforms within tolerance (also D-4). +#[test] +fn mm9_ungroup_preserves_world_transforms() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(100.0); + gh.y = AxisBinding::start(50.0); + gh.rotation = 30.0; + let g = b.add(0, gh, Payload::Group); + let (mut s1h, s1p) = shape(40.0, 40.0); + s1h.rotation = 10.0; + let s1 = b.add(g, s1h, s1p); + let (mut s2h, s2p) = shape(40.0, 40.0); + s2h.x = AxisBinding::start(56.0); + let s2 = b.add(g, s2h, s2p); + let mut doc = b.build(); + + let before = run(&doc); + let w1 = before.world_of(s1); + let w2 = before.world_of(s2); + + anchor_lab::ops::ungroup(&mut doc, &before, g).unwrap(); + assert!(doc.get_opt(g).is_none(), "group dissolved"); + + let after = run(&doc); + for (id, w) in [(s1, w1), (s2, w2)] { + let v = after.world_of(id); + assert_close(v.a, w.a, "a"); + assert_close(v.b, w.b, "b"); + assert_close(v.c, w.c, "c"); + assert_close(v.d, w.d, "d"); + assert_close(v.e, w.e, "e"); + assert_close(v.f, w.f, "f"); + } +} diff --git a/model-v2/a/lab/tests/ops_suite.rs b/model-v2/a/lab/tests/ops_suite.rs new file mode 100644 index 0000000000..34aff3230f --- /dev/null +++ b/model-v2/a/lab/tests/ops_suite.rs @@ -0,0 +1,176 @@ +//! M-* / editor.md doctrine — typed errors, write counts, M-6 atomicity. + +mod common; +use common::*; + +use anchor_lab::model::*; +use anchor_lab::ops::{self, OpError}; + +fn free_shape_doc() -> (Document, NodeId) { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::start(10.0); + h.y = AxisBinding::start(20.0); + let s = b.add(0, h, p); + (b.build(), s) +} + +/// editor.md write-count doctrine: the gesture table is enforced. +#[test] +fn write_counts_match_doctrine() { + // rotate (boxed) = 1 + let (mut doc, s) = free_shape_doc(); + assert_eq!(ops::set_rotation(&mut doc, s, 30.0).unwrap(), 1); + + // move = 2 + let (mut doc, s) = free_shape_doc(); + let r = run(&doc); + assert_eq!(ops::move_by(&mut doc, &r, s, 5.0, 5.0).unwrap(), 2); + + // corner resize = 4 + let (mut doc, s) = free_shape_doc(); + let r = run(&doc); + assert_eq!( + ops::resize_top_left(&mut doc, &r, s, 0.0, 0.0, 150.0, 100.0).unwrap(), + 4 + ); + + // group center-feel rotate = 3 (asserted in rotation.rs too) +} + +/// Writes re-target intent: setting x on an End-pinned axis rewrites the +/// end offset so the resolved x equals the given value — the binding kind +/// (the user's intent) is preserved. +#[test] +fn set_x_retargets_end_pin() { + let mut b = DocBuilder::new(); + let (mut fh, fp) = frame_free(SizeIntent::Fixed(400.0), SizeIntent::Fixed(300.0)); + fh.x = AxisBinding::start(0.0); + let f = b.add(0, fh, fp); + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::end(24.0); // resolved x = 256 + let s = b.add(f, h, p); + let mut doc = b.build(); + let r = run(&doc); + + assert_eq!(ops::set_x(&mut doc, &r, s, 200.0).unwrap(), 1); + // intent preserved: + assert!(matches!( + doc.get(s).header.x, + AxisBinding::Pin { + anchor: AnchorEdge::End, + .. + } + )); + // and effective: + let r2 = run(&doc); + assert_close(r2.box_of(s).x, 200.0, "resolved x = requested"); + // offset now 400 − 200 − 120 = 80 + if let AxisBinding::Pin { offset, .. } = doc.get(s).header.x { + assert_close(offset, 80.0, "end offset rewritten"); + } +} + +/// Typed error: G-3 over-constraint is unrepresentable; the write that +/// would create it is rejected as AxisOwnedBySpan. +#[test] +fn set_width_on_spanned_axis_typed_error() { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::Span { + start: 10.0, + end: 20.0, + }; + let s = b.add(0, h, p); + let mut doc = b.build(); + let before = doc.clone(); + assert_eq!( + ops::set_width(&mut doc, s, 300.0), + Err(OpError::AxisOwnedBySpan) + ); + assert_eq!(doc, before, "M-6: rejected write leaves document identical"); +} + +/// Typed error: x-write on an in-flow child under flex (§6). +#[test] +fn set_x_under_flow_typed_error() { + let mut b = DocBuilder::new(); + let (h, p) = frame_flex( + SizeIntent::Fixed(300.0), + SizeIntent::Fixed(100.0), + Direction::Row, + 0.0, + 0.0, + ); + let f = b.add(0, h, p); + let (h1, p1) = shape(50.0, 50.0); + let a = b.add(f, h1, p1); + let mut doc = b.build(); + let r = run(&doc); + let before = doc.clone(); + assert_eq!( + ops::set_x(&mut doc, &r, a, 99.0), + Err(OpError::OwnedByLayout) + ); + assert_eq!(doc, before, "M-6"); +} + +/// R-E3 / N-2: NaN/Inf rejected at the write boundary with a typed error; +/// the document stays NaN-free by construction. +#[test] +fn nan_rejected_at_write_boundary() { + let (mut doc, s) = free_shape_doc(); + let before = doc.clone(); + assert_eq!( + ops::set_rotation(&mut doc, s, f32::NAN), + Err(OpError::InvalidNumber) + ); + assert_eq!( + ops::set_width(&mut doc, s, f32::INFINITY), + Err(OpError::InvalidNumber) + ); + assert_eq!(doc, before, "M-6"); +} + +/// Typed error: size writes on derived-box kinds. +#[test] +fn set_width_on_group_typed_error() { + let mut b = DocBuilder::new(); + let g = b.add(0, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (h1, p1) = shape(40.0, 40.0); + b.add(g, h1, p1); + let mut doc = b.build(); + assert_eq!(ops::set_width(&mut doc, g, 100.0), Err(OpError::BoxDerived)); +} + +/// C-1/C-2 shape (executable H3): move ∥ rotate and resize ∥ rotate touch +/// disjoint scalar fields — a field-level merge preserves both intents. +#[test] +fn concurrency_atoms_disjoint_fields() { + let (doc, s) = free_shape_doc(); + + // replica A: move + let mut da = doc.clone(); + let ra = run(&da); + ops::move_by(&mut da, &ra, s, 30.0, 0.0).unwrap(); + + // replica B: rotate + let mut db = doc.clone(); + ops::set_rotation(&mut db, s, 45.0).unwrap(); + + // field-level merge (x/y from A, rotation from B) + let mut merged = doc.clone(); + merged.get_mut(s).header.x = da.get(s).header.x; + merged.get_mut(s).header.y = da.get(s).header.y; + merged.get_mut(s).header.rotation = db.get(s).header.rotation; + + let rm = run(&merged); + let b = rm.box_of(s); + assert_close(b.x, 40.0, "move survived"); + assert_eq!(merged.get(s).header.rotation, 45.0, "rotation survived"); + // merged document is valid — resolution succeeds with no error reports + assert!(rm + .reports + .iter() + .all(|r| !matches!(r, anchor_lab::resolve::Report::ErrorByRule { .. }))); +} diff --git a/model-v2/a/lab/tests/rotation.rs b/model-v2/a/lab/tests/rotation.rs new file mode 100644 index 0000000000..e5990abefa --- /dev/null +++ b/model-v2/a/lab/tests/rotation.rs @@ -0,0 +1,212 @@ +//! R-* — rotation & transform semantics, including the E1 FORK row (R-3). + +mod common; +use common::*; + +use anchor_lab::math::{rotated_aabb_size, Affine}; +use anchor_lab::model::*; +use anchor_lab::resolve::resolve; + +/// R-1 (locked POL): rotating a boxed node preserves its box center. +#[test] +fn r1_center_pivot_preserves_center() { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::start(10.0); + h.y = AxisBinding::start(20.0); + let s = b.add(0, h, p); + let mut doc = b.build(); + + let r0 = run(&doc); + let c0 = r0.world_of(s).apply((60.0, 40.0)); + + for deg in [15.0, 90.0, 213.0, -45.0] { + doc.get_mut(s).header.rotation = deg; + let r = run(&doc); + let c = r.world_of(s).apply((60.0, 40.0)); + assert_close(c.0, c0.0, "center x invariant"); + assert_close(c.1, c0.1, "center y invariant"); + } +} + +/// R-2: world AABB is the exact |w·cosθ|+|h·sinθ| envelope. +#[test] +fn r2_world_aabb_is_exact_envelope() { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(120.0, 80.0); + h.rotation = 37.0; + let s = b.add(0, h, p); + let doc = b.build(); + let r = run(&doc); + let aabb = r.aabb_of(s); + let (we, he) = rotated_aabb_size(120.0, 80.0, 37.0); + assert_close(aabb.w, we, "aabb w"); + assert_close(aabb.h, he, "aabb h"); +} + +/// R-4: resize-then-rotate ≡ rotate-then-resize under the center pivot, +/// compared on the box (position + extent), which is what the document +/// stores. (World transforms differ by construction: the pivot moves with +/// the box center.) +#[test] +fn r4_rotation_resize_commute_on_box() { + let build = |order: bool| { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(120.0, 80.0); + h.x = AxisBinding::start(10.0); + h.y = AxisBinding::start(20.0); + let s = b.add(0, h, p); + let mut doc = b.build(); + if order { + doc.get_mut(s).header.rotation = 30.0; + doc.get_mut(s).header.width = SizeIntent::Fixed(200.0); + } else { + doc.get_mut(s).header.width = SizeIntent::Fixed(200.0); + doc.get_mut(s).header.rotation = 30.0; + } + (doc, s) + }; + let (d1, s1) = build(true); + let (d2, s2) = build(false); + assert_eq!(run(&d1).box_of(s1), run(&d2).box_of(s2)); + assert_eq!(run(&d1).world_of(s1), run(&d2).world_of(s2)); +} + +/// R-5: 50-deep rotated nesting matches closed-form composition within N-3. +#[test] +fn r5_deep_nesting_matches_closed_form() { + let mut b = DocBuilder::new(); + let mut parent = 0; + let deg = 3.0f32; + for _ in 0..50 { + let (mut h, p) = frame_free(SizeIntent::Fixed(200.0), SizeIntent::Fixed(200.0)); + h.x = AxisBinding::start(5.0); + h.y = AxisBinding::start(4.0); + h.rotation = deg; + parent = b.add(parent, h, p); + } + let doc = b.build(); + let r = run(&doc); + + // closed form: fold the same local transforms + let mut expect = Affine::IDENTITY; + for _ in 0..50 { + expect = expect.then(&Affine::from_box_center(5.0, 4.0, 200.0, 200.0, deg)); + } + let got = r.world_of(parent); + // 50 compositions: allow a looser (but still tight) bound + for (g, e) in [ + (got.a, expect.a), + (got.b, expect.b), + (got.c, expect.c), + (got.d, expect.d), + (got.e, expect.e), + (got.f, expect.f), + ] { + assert!((g - e).abs() < 1e-2, "closed form: {g} vs {e}"); + } +} + +/// R-3 — THE fork row, both arms, locked per mode (E1 executable form). +/// +/// Portrait cards (60×100) in a row; the middle one rotated 90° so its +/// AABB (100×60) is *wider* than its box — the case where the two +/// semantics visibly diverge. +#[test] +fn r3_rotation_in_flow_fork_both_arms() { + let mut b = DocBuilder::new(); + let f = { + let (h, p) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(140.0), + Direction::Row, + 10.0, + 10.0, + ); + b.add(0, h, p) + }; + let mut cards = vec![]; + for _ in 0..3 { + let (h, p) = shape(60.0, 100.0); + cards.push(b.add(f, h, p)); + } + let mut doc = b.build(); + doc.get_mut(cards[1]).header.rotation = 90.0; + + // anchor arm: middle slot = rotated AABB (100 wide); siblings make room + let ra = resolve(&doc, &opts()); + assert_close(ra.box_of(cards[2]).x, 190.0, "third card after AABB slot"); // 10+60+10+100+10 + assert_close(ra.box_of(f).w, 260.0, "hug = pad+60+gap+100+gap+60+pad"); + // box center := slot center (slot 80..180 → center 130; box 100..160… box w=60) + assert_close(ra.box_of(cards[1]).x, 100.0, "box centered in slot"); + let o = ra + .aabb_of(cards[0]) + .intersection_area(&ra.aabb_of(cards[1])); + assert_close(o, 0.0, "no overlap (anchor arm)"); + + // sheet arm (VisualOnly): layout unchanged by rotation → overlap is correct + let rv = resolve(&doc, &opts_visual()); + assert_close(rv.box_of(f).w, 220.0, "container ignores rotation"); + assert_close(rv.box_of(cards[2]).x, 150.0, "siblings don't move"); + let ov = rv + .aabb_of(cards[0]) + .intersection_area(&rv.aabb_of(cards[1])); + assert_close(ov, 600.0, "overlap exists and is exact (visual-only arm)"); +} + +/// R-E1: quadrant angles produce bit-clean matrices end-to-end. +#[test] +fn re1_quadrant_angles_bit_clean() { + let mut b = DocBuilder::new(); + let (mut h, p) = shape(100.0, 50.0); + h.rotation = 90.0; + let s = b.add(0, h, p); + let doc = b.build(); + let w = run(&doc).world_of(s); + assert_eq!(w.a, 0.0); + assert_eq!(w.b, 1.0); + assert_eq!(w.c, -1.0); + assert_eq!(w.d, 0.0); +} + +/// Derived-box pivot (§5): a group rotates about the point its bindings +/// place, and the center-feel gesture compensates in exactly 3 writes. +#[test] +fn group_origin_pivot_and_center_feel_gesture() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(100.0); + gh.y = AxisBinding::start(50.0); + let g = b.add(0, gh, Payload::Group); + let (s1h, s1p) = shape(40.0, 40.0); + b.add(g, s1h, s1p); + let (mut s2h, s2p) = shape(40.0, 40.0); + s2h.x = AxisBinding::start(56.0); + b.add(g, s2h, s2p); + let mut doc = b.build(); + + let r0 = run(&doc); + let b0 = r0.box_of(g); + assert_rect(b0, 100.0, 50.0, 96.0, 40.0, "union box"); + // origin pivot: bindings-placed point is fixed under raw rotation write + doc.get_mut(g).header.rotation = 30.0; + let r1 = run(&doc); + let origin_before = r0.world_of(g).apply((0.0, 0.0)); + let origin_after = r1.world_of(g).apply((0.0, 0.0)); + assert_close(origin_before.0, origin_after.0, "pivot x fixed"); + assert_close(origin_before.1, origin_after.1, "pivot y fixed"); + + // center-feel gesture: 3 writes, union center fixed in parent space + doc.get_mut(g).header.rotation = 0.0; + let r2 = run(&doc); + let center_before = (b0.x + b0.w / 2.0, b0.y + b0.h / 2.0); + let writes = + anchor_lab::ops::rotate_derived_center_feel(&mut doc, &r2, g, 30.0).unwrap(); + assert_eq!(writes, 3, "rotation + x + y — the Figma trick over scalars"); + let r3 = run(&doc); + let bb = r3.box_of(g); + let local_center = (bb.w / 2.0, bb.h / 2.0); + let center_after = r3.world_of(g).apply(local_center); + assert_close(center_after.0, center_before.0, "center x fixed"); + assert_close(center_after.1, center_before.1, "center y fixed"); +} diff --git a/model-v2/a/lab/tests/textir_suite.rs b/model-v2/a/lab/tests/textir_suite.rs new file mode 100644 index 0000000000..c3cf85e95e --- /dev/null +++ b/model-v2/a/lab/tests/textir_suite.rs @@ -0,0 +1,95 @@ +//! E3 — the agent text IR: the §7 quartet parses, round-trips, and +//! resolves to the geometry the spec's worked examples promise. + +mod common; +use common::*; + +use anchor_lab::model::*; +use anchor_lab::textir::{parse, print}; + +const QUARTET: &str = r#" + + + + + + hello + + + + + + + + + +"#; + +/// H1: the a.md §7 worked examples are expressible verbatim-ish and parse. +#[test] +fn quartet_parses_and_resolves() { + let doc = parse(QUARTET).expect("quartet parses"); + let r = run(&doc); + + // (a) rotated rect: box as authored, world rotated about center + let a = 1; // document order + assert_rect(r.box_of(a), 10.0, 20.0, 120.0, 80.0, "(a) box"); + let c = r.world_of(a).apply((60.0, 40.0)); + assert_close(c.0, 70.0, "(a) center fixed"); + assert_close(c.1, 60.0, "(a) center fixed"); + + // (b) end-pinned: x = 1000 − 24 − 120 + let b = 2; + assert_close(r.box_of(b).x, 856.0, "(b) end pin"); + + // (c) flex column hug: padding 16×2 + 40 + gap 8 + text(19.2) = 99.2 + let f = 3; + assert_close(r.box_of(f).w, 400.0, "(c) fixed w"); + assert_close(r.box_of(f).h, 16.0 + 40.0 + 8.0 + 19.2 + 16.0, "(c) hug h"); + // stretch: rect fills 400 − 32 + let rect = 4; + assert_close(r.box_of(rect).w, 368.0, "(c) stretch"); + + // (d) group: derived box 96×40 placed at origin (100,500) + let g = 6; + assert_rect(r.box_of(g), 100.0, 500.0, 96.0, 40.0, "(d) union"); + + // (e) lens: pre-ops box participates; world of child carries skew + let lens = 9; + assert_close(r.box_of(lens).w, 240.0, "(e) pre-ops box"); + let child = 10; + let w = r.world_of(child); + assert!((w.c - (20.0f32).to_radians().tan()).abs() < 1e-4, "(e) skew"); +} + +/// S-1 analogue: parse → print → parse is a fixpoint on the model. +#[test] +fn roundtrip_fixpoint() { + let doc1 = parse(QUARTET).expect("parse 1"); + let text = print(&doc1); + let doc2 = parse(&text).unwrap_or_else(|e| panic!("reparse failed: {e}\n---\n{text}")); + assert_eq!(doc1, doc2, "canonical round-trip\n---\n{text}"); + // and the printer is itself a fixpoint + assert_eq!(text, print(&doc2), "print is stable"); +} + +/// Amendment 2 boundary: malformed values are typed parse errors, never +/// silently coerced. +#[test] +fn parse_errors_are_typed() { + assert!(parse(r#""#).is_err(), "NaN rejected (N-2)"); + assert!(parse(r#""#).is_err(), "unknown attr rejected"); + assert!(parse(r#""#).is_err(), "shape without kind rejected"); + assert!(parse(r#""#).is_err(), "bad anchor word"); +} + +/// Kind defaults (§4): text defaults to auto/auto; line locks height. +#[test] +fn kind_defaults_in_ir() { + let doc = parse(r#"hi"#) + .unwrap(); + let t = 1; + assert_eq!(doc.get(t).header.width, SizeIntent::Auto); + let l = 2; + assert_eq!(doc.get(l).header.height, SizeIntent::Fixed(0.0), "line h locked"); +} diff --git a/model-v2/a/lab/tests/visual_only.rs b/model-v2/a/lab/tests/visual_only.rs new file mode 100644 index 0000000000..31dc6d652b --- /dev/null +++ b/model-v2/a/lab/tests/visual_only.rs @@ -0,0 +1,362 @@ +//! DEC-0 conformance — the VISUAL-ONLY default (dec0-visual-only.md). +//! Sizing never reads rotation/flips (CSS-pure); reads stay oriented. +//! Every rule V-1…V-9 that changes behavior vs the anchor arm gets its +//! floor here; the anchor arm stays guarded by the pinned suites. + +mod common; +use common::*; + +use anchor_lab::math::rotated_aabb_size; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, Report, Resolved}; + +/// The DEFAULT options — deliberately `Default::default()`: this suite +/// guards what a consumer gets without asking. +fn run_default(doc: &Document) -> Resolved { + resolve( + doc, + &anchor_lab::resolve::ResolveOptions { + viewport: (1000.0, 1000.0), + ..Default::default() + }, + ) +} + +fn at(mut h: Header, x: f32, y: f32) -> Header { + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(y); + h +} + +/// Fixed 460x170 row (gap 10, pad 10), three 60x100 cards; `mutate` +/// tweaks the middle card. +fn row_doc(mutate: impl Fn(&mut Header)) -> (Document, [NodeId; 3], NodeId) { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut ids = [0u32; 3]; + for (i, slot) in ids.iter_mut().enumerate() { + let (mut ch, cp) = shape(60.0, 100.0); + if i == 1 { + mutate(&mut ch); + } + *slot = b.add(f, ch, cp); + } + (b.build(), ids, f) +} + +// ── V-1: flex contribution is the unrotated box ───────────────────── + +#[test] +fn v1_rotated_child_contributes_unrotated_box() { + let (plain, pids, _) = row_doc(|_| {}); + let (rotated, rids, _) = row_doc(|h| h.rotation = 45.0); + let rp = run_default(&plain); + let rr = run_default(&rotated); + // Sizing identical: every box the same as the unrotated document. + for (p, r) in pids.iter().zip(rids.iter()) { + let (bp, br) = (rp.box_of(*p), rr.box_of(*r)); + assert_rect(br, bp.x, bp.y, bp.w, bp.h, "box unchanged by rotation"); + } +} + +#[test] +fn v1_overlap_is_correct_behavior() { + let (doc, ids, _) = row_doc(|h| h.rotation = 45.0); + let r = run_default(&doc); + // Boxes stay disjoint (they never moved)… + assert!(r.box_of(ids[1]).intersection_area(&r.box_of(ids[2])) == 0.0); + // …while the INK envelope overlaps the neighbor — visual-only's + // accepted behavior, visible in the read tier. + assert!(r.aabb_of(ids[1]).intersection_area(&r.aabb_of(ids[2])) > 0.0); +} + +// ── V-2: fill never fights rotation ───────────────────────────────── + +#[test] +fn v2_grow_is_continuous_under_rotation() { + let widths: Vec = [0.0f32, 3.0, 45.0, 90.0] + .iter() + .map(|theta| { + let (doc, ids, _) = row_doc(|h| { + h.grow = 1.0; + h.rotation = *theta; + }); + run_default(&doc).box_of(ids[1]).w + }) + .collect(); + // The grown width is the same at EVERY angle — the E-A11 pop cannot + // exist under visual-only. + for w in &widths { + assert_close(*w, widths[0], "grown width angle-invariant"); + } + assert!(widths[0] > 250.0, "grow actually filled: {}", widths[0]); +} + +#[test] +fn v2_stretch_applies_to_rotated_child() { + let (doc, ids, _) = row_doc(|h| { + h.self_align = SelfAlign::Stretch; + h.height = SizeIntent::Auto; + h.rotation = 30.0; + }); + let r = run_default(&doc); + // Stretch fills the content cross size (170 - 2*10 padding). + assert_close(r.box_of(ids[1]).h, 150.0, "stretch applies while rotated"); +} + +#[test] +fn v2_no_inert_reports() { + let (doc, _, _) = row_doc(|h| { + h.grow = 1.0; + h.rotation = 30.0; + }); + let r = run_default(&doc); + let inert = r.reports.iter().any(|rep| { + matches!(rep, Report::IgnoredByRule { field, .. } + if *field == "grow" || *field == "self_align") + }); + assert!(!inert, "nothing is ignored-for-rotation anymore: {:?}", r.reports); +} + +// ── V-3: hug ignores transforms (flex AND free) ────────────────────── + +#[test] +fn v3_flex_hug_ignores_rotation() { + let hug_w = |theta: f32| { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Auto, + SizeIntent::Fixed(170.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + for i in 0..3 { + let (mut ch, cp) = shape(60.0, 100.0); + if i == 1 { + ch.rotation = theta; + } + b.add(f, ch, cp); + } + run_default(&b.build()).box_of(f).w + }; + assert_close(hug_w(60.0), hug_w(0.0), "flex hug angle-invariant"); +} + +#[test] +fn v3_free_hug_ignores_rotation_and_flip() { + let hug = |theta: f32, flip: bool| { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_free(SizeIntent::Auto, SizeIntent::Auto); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let (mut ch, cp) = shape(120.0, 40.0); + ch.x = AxisBinding::start(10.0); + ch.y = AxisBinding::start(10.0); + ch.rotation = theta; + ch.flip_x = flip; + b.add(f, ch, cp); + let r = run_default(&b.build()); + r.box_of(f) + }; + let base = hug(0.0, false); + assert_rect(base, 20.0, 20.0, 130.0, 50.0, "hug = pins + box"); + let turned = hug(75.0, true); + assert_rect( + turned, + base.x, + base.y, + base.w, + base.h, + "free hug ignores rotation and flip (CSS-pure)", + ); +} + +// ── V-4: the derived box is sizing-tier ─────────────────────────────── + +#[test] +fn v4_group_box_is_unrotated_union() { + let mut b = DocBuilder::new(); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(200.0); + gh.y = AxisBinding::start(100.0); + let g = b.add(0, gh, Payload::Group); + let (mut ch, cp) = shape(100.0, 20.0); + ch.rotation = 45.0; + let c = b.add(g, ch, cp); + let doc = b.build(); + let r = run_default(&doc); + // Sizing tier: the member's UNROTATED box. + assert_rect(r.box_of(g), 200.0, 100.0, 100.0, 20.0, "group box unrotated"); + // Read tier: the ink is oriented and larger. + let ink = r.aabb_of(c); + let (ew, eh) = rotated_aabb_size(100.0, 20.0, 45.0); + assert_close(ink.w, ew, "ink envelope w"); + assert_close(ink.h, eh, "ink envelope h"); + // Note the direction: a flat bar's 45° envelope is NARROWER than its + // width (84.85 < 100) — ink vs box can differ either way per axis. + // The height is where this member's ink provably exceeds the box. + assert!(r.aabb_of(g).h > r.box_of(g).h, "group ink taller than box"); +} + +#[test] +fn v4_group_flex_contribution_is_unrotated() { + // A group whose member is rotated sits in a row next to a card: the + // card's position derives from the UNROTATED union width. + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let g = b.add(f, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let (mut m, mp) = shape(100.0, 20.0); + m.rotation = 45.0; + b.add(g, m, mp); + let (nh, np) = shape(60.0, 100.0); + let n = b.add(f, nh, np); + let r = run_default(&b.build()); + // pad 10 + group slot 100 (unrotated union) + gap 10 = 120. + assert_close(r.box_of(n).x, 120.0, "neighbor placed by unrotated union"); +} + +#[test] +fn v4_group_own_rotation_paints_only() { + let build = |theta: f32| { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + Direction::Row, + 10.0, + 10.0, + ); + let f = b.add(0, at(fh, 20.0, 20.0), fp); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.rotation = theta; + let g = b.add(f, gh, Payload::Group); + let (m, mp) = shape(80.0, 40.0); + b.add(g, m, mp); + let (nh, np) = shape(60.0, 100.0); + let n = b.add(f, nh, np); + (b.build(), g, n) + }; + let (d0, _, n0) = build(0.0); + let (d1, g1, n1) = build(50.0); + let r0 = run_default(&d0); + let r1 = run_default(&d1); + // Sibling untouched by the group's own rotation… + assert_close(r1.box_of(n1).x, r0.box_of(n0).x, "sibling stable"); + // …while the group's ink really turned (read tier). + assert!(r1.aabb_of(g1).h > r1.box_of(g1).h + 1.0, "ink rotated"); +} + +#[test] +fn v4_nested_union_offsets_still_exact() { + // Translation-only nesting must survive the V-4 path (the census's + // nested-offset fix, re-proven under visual-only). + let mut b = DocBuilder::new(); + let mut outer_h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + outer_h.x = AxisBinding::start(50.0); + outer_h.y = AxisBinding::start(60.0); + let outer = b.add(0, outer_h, Payload::Group); + let mut inner_h = Header::new(SizeIntent::Auto, SizeIntent::Auto); + inner_h.x = AxisBinding::start(20.0); + inner_h.y = AxisBinding::start(30.0); + let inner = b.add(outer, inner_h, Payload::Group); + let (mut ch, cp) = shape(40.0, 40.0); + ch.x = AxisBinding::start(5.0); + ch.y = AxisBinding::start(7.0); + let c = b.add(inner, ch, cp); + let r = run_default(&b.build()); + assert_rect(r.box_of(inner), 25.0, 37.0, 40.0, 40.0, "inner box"); + assert_rect(r.box_of(outer), 75.0, 97.0, 40.0, 40.0, "outer box"); + let w = r.world_of(c); + assert_close(w.e, 75.0, "leaf world x"); + assert_close(w.f, 97.0, "leaf world y"); +} + +// ── V-7: lens-rotate ≡ header-rotate ───────────────────────────────── + +#[test] +fn v7_lens_rotate_equals_header_rotate() { + let theta = 33.0; + // Arm A: header rotation. + let mut a = DocBuilder::new(); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + Direction::Row, + 10.0, + 10.0, + ); + let fa = a.add(0, at(fh.clone(), 20.0, 20.0), fp.clone()); + let (mut ha, pa) = shape(60.0, 100.0); + ha.rotation = theta; + let ca = a.add(fa, ha, pa); + let da = a.build(); + // Arm B: lens rotation around the same child. + let mut bb = DocBuilder::new(); + let fb = bb.add(0, at(fh, 20.0, 20.0), fp); + let l = bb.add( + fb, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { + ops: vec![LensOp::Rotate { deg: theta }], + }, + ); + let (hb, pb) = shape(60.0, 100.0); + let cb = bb.add(l, hb, pb); + let db = bb.build(); + + let ra = run_default(&da); + let rb = run_default(&db); + let (wa, wb) = (ra.world_of(ca), rb.world_of(cb)); + for (x, y) in [(wa.a, wb.a), (wa.b, wb.b), (wa.e, wb.e), (wa.f, wb.f)] { + assert_close(x, y, "lens-rotate ≡ header-rotate under visual-only"); + } +} + +// ── V-8: reads stay oriented ───────────────────────────────────────── + +#[test] +fn v8_world_aabb_is_the_oriented_envelope() { + let (doc, ids, _) = row_doc(|h| h.rotation = 37.0); + let r = run_default(&doc); + let (ew, eh) = rotated_aabb_size(60.0, 100.0, 37.0); + let ink = r.aabb_of(ids[1]); + assert_close(ink.w, ew, "read tier envelope w"); + assert_close(ink.h, eh, "read tier envelope h"); +} + +// ── UB hunt: hug of a group of rotated ──────────────────────────────── + +#[test] +fn v3v4_hug_of_group_of_rotated() { + let mut b = DocBuilder::new(); + let (fh, fp) = frame_free(SizeIntent::Auto, SizeIntent::Auto); + let f = b.add(0, at(fh, 10.0, 10.0), fp); + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(15.0); + gh.y = AxisBinding::start(25.0); + let g = b.add(f, gh, Payload::Group); + let (mut ch, cp) = shape(100.0, 20.0); + ch.rotation = 45.0; + b.add(g, ch, cp); + let r = run_default(&b.build()); + // Hug wraps the group's SIZING box (unrotated union at its pins): + // 15 + 100 wide, 25 + 20 tall — no envelope anywhere in sizing. + assert_rect(r.box_of(f), 10.0, 10.0, 115.0, 45.0, "hug of group of rotated"); + // The ink still escapes, visibly, in the read tier. + assert!(r.aabb_of(f).w > r.box_of(f).w - 1.0); +} diff --git a/model-v2/a/model-demo.html b/model-v2/a/model-demo.html new file mode 100644 index 0000000000..312fef8f88 --- /dev/null +++ b/model-v2/a/model-demo.html @@ -0,0 +1,1017 @@ +anchor — how the model works + + +
+
+
+ model-v2 · anchor · model demo — 2026-07-07 +
+

One box. Content realizes into it.

+

+ The consolidated model in three interactive laws. A node is an + anchored box with typed content: the box comes from intent, content + is realized into the box at render, and nothing derivable is ever + stored. Everything below runs the spec’s own formulas (the same ones + the Rust lab asserts in 58 tests and LLMs predicted cold at 100%). +

+
+ +
+
demo a — the two-axis core
+

One box, four realizations.

+

+ Drag the box size. + The document writes are the same two fields every time (w, h) + — what differs per kind is how content re-realizes: parametric + shapes re-derive, vectors re-map from their reference rect, text re-wraps, + image paint re-fits. No points move, no matrix appears, no bake happens. +

+ +
+
+ + + + + + document writes per drag: + 2 fields +
+
+
+
shape · parametric
+
+ Descriptor = f(box). A star has no points — count 5 + + inner ratio 0.45, evaluated at the box. Stroke stays 2px. +
+ +
stored: kind=star points=5 inner=0.45
+
+
+
vector · mapped
+
+ Points live in a 24×24 reference rect (inset, frozen). + Render maps ×(box/ref). Stroke stays 2px — non-scaling + by construction. +
+ +
+ stored: ref=24×24 + 7 vertices (never rewritten) +
+
+
+
text · flowed
+
+ Content re-wraps at the imposed width (greedy, the spec metric). + Font size stays 14 — a style, not geometry. +
+ +
stored: content + size=14
+
+
+
image fill · fitted
+
+ Paint re-fits at the resolved box per fit mode — never + stretched with geometry. + +
+ +
stored: image ref + fit mode
+
+
+
+
+ +
+
demo b — the points law (e-a9)
+

Resize all you want. The blob stays the blob.

+

+ The table is the stored document data — vertices in the + reference rect. Resize writes w/h and the table never changes (Sketch + stores 0–1 points, Figma ships + normalizedSize ≠ size + nodes — same law). Only a vertex-editing gesture writes + points; then the reference bounds re-derive. +

+ +
+
+
+
+ + + + +
+ +
+
+
+
+ stored document data + FROZEN UNDER RESIZE +
+
+ w: 200   h: 200   ref: + 24×24 +
+ + + + + + +
vertexx (ref)y (ref)
+
+ resolved (read tier, materialized like Figma’s plugin API):
+ vertex #4 → +
+
+
+
+
+ +
+
demo c — the two scales
+

Resize is not scale. And scale is two different things.

+

+ One card (dotted image fill, 2px stroke, radius 10, label 12px), one + slider, three semantics. Watch the document under each: resize + writes two fields and keeps every style in px; K is a sanctioned + bake that multiplies the numbers; lens stores one op and touches no + value — the picture semantics (strokes and tiles magnify), + quarantined. +

+ +
+
+ +
+
+
+
resize
+ +
+
+
+
K · parameter scale (bake)
+ +
+
+
+
lens · picture scale (stored op)
+ +
+
+
+
+ Law 5. Styles are px-stable under resize (non-scaling-stroke by + construction). K bakes numbers through the subtree — Figma + rescale(), Sketch K, Grida parametric_scale, already shipping. A + retained picture-scale exists only as a lens op — the + SVG/Graphics-container semantics, opt-in, never a prerequisite. +
+
+
+
+ + diff --git a/model-v2/a/pedantic-review.md b/model-v2/a/pedantic-review.md new file mode 100644 index 0000000000..95f6dfd709 --- /dev/null +++ b/model-v2/a/pedantic-review.md @@ -0,0 +1,209 @@ +# Pedantic review — the anchor model (MODEL.md + REPORT.md) + +2026-07-07. Four adversarial lenses (bedrock/logic, UX, engineering, +market) + an edge-case hunter, run as a panel; findings below are the +survivors after cross-checking against the docs, the lab, and — where a +claim was executable — **construction tests** (the edge-case sweep +`lab/src/bin/edge.rs` → [`edge-cases/`](./edge-cases), whose data +independently confirmed two findings before the panel returned them). + +**Verdict: the core survives — the two-axis model, layout-visible +rotation, the points-law direction, and the four-phase resolver are +genuinely evidenced. MODEL.md as a phase-3 seed needs rework in seven +places, and two market-facing seams were never measured at all.** +Findings in target order; severity per finding. + +--- + +## A. Machine-verified (a failing construction exists) + +**A1 — grow × rotation is discontinuous at θ=0 (SERIOUS, new E-A11).** +Quote: "grow expands the slot; a rotated child's box keeps its basis" +(E-A4). Constructed: fixed row, middle card `grow:1` — at θ=0 the box +fills 300 px; at θ=3° it snaps to its 60 px basis. **240 px jump in one +3° step**, measured by the edge sweep (scene `grow`). E-A4 never defined +the θ=0 boundary. _Resolution:_ inverse-envelope fill — the box takes +the largest extent whose rotated envelope fits the grown slot +(continuous at 0°, reads as "a filling card stays filling while it +turns"); or at minimum declare the pop and make the editor clear `grow` +on rotate. Conformance row owed. + +**A2 — stretch × rotation is a silent pop (SERIOUS, new E-A12).** +Constructed: `align:stretch` child stretches to 170 px tall at θ=0, +pops to 100 px at θ=3° (scene `stretch`; 70 px jump). The lab's +`contribute_aabb` branch silently skips the stretch override and the +text measure closure — an **unstated rule that is currently +load-bearing** for both "zero overlap by construction" and "single-pass +acyclic" (the bedrock lens derived the same gap from the applicability +matrix alone: §8 says grow/self_align are unconditionally "effective" +in flow, which is false for θ≠0). _Resolution:_ promote the policy to +spec text: for θ≠0 in-flow children, `self_align:Stretch` and +layout-imposed re-measure are ignored-by-rule **with report**; same +continuity treatment as A1. + +**A3 — the bool box-source cell in MODEL.md is wrong (SERIOUS).** +MODEL.md's table: `bool` = "derived (union of oriented children)". +a.md §3 and conformance **D-5** say: op-_result_ bounds (subtract-to- +empty: union is large, result is empty — they differ by construction). +Transcription drift in the consolidated statement; REPORT's lose column +concedes bool never ran, so no test caught it. _Resolution:_ fixed in +MODEL.md (this pass); lab test (subtract-to-empty) owed before phase 3. + +## B. Counterexample-verified (constructible on paper, no lab arm yet) + +**B1 — flip pivot violates D-2 for derived kinds (SERIOUS).** E-A2 says +flips are "center-applied" with no derived-kind carve-out. Counter- +example: group with `flip_x`, move child A → union widens → union +center shifts → mirrored sibling B moves in world space — the exact P6 +instability E-A1 exists to kill. Flip is unimplemented in the lab +(`grep flip lab/src` = 0 hits): the corpus forced flip's _existence_, +not its _semantics_. _Resolution:_ flip pivot per kind exactly as +rotation (center for boxed/measured, own-origin for derived); D-2-with- +flip conformance row; lab arm. + +**B2 — E-A9's write side has a stationarity counterexample +(DEALBREAKER-class for law 6 as written).** Fixed-box vector (box +48×48, ref 24×24): drag ONE vertex outward → if the ref rect re-derives +(T-5), `box/ref` changes and **every untouched vertex visibly moves**. +The disjoint-fields CRDT story also only holds for a _stored_ ref rect +— normalize-at-write renormalization rewrites every vertex on any +bound-moving edit, resurrecting the write amplification E-A9 was minted +to kill. Plus: zero-extent reference axis (any straight-line vector) → +division by zero at render, reachable from a shipping document (N-2 +promises NaN-free). _Resolution set (lock before phase 3):_ ref rect = +**free intent** (explicitly exempt from law 1 — same status as Figma's +`normalizedSize`, which is bake-time state, not point bounds); **forbid +origin/bounds renormalization as a gesture side effect**; vertex-edit = +declared atomic write-set with a stationarity guarantee; zero-extent +axis → translation-only mapping (POL; check a straight-line .fig +fixture first — io-figma harness exists); rewrite T-5 with Auto/Fixed +arms; implement the vector kind in the lab with a resize∥vertex-edit +test. + +**B3 — law 8 contradicts itself and the lab (MODERATE).** "The op layer +never hard-walls… a rejected op leaves the document byte-identical" — +both halves can't rule. The lab (cited as proof) _rejects_: +`Err(AxisOwnedBySpan)`, `Err(OwnedByLayout)`; M-2 locks a typed-error +list. _Resolution:_ two enumerated regimes — the re-target set (writes +that coerce, with report) and the reject set (M-2's list, byte- +identical) — and MODEL.md says which is which. + +**B4 — reads-materialize/writes-retarget has a counterexample (SERIOUS, +editor seam).** Type W into the inspector of a `grow:1` (or stretched) +child: the echo cannot equal the typed value unless the write also +clears `grow`/`self_align`. Figma's answer (clears fill on W write) is +the researched precedent. _Resolution:_ editor.md rule — a W/H write on +a grown/stretched child clears the growth intent (session-memory +restorable); law 7 carve-out stated. + +**B5 — group rotation typed into an inspector lurches (SERIOUS, editor +seam).** Raw `rotation` write on a group = origin pivot (E-A1's +"bindings place the origin" can sit far from visible content); the +center-feel result requires the 3-write OP-ROT-3 gesture. A numeric +inspector field is a raw write today. _Resolution:_ inspector rotation +on derived kinds routes through OP-ROT-3 (compensated); spec'd in +editor.md, not left to implementers. (The edge demo's `group` scene +shows flow placement stays centered — the resolver's slot-centering +hides the pivot in flow; free context is where the lurch lives.) + +## C. Leaked uncertainty / overclaims in MODEL.md prose + +**C1 — "E-A1…E-A10 folded into one coherent statement" is false +(MODERATE, verified by grep).** E-A4, E-A5, E-A7, E-A8 are absent. +Worse: E-A5 (underdetermined bindings error-by-rule) is the _acyclicity +guard_ — without it stated, "single-pass, acyclic" has a constructible +cycle (Span ← parent hug ← child extents). _Resolution:_ fold or +narrow the claim (this pass narrows it); E-A5 stated next to the +acyclicity claim. + +**C2 — locality overclaim (MODERATE).** "18 µs locality" drops E4's own +qualifier ("under clean parent boxes"); a text edit in a hug chain +propagates to the nearest fixed-extent ancestor (the flagship card-list +example violates the clean-parent precondition). And "subtree-local +invalidation by structure" presents as achieved what REPORT quarantines +("no incremental resolver"). _Resolution:_ state the dirty-propagation +rule (up the measure chain to the nearest fixed-extent ancestor) and +the 18 µs precondition. + +**C3 — E3 citation drops its own quarantine (MINOR but strategic).** +"LLMs predicted cold at 100%" without "n=3 models, 6 documents, +authoring-only, no stable ids". Law 7's write half (agent edit-in-place) +is _open_, not proven — no ids means positional identity breaks on +reorder+edit. _Resolution:_ carry the protocol caveat; split law 7 +(reads proven / IR writes pending stable ids). + +**C4 — M-4 is an INV the chosen encoding verifiably fails (MODERATE).** +E-A6 measured FlatBuffers dropping unknown fields on old-schema +re-encode, yet conformance M-4 still says INV "preserves unknown +content through RMW". _Resolution:_ decide E-A6 (buffer-retention vs +version-gated writers) or downgrade M-4 to POL with the declared +answer — before phase 3, since S-5/forward-compat cite it. + +**C5 — lens `Scale` is a scalar; Framer-Graphic semantics are +box-proportional (MODERATE, honest-trade finding).** E7 oversold the +equivalence: a retained scalar cannot track the box, so "icon scales +proportionally _when its box resizes_" is still unrepresentable +retained. _Resolution:_ declare the trade explicitly (substitutes: K +bake, per-leaf mapped vectors) or add a box-relative scale variant to +the lens vocabulary later — additive either way. + +**C6 — the lens has no opt-in (MODERATE, UX).** MODEL.md calls the +proportional world "the opt-in lens"; editor.md has no lens-creating +or lens-resizing operation — the only producer is SVG import. Resize +handles on a lens are undefined (pre-ops box vs post-ops visual). +_Resolution:_ OP rows: "wrap in lens", "lens resize re-targets the +Scale op", hit/chrome tracks post-ops visual. + +## D. The two unmeasured market seams + +**D1 — "lossless transpile to CSS" is unfalsifiable as claimed +(DEALBREAKER-class for the web story).** finale.md's concession table +carries it; layout-visible rotation **has no CSS flexbox equivalent** +— a `grow:1 rotation:20` child cannot be projected losslessly. Grida +ships a DOM-rendered tenant-site surface today. _Resolution:_ **E8** — +transpile the E1 document to HTML/CSS, measure the loss class, decide +the projection mechanism (per-breakpoint bake of resolved geometry / +JS resolver runtime / declared degradation table). Until E8 runs, the +web bill is unpaid, not cheap. + +**D2 — the Figma corpus was never scanned (SERIOUS).** Triage #3 made +Figma-convertibility the hard requirement; E5 measured a _million SVG +transforms_ and zero `.fig` nodes. Known concrete gap: Figma's SCALE +constraint has no v1 equivalent (percent pins are deferred). _Resolution:_ +**E9** — E5-style scanner over the io-figma corpus counting constraint +types (esp. SCALE), rotated children in auto-layout, +`normalizedSize≠size` vectors, boolean nesting, negative determinants. +Also: declare the export posture (flatten-to-frames lossy table, or +export-is-a-non-goal) — round-trip silence reads as a hidden "no". + +**D3 — the Figma community record predicts the #1 support ticket +(SERIOUS, UX-market).** Figma's forum carries recurring tickets against +exactly this behavior class ("prevent rotated images increasing the +component's bounding box"). E1 measured smoothness, not user reaction. +_Resolution:_ the escapes are launch-blocking editor affordances, not +notes — rotate-HUD one-click "keep slot fixed" (wrap/lens re-target) + +envelope readout (E-A7). Also fold the "Fill" sugar (`width="fill"` in +IR/inspector redirecting to grow/stretch/span with report) — one user +concept, three intents, incumbents spell it as one word. + +## Cleared by the panel (pressed, survived) + +Layout-visible rotation itself (Figma parity X-FIG-1 + E1 measurement); +the no-Fill _model_ (given D3's sugar); reads-materialize for +copy/paste (natural); origin-placement E-A1 (D-2 holds, lab-tested); +the two-scales split (K vs lens, given C5/C6 fixes); text-measure- +unrotated (industry-standard dodge, edge scene reads naturally); wrap +line-hop (discrete by nature, CSS-equivalent; editor should animate); +rotated-frame rigidity, space-between symmetry, two-envelope +composition — all smooth in the sweep with sub-bound step sizes. + +## Disposition + +MODEL.md surgically corrected this pass: A3 (bool cell), C1 (folded- +claim narrowed + E-A5 stated), C2 (locality qualified), B3 (law 8 two +regimes), C3 (E3 caveat), law 6 marked open-pending-B2. Everything +else lands as phase-3 lock conditions: **E-A11** (A1), **E-A12** (A2), +the B2 lock set, B1 flip pivots, B4/B5/C6 editor.md rows, C4 M-4 +decision, C5 declared trade, **E8** and **E9** as new ledger +experiments. The demo for A1/A2 and the cleared scenes is +[`edge-cases/`](./edge-cases) (interactive; discontinuity meter). diff --git a/model-v2/a/proof.html b/model-v2/a/proof.html new file mode 100644 index 0000000000..0e5ce85e1c --- /dev/null +++ b/model-v2/a/proof.html @@ -0,0 +1,3876 @@ +anchor — visual proof + + +
+
+
+ model-v2 · anchor · proof run — 2026-07-07 +
+

The list makes room.

+

+ Every number and pixel on this page comes from the lab in + model-v2/a/ — the resolver that implements the anchor spec, + the sweeps it ran, and the agents that were tested against it. Three kinds + of proof, in order of strength: +

+ +
+
+
runtime
+
it runs, live
+

+ The E1 demo below is the resolver's actual output, scrubbable. On your + machine: + cargo test → + 56/56. +

+
+
+
image
+
it renders, exactly
+

+ SVG frames emitted by the resolver at key angles, magenta dashes = + computed world AABBs. +

+
+
+
record
+
it is written down
+

+ metrics.csv, truth.txt, prediction files, byte-fixpoint binaries + — every claim re-derivable. +

+
+
+ +
+ LAB 56/56 tests + E1 layout-visible locked + E2 byte-fixpoint + E3 LLM 22/22 ×2 + E4 10k → 5.4 ms + E5 1M transforms +
+
+ +
+
exhibit e1 — the deciding experiment, live
+

Rotate the middle card. Watch both worlds.

+

+ Same document, same resolver, one flag. Top: + anchor — the rotated card + participates by its oriented envelope, siblings make room, the hug + container breathes. Bottom: + CSS control — layout is frozen, + rotation is paint-only, and the red wash is sibling overlap that no + document field expresses. +

+ +
+
+
+
anchor — aabb participates
+ +
+
+
css control — visual-only
+ +
+
+
+ θ = 0° + + + + + +
+
+ anchor container 220.0 px + anchor overlap 0 px² + control overlap 0 px² + rotate gesture 1 write / step +
+
+ +
+
+
Container width vs θ
+
+ anchor breathes 220→276.6 px, peak at + θ*=atan(h/w)≈59°; control flat +
+ +
+
+
Sibling overlap area vs θ
+
+ anchor: zero at every angle · control: up to 1,830 px² +
+ +
+
+
Third card position vs θ
+
+ smooth — max step 3.45 px per 2°, inside the + √(w²+h²)·Δθ = 4.07 px bound +
+ +
+
+
Verdict
+
+ conformance R-3 and editor OP-ROT-2: POL → INV +
+

+ Locked layout-visible. Zero overlap by construction and by + measurement; displacement continuous through + 0°/90°/180°; rotation stays a one-field write. + Breathing during a full spin (56.6 px) is a hug-container phenomenon + and is exactly why motion rotation targets a lens channel — the + two-lane rule, now measured rather than argued. +

+
+
+ +
+
+ + + + anchor: AABB participates — θ=0° + + + + + + + + + + + + + + control: visual-only (CSS) — θ=0° + + + + + + + + + + + + +
θ = 0° — resolver SVG output
+
+
+ + + + anchor: AABB participates — θ=45° + + + + + + + + + + + + + + control: visual-only (CSS) — θ=45° + + + + + + + + + + + + +
+ θ = 45° — envelope 113.1×113.1 +
+
+
+ + + + anchor: AABB participates — θ=90° + + + + + + + + + + + + + + control: visual-only (CSS) — θ=90° + + + + + + + + + + + + +
+ θ = 90° — slot is exactly 100×60 +
+
+
+
+ +
+
exhibit e3 — can a model predict it cold?
+

Two fresh agents, a 150-line grammar, 22/22 exact.

+

+ Three agents were handed only the grammar and six documents — no + tools, no resolver — and asked for every resolved box. The hardest + probe is the E1 rule itself (p5, below). Frontier agents matched the + resolver on all 22 values within 0.5 px. +

+ +
+
<!-- probe p5 — rotated card in a hug row -->
+<frame name="list" w="auto" h="140"
+       layout="flex" gap="10" padding="10"
+       cross="center">
+  <shape name="a" kind="rect" w="60" h="100"/>
+  <shape name="b" kind="rect" w="60" h="100"
+         rotation="90"/>
+  <shape name="c" kind="rect" w="60" h="100"/>
+</frame>
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
p5 valueresolverfable-afable-bhaiku-c
list (hug w)260260260260
b box x,y100, 20100, 20100, 2050, 40
b world AABB80, 40, 100×60exactexactx=30
c (pushed) x190190190190
all 6 probes22 values22/2222/2217/22
+
+
+

+ Even the small-model control applied the rotation-in-flow + rule correctly — its misses were an off-by-one character + count and placing a box at slot-start instead of slot-center. Slips, not + structural confusion. Records: + e3-text-ir/truth.txt · predictions/* +

+
+ +
+
exhibit e2 — the file format
+

Encode → decode → encode: byte-identical.

+

+ The quartet document through flatc 25.12: JSON → binary → JSON + → binary produces the same bytes. And "unset" is structural — + read the decoded output: +

+
+
// grow-text node — decoded JSON
+{
+  "id": 5, // no x, no y keys at all
+  "grow": 1.0  // ⇒ Pin{Start,0} by rule
+}
+// flex frame — height IS auto, structurally
+{
+  "width_type": "SizeFixed",
+  "width": { "value": 400.0 },
+  "height_type": "SizeAuto",
+  "height": { }
+}
+
+
+$ ./run.sh
+S-1 FIXPOINT: byte-identical
+M-4 forward:  v1 binary decodes under v2 schema
+M-4 backward: v2 binary decodes under v1 schema
+ max_lines: 0 ≡ unset — now unrepresentable +

+ One policy owed for phase 3: re-encoding through an old schema + drops unknown fields — read-skip is free, read-modify-write + preservation is not. +

+
+
+
+ +
+
exhibit e4 — the resolver, timed
+

10,000 nodes in 5.4 ms, unoptimized.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
scenenodesfull resolvethroughput
flat canvas (⅐ rotated)10,0015.42 ms1,844 nodes/ms
flex cards — nested hug + text measure (worst case)5,78524.4 ms237 nodes/ms
mixed groups + rotated-in-flow10,0038.21 ms1,218 nodes/ms
one card subtree (locality bound per leaf edit)618 µs
+
+

+ Single-threaded, zero caching, and the lab double-runs layout on hug + containers — this is the floor. Scaling is linear (10× nodes + → 9.6× time in the all-flex worst case). What it replaces: the + 26-arm branch forest, the atan2 transform reconstruction, and + MIN_SIZE_DIRTY_HACK. +

+
+ +
+
exhibit e5 — a million transforms
+

The corpus re-ranked the escape hatch.

+

+ 7,138 SVGs, 1,003,787 transforms scanned. The lens was designed for skew + — but genuine shear is under 1%. The real finding is + flip at 26%: single-axis mirrors must be native header bits, or the + "quarantine" fires on a quarter of wild files. +

+ +
+ translate + 9.1% + rotate 1.3% + scale= 19.8% + scale≠ + 42.8% + flip 26.1% → + goes native + shear 0.95% → + lens +
+
+ +
+
reproduce it
+

Runtime proof, on your machine.

+
+
from the repo root
+
open model-v2/a/e1-rotation-in-flow/demo.html   # the offline twin of the demo above
+cd model-v2/a/lab
+cargo test                        # 56/56 conformance-derived tests
+cargo run --bin e1                # regenerate the sweep, frames, metrics
+cargo run --bin e3 -- truth       # ground truth an agent must match
+cargo run --release --bin e4      # the timings above
+
+

+ Full narrative, amendments E-A1…E-A7, and the honest lose column: + model-v2/a/REPORT.md. + Nothing is committed to git — the workbench is yours to review. +

+
+
+ + diff --git a/model-v2/a/spike-canvas/.cargo/config.toml b/model-v2/a/spike-canvas/.cargo/config.toml new file mode 100644 index 0000000000..962a47cbc9 --- /dev/null +++ b/model-v2/a/spike-canvas/.cargo/config.toml @@ -0,0 +1,5 @@ +# Reuse the repo's shared target dir so the already-compiled skia-safe +# build (grida_dev / grida_editor — same version, same features) is hit +# instead of rebuilt inside this standalone workspace. +[build] +target-dir = "../../../target" diff --git a/model-v2/a/spike-canvas/Cargo.lock b/model-v2/a/spike-canvas/Cargo.lock new file mode 100644 index 0000000000..779c8670be --- /dev/null +++ b/model-v2/a/spike-canvas/Cargo.lock @@ -0,0 +1,2686 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anchor-lab" +version = "0.0.0" +dependencies = [ + "quick-xml 0.37.5", + "taffy", +] + +[[package]] +name = "anchor-spike" +version = "0.0.0" +dependencies = [ + "anchor-lab", + "egui", + "egui-winit", + "egui_glow", + "gl", + "glow", + "glutin", + "glutin-winit", + "raw-window-handle", + "skia-safe", + "winit", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.0", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.0", + "emath", + "epaint", + "itertools 0.14.0", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-winit" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" +dependencies = [ + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "web-time", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52274b9bfb8d8e252cd0f00c9f6214f360d75a0962baa77a2d62ddb002fe99d4" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "emath" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.0", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "harfrust" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skia-bindings" +version = "0.93.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2359f7e30c9da3f322f8ca3d4ec0abbc12a40035ce758309db0cdab07b5d4476" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.93.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e837ea9d531c9efee8f980bfcdb7226b21db0285b0c3171d8be745829f940" +dependencies = [ + "base64", + "bitflags 2.13.0", + "percent-encoding", + "skia-bindings", + "skia-svg-macros", +] + +[[package]] +name = "skia-svg-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "044dd2233c9717a74f75197f3e7f0a966db2127c0ffb5e05013b480a9b75b2c7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.0", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "log", + "peniko", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.0", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zerocopy" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/model-v2/a/spike-canvas/Cargo.toml b/model-v2/a/spike-canvas/Cargo.toml new file mode 100644 index 0000000000..3d277b1d72 --- /dev/null +++ b/model-v2/a/spike-canvas/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "anchor-spike" +version = "0.0.0" +edition = "2021" +publish = false +description = "E10 — the feel spike: a native Skia canvas driven by the anchor model (model-v2). Consumes anchor-lab as a library, the same relationship the phase-4 migration will have." + +# Standalone on purpose (model-v2 containment) — not a member of the +# repo workspace, not committed. See ../a/README.md ledger row E10. +[workspace] + +[dependencies] +anchor-lab = { path = "../lab" } + +# Shell deps: versions matched to crates/grida_dev / crates/grida_editor +# (the prior-art winit hosts) so the shared target dir can reuse the +# already-compiled artifacts — especially skia-safe. +winit = "0.30.0" +glutin = "0.32.0" +glutin-winit = "0.5.0" +raw-window-handle = "0.6.0" +gl = "0.14.0" +skia-safe = { version = "0.93.1", features = [ + "gpu", + "gl", + "textlayout", + "pdf", + "svg", + "webp", +] } + +# egui overlay for the dev panels (IR / writes / reports) — the house UI +# dialect (grida_editor shell), same versions so glow types unify. +egui = "0.35" +egui-winit = { version = "0.35", default-features = false } +egui_glow = "0.35" +glow = "0.17" + +[profile.release] +debug = true diff --git a/model-v2/a/spike-canvas/README.md b/model-v2/a/spike-canvas/README.md new file mode 100644 index 0000000000..86af1edc5f --- /dev/null +++ b/model-v2/a/spike-canvas/README.md @@ -0,0 +1,54 @@ +# anchor-spike — E10, the feel spike + +A native Skia canvas driven end-to-end by the `anchor` model +([`../lab`](../lab) — consumed as a library, the same relationship the +phase-4 migration will have). Two goals, owner-stated: **feel it** and +**be the textbook** for the legacy migration +([`TEXTBOOK.md`](./TEXTBOOK.md)). + +## Run + +```sh +cd model-v2/a/spike-canvas +cargo run --release # the window +cargo run --release -- --bench # resolve+paint timings +cargo run --release -- --shot out.png [crosszero|ungroup|rot45] +``` + +First build reuses the repo's compiled skia via the shared target dir +(`.cargo/config.toml`). + +## The feel checklist + +- **pan/zoom** — scroll pans, ⌘-scroll / pinch zooms about the cursor, + space-drag or middle-drag pans, ⌘0 fits, ⌘= / ⌘- zooms. Zero writes — + the camera is host state. +- **select** — click; clicking group content selects the OUTERMOST + group (GROUP.md transparent-select). Esc deselects. +- **move** — drag a card inside the flex row: layout owns position, the + log shows the typed wall if an axis refuses (drag the amber bar + sideways: `x: Err(AxisOwnedBySpan)`); drag the green badge: its End + pin negates the delta in the log. +- **rotate** — the stick above the selection; 1 write (watch the log), + shift snaps to 15°. Under DEC-0 (visual-only) rotation is PAINT: the + row does not reflow, overlap is correct behavior, and the HUD readout + explains box vs INK. Rotating the `chips` group is the 3-write + center-feel gesture. +- **resize** — corner/edge handles; drag an edge THROUGH the opposite + edge: the mirror flips (`flip-x: false -> true` in the log, 2–3 + writes), out-and-back restores the document exactly (E-A14). +- **artboard** — select it, drag its right edge: the badge and the bar + respond with ZERO writes of their own. +- **structure** — ⌫ deletes, ⌘⇧G ungroups (bake writes logged), arrows + nudge (⇧ = 10px), ⌘Z / ⌘⇧Z undo/redo (document snapshots). +- **the IR** — right panel, live canonical print; edit it and hit + apply: the canvas updates; a bad IR is a typed parse error in place. + +## Scope fence (named, not silent) + +No images/vectors/bools; text renders via the default typeface but is +MEASURED by the lab stub (visible mismatch = open DEC-4/B-1, on +purpose); no marquee multi-select, snapping, rulers, persistence beyond +the IR, or wasm. DEC-0 is decided (visual-only, CSS-pure — +[`../dec0-visual-only.md`](../dec0-visual-only.md)); DEC-1/2/3 are +closed n/a by it — fill never fights rotation here, by construction. diff --git a/model-v2/a/spike-canvas/SPIKE.md b/model-v2/a/spike-canvas/SPIKE.md new file mode 100644 index 0000000000..a6edf2b0ee --- /dev/null +++ b/model-v2/a/spike-canvas/SPIKE.md @@ -0,0 +1,99 @@ +# SPIKE.md — E10 read-out + +2026-07-07. Built in one session on the lab (which gained the arena/SOA +storage, `pick`, and `delete` — 100 tests green). Self-verified via +`--shot` headless renders and a 6 s window smoke-launch; the FEEL half +of the acceptance is the owner's (README checklist). + +## Numbers + +Resolver bench (E4, median of 11, release) — the arena+SOA refactor +alone, before the spike even paints: + +| scene | map store | arena+SOA | Δ | +| ------------ | --------- | --------- | ------------------- | +| flat 1,000 | 0.753 ms | 0.097 ms | 7.8× | +| flat 10,000 | 5.462 ms | 0.473 ms | 11.5× | +| mixed 10,000 | 8.534 ms | 3.005 ms | 2.8× | +| flex ~10,000 | 24.373 ms | 19.341 ms | 1.26× (Taffy-bound) | + +Spike frame bench (`--bench`, raster, resolve + skia paint): + +| scene | nodes | resolve | paint | frame | +| ------------- | ------ | -------- | -------- | ------- | +| starter scene | 18 | 0.008 ms | 0.163 ms | 0.17 ms | +| flat 100 | 101 | 0.004 ms | 0.183 ms | 0.19 ms | +| flat 1,000 | 1,001 | 0.034 ms | 1.248 ms | 1.28 ms | +| flat 10,000 | 10,001 | 0.345 ms | 8.674 ms | 9.02 ms | + +**Verdict on the thesis:** resolve-per-frame holds with an order of +magnitude of headroom — at editor scales the frame is PAINT-bound, not +resolve-bound. The incremental invalidator is an optimization for +later, not a load-bearing wall. (This is the inverse of the legacy +architecture's assumption, and now it has numbers.) + +## Verified headless (committed-adjacent in [shots/](./shots); reproduce with --shot) + +- `default`: the row makes room for the 20° card; HUD reads + "70×110 basis · 103×127 envelope · 20.0°" (E-A7); badge end-pinned; + bar spanned; lens child rotated in the paint lane; hug breathing. +- `crosszero`: card.a dragged 50 px past its fixed right edge → + 50×110, `flip_x=true`, row reflowed (E-A14 re-target, no negative + extent stored). +- `ungroup`: chips baked to the artboard, world-exact. +- Window smoke-launch: 6 s, exit clean (GL + skia + egui painter + + event loop healthy). + +## Frictions found by hand (the spike's real yield) + +1. **Rotated edge-resize steers in parent axes.** `resize_drag` is + parent-axis by design; on a rotated node the handle follows the + cursor's parent-X, not the local edge normal. Usable, but the + local-axis gesture (project cursor onto the local axis, then + re-target) belongs in editor.md as the real spec — follow-up, not a + model change (R-4 commute already guarantees the write side). +2. **Text: measured-vs-rendered mismatch is visible** (lab stub vs + default typeface). Expected — this is open DEC-4/B-1 rendered + honest. The moment a real shaper lands, the measure closure seam in + `resolve.rs` is where it plugs. +3. **Hover-pick every pointer-move re-resolves.** At spike scale it's + free (8 µs); at product scale the resolved tier of the LAST frame is + already correct for picking — reuse it (the SOA columns make that a + borrow, not a copy). Noted for the migration. +4. **skia-safe 0.93 removed mutable `Path`** — `PathBuilder` + + `snapshot()` (the engine already does this; the textbook records it + so the migration doesn't rediscover it). +5. **egui 0.35 API drift** (`Panel` not `SidePanel`, `run_ui`, + `egui_wants_pointer_input`) — grida_editor's shell is the reliable + reference, upstream docs are not. + +## Register feedback + +- Nothing falsified. DEC-1's pop reproduces exactly as the register + describes (rotate card.grow and watch it snap at the first degree — + with the report badge visible in the panel). +- DEC-9's flip arm feels RIGHT in the hand (drag through zero and + back): supports the owner lean (true flip) with gesture evidence. +- E-A13 untouched by the spike (group pass-through not exercised — + the starter groups are Start-pinned free-context). + +## What this cost + +Lab: +arena/SOA refactor (semantic-equality PartialEq, structural +APIs), +`pick.rs`, +`ops::delete` — net +8 tests (100 total). +Spike: ~1,900 lines across 9 files, one session, skia reused from the +repo's shared target dir (first full build ≈ 2.5 min). + +## Addendum — DEC-0 flip (same day) + +After the read-out, DEC-0 second-locked to **visual-only (CSS framing, +CSS-pure sizing)** — owner framing, correcting a mis-recorded first +lock. The gated spec review ran: normative rules in +`../dec0-visual-only.md` (the V-4 group-box fork was real UB until +decided — sizing-tier union chosen); lab default flipped; +`tests/visual_only.rs` added (14 tests; suite now **114**); shots above +regenerated under the new default (the rot45 shot now shows overlap as +correct behavior and the hug frame NOT breathing — compare the fork +demo's right panel). The E-A11 pop noted in "register feedback" is +n/a under the new default — the configuration it popped in no longer +exists. diff --git a/model-v2/a/spike-canvas/TEXTBOOK.md b/model-v2/a/spike-canvas/TEXTBOOK.md new file mode 100644 index 0000000000..3b7352611d --- /dev/null +++ b/model-v2/a/spike-canvas/TEXTBOOK.md @@ -0,0 +1,112 @@ +# TEXTBOOK — how an editor sits on the `anchor` model + +2026-07-07. The spike's second deliverable: the reference the +`crates/grida` / `crates/grida_editor` migration reads. Every chapter +names the concern, where it lives here, the model law it exercises, and +the lab test that guards the claim. The lab (`../lab`) is the single +source of truth; this app consumes it as a library — exactly the +relationship the migration will have with the engine crate. + +> **DEC-0 second lock (same day):** the default flipped to +> **visual-only** rotation (owner framing; normative rules incl. the +> V-4 group-box fork: [`../dec0-visual-only.md`](../dec0-visual-only.md)). +> Chapters below hold unchanged except: HUD chrome for derived kinds +> reads INK bounds (`world_aabb`), and the rotate×layout policy surface +> (E-A4/E-A7/E-A11/E-A12, DEC-1/2/3) is retired. Both arms stay +> implemented and tested; the anchor arm is the documented alternative. + +## The thesis: resolve-per-frame, no derived state + +`document → resolve (full) → paint`, immediate. The editor holds NO +caches, no dirty tracking, no reactive layer — its whole state is the +arena `Document`, an FSM, a camera, and an undo stack of document +snapshots. This is affordable because the resolved tier is cheap to +recompute: the spike's `--bench` measures the starter scene at +**0.008 ms resolve / 0.17 ms full frame**, and 10k nodes at 0.35 ms +resolve (paint-bound at ~9 ms). Where the legacy engine invalidates, +this design just re-resolves — the incremental invalidator becomes an +optimization to ADD someday, not an architecture to build first. + +## The storage chapter: node arena + SOA resolved tier + +The lab's original store was `BTreeMap` with an O(n) +`parent_of` scan — the same pointer-chasing shape the legacy system +pays for. The spike replaced it (`lab/src/model.rs`): + +- **cold intent = AoS in a node arena** — `NodeId` IS the slot index, + deleted slots are tombstones, parent links are an index-aligned + column (O(1) `parent_of`). Nodes stay AoS because intent is edited + field-wise and read whole. +- **hot resolved = SOA columns** (`lab/src/resolve.rs::Resolved`) — + `Vec>` / `Vec>` indexed by NodeId, + written once per resolve, read every frame by paint/HUD/pick. +- **equality is semantic** — tombstones and arena capacity are storage + artifacts, not document content (MM-7's add-then-delete-restores + holds by definition; `tests/mm_laws.rs`). + +Measured (E4 bench, median of 11, same machine, before → after): + +| scene | before (map) | after (arena+SOA) | speedup | +| ------------------------ | ------------ | ----------------- | ------------------- | +| flat canvas 1,000 | 0.753 ms | 0.097 ms | 7.8× | +| flat canvas 10,000 | 5.462 ms | 0.473 ms | **11.5×** | +| mixed groups+flex 10,000 | 8.534 ms | 3.005 ms | 2.8× | +| flex cards ~10,000 | 24.373 ms | 19.341 ms | 1.26× (Taffy-bound) | + +Guarded by: the whole suite (100 tests) passing unchanged across the +refactor, plus `tests/arena_pick.rs::parent_links_survive_structural_ops`. + +## Chapter map (concern → file → law → guarding test) + +| concern | spike file | model law | lab test | +| ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| camera (view ≠ document) | `src/camera.rs` | reads/writes split: pan/zoom write nothing | (host-only; no doc writes exist to test) | +| hit-testing | `lab/src/pick.rs` (model concern, NOT chrome) | oriented boxes via inverse world; lens hits post-ops; groups transparent-select to the OUTERMOST (GROUP.md) | `arena_pick.rs::pick_*` (5 tests) | +| pointer FSM | `src/interaction.rs` | states are data; transitions call ops; typed errors surface | ops layer: `ops_suite.rs`, `flip.rs` | +| gestures → writes | `src/shell/app.rs` (`pointer_move`) | delta-form ops re-target intent; a drag ends as if written once; parent-space conversion via world inverse | `ops_suite.rs::write_counts_match_doctrine` | +| resize through zero | `Drag::ResizeEdge/Corner` → `ops::resize_drag` | E-A14: \|extent\| + flip toggle + re-pin; out-and-back = identity; typed `set_width(−x)` stays a wall | `flip.rs::f4_*` (4 tests), `f5_typed_negative…` | +| rotate | `Drag::Rotate` | boxed = 1 write (center pivot); derived = 3-write center-feel | `rotation.rs::r1`, `ops_suite.rs`, `rotation.rs::group_origin_pivot…` | +| HUD chrome | `src/shell/hud.rs` | chrome derives from the resolved tier; derived local box = box − origin (E-A1); envelope readout (E-A7) | `derived.rs::d2_*`, `geometry.rs` | +| undo | snapshot stack in `app.rs` | the document is one value; history = values | `mm_laws.rs::mm7_add_delete_restores` | +| structure ops | `ops::delete` / `ops::ungroup` | subtree remove; ungroup = 3–5 write bake with mirror conjugation | `arena_pick.rs::delete_*`, `flip.rs::f6`, `mm_laws.rs::mm9` | +| the IR panel | `src/shell/app.rs::apply_ir` | the text IR is a full projection; parse errors are typed; round-trip is law | `textir_suite.rs::roundtrip_fixpoint` | +| reports as UI | panel "reports" section | §8 applicability outcomes are REPORTED, never silent | `layout.rs`, `edge_census.rs` (report assertions) | + +## The shell (non-textbook, by design) + +`src/shell/window.rs` is cribbed from `crates/grida_editor/src/shell/window.rs` +(winit 0.30 + glutin 0.32 + skia GL surface + egui_glow 0.35 overlay on +the ONE shared context, with the Ganesh `reset(None)` dance after egui's +raw GL). The windowing is chrome, not model — the migration keeps its +own shell and takes the chapters above. + +## Counterpart map (spike → grida_editor) + +| spike | grida_editor | note | +| -------------------------- | ------------------------------- | ----------------------------------------------------------------------------- | +| `interaction.rs` FSM | `hud/gesture.rs`, `tool.rs` | states-as-data; the spike's is minimal on purpose | +| `shell/hud.rs` | `hud/chrome.rs`, `hud/vocab.rs` | one geometry for paint AND hit (`handles()` shared) | +| `lab/pick.rs` | `hud/hit.rs` | spike puts hit in the MODEL crate — recommended for the migration | +| undo snapshots | `history.rs` | grida_editor has invertible mutations; snapshots are the spike's honest floor | +| `apply_ir` | `io.rs` / wire | text seam with typed errors | +| `shell/app.rs::paint_egui` | `shell/app.rs::paint_egui` | same recipe, same reset dance | + +## The legacy wasm seam (recorded for the migration, not exercised) + +`crates/grida-canvas-wasm` is a C-ABI seam: `#[no_mangle] extern "C"`, +strings in as ptr+len UTF-8, results out as 4-byte-LE length-prefixed +buffers via exported `_allocate`/`_deallocate` (JS mirror: +`lib/modules/ffi.ts` — `allocString`/`readLenPrefixedString`), pointer +events as packed-u32 responses, hit-test precedent +`_get_node_id_from_point`. When the anchor model crosses that boundary, +`resolve → draw list` and the op vocabulary here are the payload shapes +to encode; the spike deliberately did not build the boundary (owner: +no wasm — testing the model, not the transport). + +## Frictions found by hand (see SPIKE.md for the full list) + +Fed back into the register/docs rather than patched silently: +edge-resize of a ROTATED node steers in parent axes (usable, not +local-axis correct — a gesture-math follow-up for editor.md); text +measure-vs-render mismatch is visible (DEC-4/B-1, on purpose); skia +0.93 removed mutable `Path` (use `PathBuilder` — engine already does). diff --git a/model-v2/a/spike-canvas/shots/crosszero.png b/model-v2/a/spike-canvas/shots/crosszero.png new file mode 100644 index 0000000000000000000000000000000000000000..1ea2a83e604380a70b56d04a15ceeff42509ee3e GIT binary patch literal 29914 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 eSO2$$eT8rJ)-5ys@x@;-~40i6r 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 MDqO4J58Fro0=uZRXaE2J literal 0 HcmV?d00001 diff --git a/model-v2/a/spike-canvas/shots/rot45.png b/model-v2/a/spike-canvas/shots/rot45.png new file mode 100644 index 0000000000000000000000000000000000000000..f5a423d2aa972065a055398764241d140732406e GIT binary patch literal 30054 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±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 literal 0 HcmV?d00001 diff --git a/model-v2/a/spike-canvas/src/camera.rs b/model-v2/a/spike-canvas/src/camera.rs new file mode 100644 index 0000000000..d257b9a1b4 --- /dev/null +++ b/model-v2/a/spike-canvas/src/camera.rs @@ -0,0 +1,62 @@ +//! The camera is HOST state, never document state — panning and zooming +//! write nothing (the model's read/write split made visible). View = +//! T(tx,ty) · S(zoom); world = view⁻¹(screen). + +use anchor_lab::math::Affine; + +#[derive(Debug, Clone, Copy)] +pub struct Camera { + pub zoom: f32, + pub tx: f32, + pub ty: f32, +} + +impl Camera { + pub fn new() -> Camera { + Camera { + zoom: 1.0, + tx: 0.0, + ty: 0.0, + } + } + + pub fn view(&self) -> Affine { + Affine { + a: self.zoom, + b: 0.0, + c: 0.0, + d: self.zoom, + e: self.tx, + f: self.ty, + } + } + + pub fn screen_to_world(&self, p: (f32, f32)) -> (f32, f32) { + ((p.0 - self.tx) / self.zoom, (p.1 - self.ty) / self.zoom) + } + + pub fn pan(&mut self, dx: f32, dy: f32) { + self.tx += dx; + self.ty += dy; + } + + /// Zoom about a SCREEN point: the world point under the cursor stays + /// under the cursor. + pub fn zoom_about(&mut self, cursor: (f32, f32), factor: f32) { + let z = (self.zoom * factor).clamp(0.05, 32.0); + let k = z / self.zoom; + self.tx = cursor.0 - k * (cursor.0 - self.tx); + self.ty = cursor.1 - k * (cursor.1 - self.ty); + self.zoom = z; + } + + /// Fit a world rect into a viewport with a margin. + pub fn fit(&mut self, world: (f32, f32, f32, f32), viewport: (f32, f32), margin: f32) { + let (x, y, w, h) = world; + let zx = (viewport.0 - 2.0 * margin) / w; + let zy = (viewport.1 - 2.0 * margin) / h; + self.zoom = zx.min(zy).clamp(0.05, 32.0); + self.tx = (viewport.0 - w * self.zoom) / 2.0 - x * self.zoom; + self.ty = (viewport.1 - h * self.zoom) / 2.0 - y * self.zoom; + } +} diff --git a/model-v2/a/spike-canvas/src/interaction.rs b/model-v2/a/spike-canvas/src/interaction.rs new file mode 100644 index 0000000000..4ec2157721 --- /dev/null +++ b/model-v2/a/spike-canvas/src/interaction.rs @@ -0,0 +1,249 @@ +//! The minimal interaction model — an EXPLICIT pointer state machine +//! (counterpart: grida_editor hud/gesture.rs + tool.rs). States are data, +//! transitions call lab ops, typed errors surface into the gesture log — +//! nothing here mutates the document except through `anchor_lab::ops`. +//! +//! Idle -> Pressed(node) -> Dragging(Move | ResizeEdge | ResizeCorner +//! | Rotate | Pan) -> Idle +//! +//! A gesture's log entry is the HEADER DIFF (before vs after) — the +//! honest write count: a drag re-applies its op continuously, but the +//! document ends as if written once (delta-form ops make that true). + +use anchor_lab::math::Affine; +use anchor_lab::model::*; +use anchor_lab::ops::ResizeDrag; + +use crate::shell::hud::HandleKind; + +/// Screen-px tolerance for grabbing a handle. +pub const HANDLE_TOL: f32 = 10.0; +/// Drag starts after this many screen px (a click is not a move). +pub const DRAG_THRESHOLD: f32 = 3.0; + +#[derive(Debug)] +pub enum Drag { + Move { + id: NodeId, + last_screen: (f32, f32), + }, + ResizeEdge { + id: NodeId, + drag: ResizeDrag, + }, + ResizeCorner { + id: NodeId, + dx: ResizeDrag, + dy: ResizeDrag, + }, + Rotate { + id: NodeId, + center_screen: (f32, f32), + grab_deg: f32, + start_rot: f32, + derived: bool, + }, + Pan { + last_screen: (f32, f32), + }, +} + +#[derive(Debug, Default)] +pub enum Fsm { + #[default] + Idle, + Pressed { + id: NodeId, + at_screen: (f32, f32), + }, + Dragging(Drag), +} + +/// Per-gesture scratch: the before-header for the diff, and every typed +/// error the ops raised (deduped — a span axis errors once, not per px). +pub struct Gesture { + pub id: NodeId, + pub title: String, + pub before: Header, + pub errors: Vec, +} + +impl Gesture { + pub fn begin(doc: &Document, id: NodeId, title: &str) -> Gesture { + Gesture { + id, + title: title.to_string(), + before: doc.get(id).header.clone(), + errors: Vec::new(), + } + } + pub fn error(&mut self, e: String) { + if !self.errors.contains(&e) { + self.errors.push(e); + } + } +} + +/// One finished gesture (or instant op) for the log panel. +pub struct LogEntry { + pub title: String, + pub writes: Vec, + pub errors: Vec, +} + +fn fmt_num(v: f32) -> String { + let r = (v * 10.0).round() / 10.0; + if r == r.trunc() { + format!("{}", r as i64) + } else { + format!("{r}") + } +} + +fn fmt_binding(b: &AxisBinding) -> String { + match b { + AxisBinding::Pin { anchor, offset } => match anchor { + AnchorEdge::Start => fmt_num(*offset), + AnchorEdge::Center => format!("center {}", fmt_num(*offset)), + AnchorEdge::End => format!("end {}", fmt_num(*offset)), + }, + AxisBinding::Span { start, end } => { + format!("span {} {}", fmt_num(*start), fmt_num(*end)) + } + } +} + +fn fmt_size(s: &SizeIntent) -> String { + match s { + SizeIntent::Fixed(v) => fmt_num(*v), + SizeIntent::Auto => "auto".into(), + } +} + +/// The header diff — each changed field is one write line. +pub fn diff_header(before: &Header, after: &Header) -> Vec { + let mut out = Vec::new(); + if before.x != after.x { + out.push(format!("x: {} -> {}", fmt_binding(&before.x), fmt_binding(&after.x))); + } + if before.y != after.y { + out.push(format!("y: {} -> {}", fmt_binding(&before.y), fmt_binding(&after.y))); + } + if before.width != after.width { + out.push(format!( + "w: {} -> {}", + fmt_size(&before.width), + fmt_size(&after.width) + )); + } + if before.height != after.height { + out.push(format!( + "h: {} -> {}", + fmt_size(&before.height), + fmt_size(&after.height) + )); + } + if before.rotation != after.rotation { + out.push(format!( + "rotation: {} -> {}", + fmt_num(before.rotation), + fmt_num(after.rotation) + )); + } + if before.flip_x != after.flip_x { + out.push(format!("flip-x: {} -> {}", before.flip_x, after.flip_x)); + } + if before.flip_y != after.flip_y { + out.push(format!("flip-y: {} -> {}", before.flip_y, after.flip_y)); + } + out +} + +/// Which handle (if any) is under the cursor — same geometry the HUD +/// paints, so hit and chrome can never disagree. +pub fn handle_at( + handles: &[(HandleKind, (f32, f32))], + cursor: (f32, f32), + dpr: f32, +) -> Option { + let tol = HANDLE_TOL * dpr; + // Rotate first: it sits outside the box and must win over corners. + for (k, p) in handles { + if matches!(k, HandleKind::Rotate) && dist(*p, cursor) < tol { + return Some(*k); + } + } + for (k, p) in handles { + if dist(*p, cursor) < tol { + return Some(*k); + } + } + None +} + +pub fn dist(a: (f32, f32), b: (f32, f32)) -> f32 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +/// Resize drags per handle: which axis/axes move, which edge stays +/// FIXED (the anchor — opposite of the grabbed handle). Local edge +/// numbering: Edge 0=T 1=R 2=B 3=L; Corner 0=TL 1=TR 2=BR 3=BL. +pub fn resize_anchors(kind: HandleKind) -> (Option, Option) { + match kind { + HandleKind::Edge(1) => (Some(AnchorEdge::Start), None), // drag R, L fixed + HandleKind::Edge(3) => (Some(AnchorEdge::End), None), // drag L, R fixed + HandleKind::Edge(2) => (None, Some(AnchorEdge::Start)), // drag B, T fixed + HandleKind::Edge(0) => (None, Some(AnchorEdge::End)), // drag T, B fixed + HandleKind::Corner(2) => (Some(AnchorEdge::Start), Some(AnchorEdge::Start)), + HandleKind::Corner(0) => (Some(AnchorEdge::End), Some(AnchorEdge::End)), + HandleKind::Corner(1) => (Some(AnchorEdge::Start), Some(AnchorEdge::End)), + HandleKind::Corner(3) => (Some(AnchorEdge::End), Some(AnchorEdge::Start)), + _ => (None, None), + } +} + +/// Cursor position in the node's PARENT space — ops speak parent space +/// (bindings, resize anchors), the pointer speaks screen space; this is +/// the one conversion, done right (parent world inverse, not a guess). +pub fn parent_point( + doc: &Document, + resolved: &anchor_lab::resolve::Resolved, + camera: &crate::camera::Camera, + id: NodeId, + screen: (f32, f32), +) -> (f32, f32) { + let world = camera.screen_to_world(screen); + match doc.parent_of(id) { + Some(p) => match resolved.world_opt(p).and_then(|w| w.invert()) { + Some(inv) => inv.apply(world), + None => world, + }, + None => world, + } +} + +/// Angle (degrees) of the cursor about a screen center — for the rotate +/// gesture. Zero at +x, increasing clockwise (y-down screen space, +/// matching the model's rotation sign). +pub fn screen_angle(center: (f32, f32), p: (f32, f32)) -> f32 { + (p.1 - center.1).atan2(p.0 - center.0).to_degrees() +} + +/// Compose a world-space affine's screen position for a node's box +/// center (rotate-gesture pivot). +pub fn box_center_screen( + doc: &Document, + resolved: &anchor_lab::resolve::Resolved, + camera: &crate::camera::Camera, + id: NodeId, +) -> (f32, f32) { + if doc.get(id).payload.box_is_derived() { + // Derived kinds pivot the GESTURE at the ink center (matches the + // chrome, which shows ink bounds under DEC-0/V-4). + let a = resolved.aabb_of(id); + return camera.view().apply((a.x + a.w / 2.0, a.y + a.h / 2.0)); + } + let lb = crate::shell::hud::local_box(doc, resolved, id); + let t: Affine = camera.view().then(&resolved.world_of(id)); + t.apply((lb.x + lb.w / 2.0, lb.y + lb.h / 2.0)) +} diff --git a/model-v2/a/spike-canvas/src/main.rs b/model-v2/a/spike-canvas/src/main.rs new file mode 100644 index 0000000000..a4ebd2612a --- /dev/null +++ b/model-v2/a/spike-canvas/src/main.rs @@ -0,0 +1,169 @@ +//! E10 — the feel spike. `cargo run --release` opens the window; +//! `--shot out.png [state]` renders headless to PNG (self-verification); +//! `--bench` prints resolve+paint timings. +//! +//! The loop is the thesis: `document -> resolve (full) -> paint`, +//! immediate, no caches — the lab resolver is fast enough that the +//! editor holds no derived state at all (see TEXTBOOK.md). + +mod camera; +mod interaction; +mod paint; +mod scene; +mod shell; + +use anchor_lab::model::Document; +use anchor_lab::resolve::{resolve, Resolved, ResolveOptions}; +use camera::Camera; +use paint::Painter; + +/// The resolve viewport — the root frame spans it; think "world extent +/// of the infinite canvas' initial container", not the window. +pub const RESOLVE_VIEWPORT: (f32, f32) = (2000.0, 1400.0); + +pub fn resolve_doc(doc: &Document) -> Resolved { + resolve( + doc, + &ResolveOptions { + viewport: RESOLVE_VIEWPORT, + ..Default::default() + }, + ) +} + +fn main() { + let args: Vec = std::env::args().collect(); + if let Some(i) = args.iter().position(|a| a == "--shot") { + let path = args.get(i + 1).cloned().unwrap_or("spike.png".into()); + let state = args.get(i + 2).cloned().unwrap_or_default(); + shot(&path, &state); + return; + } + if args.iter().any(|a| a == "--bench") { + bench(); + return; + } + shell::run(); +} + +/// Headless render: paint the starter scene (after an optional scripted +/// state) into a raster surface and encode PNG — no window, no GL. +fn shot(path: &str, state: &str) { + let (mut doc, artboard) = scene::starter(); + let mut selection: Option = None; + + match state { + "crosszero" => { + // Drag card.a's right edge THROUGH the left edge: |extent| + + // flip toggle + re-pin (E-A14). Scripted exactly as the + // gesture would issue it. + let id = find(&doc, "card.a"); + let r = resolve_doc(&doc); + let drag = anchor_lab::ops::ResizeDrag::begin( + &doc, + &r, + id, + anchor_lab::ops::Axis::X, + anchor_lab::model::AnchorEdge::End, + ) + .unwrap(); + let b = r.box_of(id); + let target = b.x + b.w + 50.0; // 50px past the FIXED right edge + let r = resolve_doc(&doc); + anchor_lab::ops::resize_drag(&mut doc, &r, id, &drag, target).unwrap(); + selection = Some(id); + } + "ungroup" => { + let id = find(&doc, "chips"); + let r = resolve_doc(&doc); + anchor_lab::ops::ungroup(&mut doc, &r, id).unwrap(); + } + "rot45" => { + let id = find(&doc, "card.rot"); + anchor_lab::ops::set_rotation(&mut doc, id, 45.0).unwrap(); + selection = Some(id); + } + _ => { + selection = Some(find(&doc, "card.rot")); + } + } + + let (w, h) = (1360, 900); + let mut surface = + skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + let resolved = resolve_doc(&doc); + let mut cam = Camera::new(); + let ab = resolved.aabb_of(artboard); + cam.fit((ab.x, ab.y, ab.w, ab.h), (w as f32, h as f32), 48.0); + + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::from_argb(255, 0xF7, 0xF8, 0xF9)); + let painter = Painter::new(); + painter.paint_scene(canvas, &doc, &resolved, &cam); + shell::hud::paint_hud(canvas, &doc, &resolved, &cam, selection, None, &painter); + + let image = surface.image_snapshot(); + let data = image + .encode(None, skia_safe::EncodedImageFormat::PNG, None) + .expect("png encode"); + std::fs::write(path, data.as_bytes()).expect("write png"); + println!("wrote {path} ({} bytes)", data.len()); +} + +fn find(doc: &Document, name: &str) -> anchor_lab::model::NodeId { + scene::find_named(doc, name).expect("named node") +} + +/// Resolve + paint wall time on raster, starter scene and synthetic +/// flat canvases — the seam numbers phase 4 wants (native, no boundary). +fn bench() { + use anchor_lab::model::*; + use std::time::Instant; + + let mut surface = skia_safe::surfaces::raster_n32_premul((1360, 900)).unwrap(); + let painter = Painter::new(); + let cam = Camera::new(); + + let mut run = |label: &str, doc: &Document| { + let mut resolve_ms = f64::MAX; + let mut paint_ms = f64::MAX; + for _ in 0..11 { + let t0 = Instant::now(); + let resolved = resolve_doc(doc); + let t1 = Instant::now(); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + painter.paint_scene(canvas, doc, &resolved, &cam); + let t2 = Instant::now(); + resolve_ms = resolve_ms.min((t1 - t0).as_secs_f64() * 1000.0); + paint_ms = paint_ms.min((t2 - t1).as_secs_f64() * 1000.0); + } + println!( + "{label:26} {:5} nodes resolve {resolve_ms:7.3} ms paint {paint_ms:7.3} ms frame {:7.3} ms", + doc.len(), + resolve_ms + paint_ms + ); + }; + + let (starter, _) = scene::starter(); + run("starter scene", &starter); + + for n in [100usize, 1000, 10000] { + let mut b = DocBuilder::new(); + for i in 0..n { + let mut h = Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(28.0)); + h.x = AxisBinding::start((i % 100) as f32 * 19.0); + h.y = AxisBinding::start((i / 100) as f32 * 13.0); + h.rotation = (i % 7) as f32 * 5.0; + b.add( + 0, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + } + let doc = b.build(); + run(&format!("flat canvas ({n})"), &doc); + } +} diff --git a/model-v2/a/spike-canvas/src/paint.rs b/model-v2/a/spike-canvas/src/paint.rs new file mode 100644 index 0000000000..039ed914d5 --- /dev/null +++ b/model-v2/a/spike-canvas/src/paint.rs @@ -0,0 +1,128 @@ +//! Scene painter: resolved SOA columns → Skia canvas. Dumb by design — +//! every world transform comes from the resolver (lens children arrive +//! post-ops), the camera is one outer matrix, and nothing here writes. +//! +//! Declared spike limitation: text renders single-line via the default +//! typeface while the MODEL measures with the lab stub — the mismatch is +//! DEC-4/B-1 territory and stays visible, not patched over. + +use anchor_lab::math::Affine; +use anchor_lab::model::*; +use anchor_lab::resolve::Resolved; +use skia_safe::{Canvas, Color, Font, FontMgr, Matrix, Paint, PaintStyle, Rect}; + +use crate::camera::Camera; + +pub struct Painter { + pub font: Option, +} + +impl Painter { + pub fn new() -> Painter { + Painter { + font: FontMgr::new().legacy_make_typeface(None, skia_safe::FontStyle::default()), + } + } + + pub fn skia_matrix(t: &Affine) -> Matrix { + Matrix::new_all(t.a, t.c, t.e, t.b, t.d, t.f, 0.0, 0.0, 1.0) + } + + pub fn color(hex: Option<&str>, fallback: Color) -> Color { + let Some(h) = hex else { return fallback }; + let h = h.trim_start_matches('#'); + if h.len() == 6 { + if let Ok(v) = u32::from_str_radix(h, 16) { + return Color::from_argb( + 255, + ((v >> 16) & 0xff) as u8, + ((v >> 8) & 0xff) as u8, + (v & 0xff) as u8, + ); + } + } + fallback + } + + pub fn paint_scene( + &self, + canvas: &Canvas, + doc: &Document, + resolved: &Resolved, + camera: &Camera, + ) { + self.paint_node(canvas, doc, resolved, camera, doc.root); + } + + fn paint_node( + &self, + canvas: &Canvas, + doc: &Document, + resolved: &Resolved, + camera: &Camera, + id: NodeId, + ) { + let Some(world) = resolved.world_opt(id) else { + return; // hidden subtree + }; + let node = doc.get(id); + let total = camera.view().then(&world); + + // Root is the infinite canvas backdrop — no ink of its own here. + if id != doc.root && !node.payload.box_is_derived() { + canvas.save(); + canvas.set_matrix(&Self::skia_matrix(&total).into()); + let b = resolved.box_of(id); + match &node.payload { + Payload::Frame { .. } => { + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_color(Self::color(node.fill.as_deref(), Color::WHITE)); + let r = Rect::from_wh(b.w, b.h); + canvas.draw_rect(r, &fill); + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(PaintStyle::Stroke); + stroke.set_stroke_width(1.0); + stroke.set_color(Color::from_argb(255, 0xC9, 0xCE, 0xD4)); + canvas.draw_rect(r, &stroke); + } + Payload::Shape { desc } => { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_color(Self::color(node.fill.as_deref(), Color::from_argb(255, 0x4A, 0x90, 0xD9))); + match desc { + ShapeDesc::Rect => { + canvas.draw_rect(Rect::from_wh(b.w, b.h), &p); + } + ShapeDesc::Ellipse => { + canvas.draw_oval(Rect::from_wh(b.w, b.h), &p); + } + ShapeDesc::Line => { + p.set_style(PaintStyle::Stroke); + p.set_stroke_width(2.0); + canvas.draw_line((0.0, 0.0), (b.w, 0.0), &p); + } + } + } + Payload::Text { content, font_size } => { + if let Some(tf) = &self.font { + let font = Font::new(tf.clone(), *font_size); + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_color(Self::color(node.fill.as_deref(), Color::BLACK)); + // Baseline approximation against the lab's box + // metric (top-anchored, 1.2em line) — declared. + canvas.draw_str(content.as_str(), (0.0, font_size * 0.85), &font, &p); + } + } + Payload::Group | Payload::Lens { .. } => unreachable!(), + } + canvas.restore(); + } + + for &c in &node.children { + self.paint_node(canvas, doc, resolved, camera, c); + } + } +} diff --git a/model-v2/a/spike-canvas/src/scene.rs b/model-v2/a/spike-canvas/src/scene.rs new file mode 100644 index 0000000000..168ce0b3ba --- /dev/null +++ b/model-v2/a/spike-canvas/src/scene.rs @@ -0,0 +1,184 @@ +//! The starter document — one scene that exercises every mechanism the +//! feel checklist walks: a flex row with a rotated child (envelope live), +//! a hug frame, end/span pins, a group, a lens escape, and free text. + +use anchor_lab::model::*; + +pub const ARTBOARD: &str = "artboard"; + +/// Find a node by header name (the spike's stable handle across IR +/// round-trips — the IR does not carry ids). +pub fn find_named(doc: &Document, name: &str) -> Option { + (0..doc.capacity() as u32).find(|id| { + doc.get_opt(*id) + .and_then(|n| n.header.name.as_deref()) + .map(|n| n == name) + .unwrap_or(false) + }) +} + +fn named(mut h: Header, name: &str) -> Header { + h.name = Some(name.to_string()); + h +} + +fn at(mut h: Header, x: f32, y: f32) -> Header { + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(y); + h +} + +fn card(w: f32, h: f32) -> Header { + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)) +} + +fn rect() -> Payload { + Payload::Shape { + desc: ShapeDesc::Rect, + } +} + +pub fn starter() -> (Document, NodeId) { + let mut b = DocBuilder::new(); + + // The artboard: an ordinary fixed frame — resizing it is ordinary + // writes on IT; its end/span-pinned children respond with ZERO + // writes of their own. + let mut ab = named(card(900.0, 640.0), ARTBOARD); + ab = at(ab, 60.0, 60.0); + let artboard = b.add( + 0, + ab, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + b.node_mut(artboard).fill = Some("#FFFFFF".into()); + + // Flex row: cards + a rotated card + a grow card. Under DEC-0 + // (visual-only) rotation is paint: the row does NOT reflow, fill + // never fights rotation (V-1/V-2), overlap is correct behavior. + let row = b.add( + artboard, + named(at(card(520.0, 150.0), 40.0, 40.0), "row"), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + gap_main: 10.0, + padding: EdgeInsets::all(10.0), + cross_align: CrossAlign::Center, + ..Default::default() + }, + clips_content: false, + }, + ); + b.node_mut(row).fill = Some("#F1F3F5".into()); + let a1 = b.add(row, named(card(70.0, 110.0), "card.a"), rect()); + b.node_mut(a1).fill = Some("#4A90D9".into()); + let mut rot = named(card(70.0, 110.0), "card.rot"); + rot.rotation = 20.0; + let a2 = b.add(row, rot, rect()); + b.node_mut(a2).fill = Some("#E2574C".into()); + let a3 = b.add( + row, + named(Header::new(SizeIntent::Auto, SizeIntent::Auto), "label"), + Payload::Text { + content: "revenue".into(), + font_size: 18.0, + }, + ); + b.node_mut(a3).fill = Some("#171A1F".into()); + let mut grow = named(card(70.0, 110.0), "card.grow"); + grow.grow = 1.0; + let a4 = b.add(row, grow, rect()); + b.node_mut(a4).fill = Some("#57B894".into()); + + // Hug frame: breathes when its rotated member turns. + let hug = b.add( + artboard, + named( + at(Header::new(SizeIntent::Auto, SizeIntent::Auto), 40.0, 240.0), + "hug", + ), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + gap_main: 10.0, + padding: EdgeInsets::all(10.0), + cross_align: CrossAlign::Center, + ..Default::default() + }, + clips_content: false, + }, + ); + b.node_mut(hug).fill = Some("#F1F3F5".into()); + let h1 = b.add(hug, named(card(60.0, 90.0), "hug.a"), rect()); + b.node_mut(h1).fill = Some("#8B7BD8".into()); + let mut h2h = named(card(60.0, 90.0), "hug.rot"); + h2h.rotation = 30.0; + let h2 = b.add(hug, h2h, rect()); + b.node_mut(h2).fill = Some("#E2A23F".into()); + + // End-pinned badge: tracks the artboard's right edge with 0 writes. + let mut badge = named(card(84.0, 48.0), "badge"); + badge.x = AxisBinding::end(32.0); + badge.y = AxisBinding::start(32.0); + let bd = b.add(artboard, badge, rect()); + b.node_mut(bd).fill = Some("#57B894".into()); + + // Span bar: stretches with the artboard, x is owned by the Span + // (dragging it sideways is a TYPED error, visible in the log). + let mut bar = named(Header::new(SizeIntent::Auto, SizeIntent::Fixed(16.0)), "bar"); + bar.x = AxisBinding::Span { + start: 32.0, + end: 32.0, + }; + bar.y = AxisBinding::end(24.0); + let br = b.add(artboard, bar, rect()); + b.node_mut(br).fill = Some("#E2A23F".into()); + + // Group: transparent-select, origin pivot, 3-write center-feel rotate. + let grp = b.add( + artboard, + named( + at(Header::new(SizeIntent::Auto, SizeIntent::Auto), 620.0, 240.0), + "chips", + ), + Payload::Group, + ); + let g1 = b.add(grp, named(card(56.0, 36.0), "chip.a"), rect()); + b.node_mut(g1).fill = Some("#8B7BD8".into()); + let g2 = b.add(grp, named(at(card(56.0, 36.0), 26.0, 46.0), "chip.b"), rect()); + b.node_mut(g2).fill = Some("#E2574C".into()); + + // Lens: the paint lane — rotates visually, layout-transparent. + let lens = b.add( + artboard, + named( + at(Header::new(SizeIntent::Auto, SizeIntent::Auto), 620.0, 380.0), + "lens", + ), + Payload::Lens { + ops: vec![LensOp::Rotate { deg: 25.0 }], + }, + ); + let l1 = b.add(lens, named(card(80.0, 80.0), "lens.child"), rect()); + b.node_mut(l1).fill = Some("#4A90D9".into()); + + // Free text. + let t = b.add( + artboard, + named( + at(Header::new(SizeIntent::Auto, SizeIntent::Auto), 40.0, 560.0), + "title", + ), + Payload::Text { + content: "anchor spike — E10".into(), + font_size: 24.0, + }, + ); + b.node_mut(t).fill = Some("#171A1F".into()); + + (b.build(), artboard) +} diff --git a/model-v2/a/spike-canvas/src/shell/app.rs b/model-v2/a/spike-canvas/src/shell/app.rs new file mode 100644 index 0000000000..1dfd3032b0 --- /dev/null +++ b/model-v2/a/spike-canvas/src/shell/app.rs @@ -0,0 +1,895 @@ +//! The app loop: window events → interaction FSM → lab ops → resolve → +//! paint. The document is the ONLY mutable truth; undo is a snapshot +//! stack of it; every mutation goes through `anchor_lab::ops` and lands +//! in the gesture log as a header diff (writes) + typed errors. + +use std::num::NonZeroU32; + +use anchor_lab::model::{Document, NodeId}; +use anchor_lab::ops::{self, Axis, ResizeDrag}; +use anchor_lab::pick::pick; +use glutin::prelude::GlSurface; +use winit::application::ApplicationHandler; +use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; +use winit::event_loop::ActiveEventLoop; +use winit::keyboard::{Key, ModifiersState, NamedKey}; + +use super::window::WindowInit; +use crate::camera::Camera; +use crate::interaction::{ + box_center_screen, diff_header, dist, handle_at, parent_point, resize_anchors, screen_angle, + Drag, Fsm, Gesture, LogEntry, DRAG_THRESHOLD, +}; +use crate::paint::Painter; +use crate::shell::hud::{self, HandleKind}; +use crate::{resolve_doc, scene}; + +pub struct App { + // document + editor state + pub doc: Document, + pub artboard: NodeId, + pub selection: Option, + pub hover: Option, + pub undo: Vec, + pub redo: Vec, + pub log: Vec, + + // interaction + pub fsm: Fsm, + pub gesture: Option, + pub space_held: bool, + pub cursor: (f32, f32), + pub modifiers: ModifiersState, + + // view + pub camera: Camera, + pub painter: Painter, + pub dpr: f32, + pub last_frame_ms: f32, + + // panels (egui overlay on the shared GL context) + egui_ctx: egui::Context, + egui_winit: egui_winit::State, + egui_painter: egui_glow::Painter, + pub ir_draft: String, + pub ir_dirty: bool, + pub ir_error: Option, + + // shell + gpu: super::window::GpuSurface, + window: winit::window::Window, + gl_surface: glutin::surface::Surface, + gl_context: glutin::context::PossiblyCurrentContext, + exiting: bool, +} + +pub fn run(init: WindowInit) { + let WindowInit { + gpu, + el, + window, + gl_surface, + gl_context, + scale_factor, + glow_context, + } = init; + + // egui overlay — the grida_editor egui-spike pattern: one GL + // context, two painters (Skia below, egui above). + let egui_ctx = egui::Context::default(); + egui_ctx.set_visuals(egui::Visuals::light()); + let egui_winit = egui_winit::State::new( + egui_ctx.clone(), + egui::ViewportId::ROOT, + &window, + Some(scale_factor as f32), + None, + None, + ); + let egui_painter = egui_glow::Painter::new(glow_context, "", None, false) + .expect("failed to create egui_glow painter"); + + let (doc, artboard) = scene::starter(); + let mut app = App { + doc, + artboard, + selection: None, + hover: None, + undo: Vec::new(), + redo: Vec::new(), + log: Vec::new(), + fsm: Fsm::Idle, + gesture: None, + space_held: false, + cursor: (0.0, 0.0), + modifiers: ModifiersState::default(), + camera: Camera::new(), + painter: Painter::new(), + dpr: scale_factor as f32, + last_frame_ms: 0.0, + egui_ctx, + egui_winit, + egui_painter, + ir_draft: String::new(), + ir_dirty: false, + ir_error: None, + gpu, + window, + gl_surface, + gl_context, + exiting: false, + }; + + app.fit_artboard(); + el.run_app(&mut app).expect("event loop"); +} + +impl App { + fn fit_artboard(&mut self) { + let resolved = resolve_doc(&self.doc); + let ab = resolved.aabb_of(self.artboard); + let size = self.window.inner_size(); + self.camera.fit( + (ab.x, ab.y, ab.w, ab.h), + (size.width as f32, size.height as f32), + 60.0 * self.dpr, + ); + } + + fn name_of(&self, id: NodeId) -> String { + self.doc + .get(id) + .header + .name + .clone() + .unwrap_or_else(|| format!("#{id}")) + } + + fn push_undo(&mut self) { + self.undo.push(self.doc.clone()); + if self.undo.len() > 64 { + self.undo.remove(0); + } + self.redo.clear(); + } + + fn log_push(&mut self, entry: LogEntry) { + self.log.push(entry); + if self.log.len() > 40 { + self.log.remove(0); + } + } + + fn undo(&mut self) { + if let Some(prev) = self.undo.pop() { + self.redo.push(std::mem::replace(&mut self.doc, prev)); + self.after_structural(); + self.log_push(LogEntry { + title: format!("undo (depth {})", self.undo.len()), + writes: vec![], + errors: vec![], + }); + } + } + + fn redo(&mut self) { + if let Some(next) = self.redo.pop() { + self.undo.push(std::mem::replace(&mut self.doc, next)); + self.after_structural(); + self.log_push(LogEntry { + title: "redo".into(), + writes: vec![], + errors: vec![], + }); + } + } + + /// After undo/redo/delete: drop dangling references. + fn after_structural(&mut self) { + self.selection = self.selection.filter(|id| self.doc.get_opt(*id).is_some()); + self.hover = self.hover.filter(|id| self.doc.get_opt(*id).is_some()); + self.fsm = Fsm::Idle; + self.gesture = None; + } + + // ── pointer ───────────────────────────────────────────────────── + + fn pointer_down(&mut self) { + if self.space_held { + self.fsm = Fsm::Dragging(Drag::Pan { + last_screen: self.cursor, + }); + return; + } + let resolved = resolve_doc(&self.doc); + + // Handle grab on the current selection wins over picking. + if let Some(sel) = self.selection { + if resolved.world_opt(sel).is_some() { + let hs = hud::handles(&self.doc, &resolved, &self.camera, sel, self.dpr); + if let Some(kind) = handle_at(&hs, self.cursor, self.dpr) { + self.begin_handle_drag(kind, sel); + return; + } + } + } + + let (wx, wy) = self.camera.screen_to_world(self.cursor); + match pick(&self.doc, &resolved, wx, wy) { + Some(hit) if hit != self.doc.root => { + self.selection = Some(hit); + self.fsm = Fsm::Pressed { + id: hit, + at_screen: self.cursor, + }; + } + _ => { + self.selection = None; + self.fsm = Fsm::Idle; + } + } + } + + fn begin_handle_drag(&mut self, kind: HandleKind, id: NodeId) { + let resolved = resolve_doc(&self.doc); + match kind { + HandleKind::Rotate => { + self.push_undo(); + self.gesture = Some(Gesture::begin( + &self.doc, + id, + &format!("rotate '{}'", self.name_of(id)), + )); + let center = box_center_screen(&self.doc, &resolved, &self.camera, id); + self.fsm = Fsm::Dragging(Drag::Rotate { + id, + center_screen: center, + grab_deg: screen_angle(center, self.cursor), + start_rot: self.doc.get(id).header.rotation, + derived: self.doc.get(id).payload.box_is_derived(), + }); + } + HandleKind::Edge(_) | HandleKind::Corner(_) => { + let (ax, ay) = resize_anchors(kind); + let begin = |axis, anchor| ResizeDrag::begin(&self.doc, &resolved, id, axis, anchor); + let dx = ax.map(|a| begin(Axis::X, a)); + let dy = ay.map(|a| begin(Axis::Y, a)); + // A derived box refuses resize — the wall is a log line, + // not a silent nothing. + if matches!(dx, Some(Err(_))) || matches!(dy, Some(Err(_))) { + let e = dx + .and_then(|r| r.err()) + .or(dy.and_then(|r| r.err())) + .unwrap(); + self.log_push(LogEntry { + title: format!("resize '{}'", self.name_of(id)), + writes: vec![], + errors: vec![format!("w/h: Err({e:?})")], + }); + return; + } + self.push_undo(); + self.gesture = Some(Gesture::begin( + &self.doc, + id, + &format!("resize '{}'", self.name_of(id)), + )); + let dx = dx.map(|r| r.unwrap()); + let dy = dy.map(|r| r.unwrap()); + self.fsm = match (dx, dy) { + (Some(dx), Some(dy)) => Fsm::Dragging(Drag::ResizeCorner { id, dx, dy }), + (Some(drag), None) | (None, Some(drag)) => { + Fsm::Dragging(Drag::ResizeEdge { id, drag }) + } + (None, None) => Fsm::Idle, + }; + } + } + } + + fn pointer_move(&mut self) { + // Threshold: a Pressed node becomes a Move drag after 3px. + if let Fsm::Pressed { id, at_screen } = self.fsm { + if dist(self.cursor, at_screen) > DRAG_THRESHOLD * self.dpr { + self.push_undo(); + self.gesture = Some(Gesture::begin( + &self.doc, + id, + &format!("move '{}'", self.name_of(id)), + )); + self.fsm = Fsm::Dragging(Drag::Move { + id, + last_screen: at_screen, + }); + } + } + + match &mut self.fsm { + Fsm::Dragging(Drag::Pan { last_screen }) => { + let (lx, ly) = *last_screen; + self.camera.pan(self.cursor.0 - lx, self.cursor.1 - ly); + *last_screen = self.cursor; + } + Fsm::Dragging(Drag::Move { id, last_screen }) => { + let id = *id; + let last = *last_screen; + *last_screen = self.cursor; + let resolved = resolve_doc(&self.doc); + let p0 = parent_point(&self.doc, &resolved, &self.camera, id, last); + let p1 = parent_point(&self.doc, &resolved, &self.camera, id, self.cursor); + let b = resolved.box_of(id); + let rx = ops::set_x(&mut self.doc, &resolved, id, b.x + (p1.0 - p0.0)); + let ry = ops::set_y(&mut self.doc, &resolved, id, b.y + (p1.1 - p0.1)); + if let Some(g) = &mut self.gesture { + if let Err(e) = rx { + g.error(format!("x: Err({e:?})")); + } + if let Err(e) = ry { + g.error(format!("y: Err({e:?})")); + } + } + } + Fsm::Dragging(Drag::ResizeEdge { id, drag }) => { + let id = *id; + let drag = *drag; + let resolved = resolve_doc(&self.doc); + let p = parent_point(&self.doc, &resolved, &self.camera, id, self.cursor); + let target = match drag.axis { + Axis::X => p.0, + Axis::Y => p.1, + }; + let r = ops::resize_drag(&mut self.doc, &resolved, id, &drag, target); + if let (Some(g), Err(e)) = (&mut self.gesture, r) { + g.error(format!("{:?}: Err({e:?})", drag.axis)); + } + } + Fsm::Dragging(Drag::ResizeCorner { id, dx, dy }) => { + let id = *id; + let (dx, dy) = (*dx, *dy); + let r1 = resolve_doc(&self.doc); + let p = parent_point(&self.doc, &r1, &self.camera, id, self.cursor); + let rx = ops::resize_drag(&mut self.doc, &r1, id, &dx, p.0); + let r2 = resolve_doc(&self.doc); + let p = parent_point(&self.doc, &r2, &self.camera, id, self.cursor); + let ry = ops::resize_drag(&mut self.doc, &r2, id, &dy, p.1); + if let Some(g) = &mut self.gesture { + if let Err(e) = rx { + g.error(format!("x: Err({e:?})")); + } + if let Err(e) = ry { + g.error(format!("y: Err({e:?})")); + } + } + } + Fsm::Dragging(Drag::Rotate { + id, + center_screen, + grab_deg, + start_rot, + derived, + }) => { + let id = *id; + let mut deg = *start_rot + screen_angle(*center_screen, self.cursor) - *grab_deg; + if self.modifiers.shift_key() { + deg = (deg / 15.0).round() * 15.0; + } + let derived = *derived; + let r = if derived { + let resolved = resolve_doc(&self.doc); + ops::rotate_derived_center_feel(&mut self.doc, &resolved, id, deg) + } else { + ops::set_rotation(&mut self.doc, id, deg) + }; + if let (Some(g), Err(e)) = (&mut self.gesture, r) { + g.error(format!("rotation: Err({e:?})")); + } + } + _ => { + // Idle / Pressed: hover tracking only. + let resolved = resolve_doc(&self.doc); + let (wx, wy) = self.camera.screen_to_world(self.cursor); + self.hover = pick(&self.doc, &resolved, wx, wy).filter(|h| *h != self.doc.root); + } + } + } + + fn pointer_up(&mut self) { + let fsm = std::mem::take(&mut self.fsm); + if let Fsm::Dragging(drag) = fsm { + let mutated = !matches!(drag, Drag::Pan { .. }); + if mutated { + if let Some(g) = self.gesture.take() { + let writes = diff_header(&g.before, &self.doc.get(g.id).header); + if writes.is_empty() && g.errors.is_empty() { + // No-op gesture: drop the snapshot it reserved. + self.undo.pop(); + } else { + self.log_push(LogEntry { + title: g.title, + writes, + errors: g.errors, + }); + } + } + } + } + } + + // ── keys ──────────────────────────────────────────────────────── + + fn key(&mut self, key: Key, pressed: bool) { + if key == Key::Named(NamedKey::Space) { + self.space_held = pressed; + return; + } + if !pressed { + return; + } + let primary = self.modifiers.super_key() || self.modifiers.control_key(); + match key { + Key::Named(NamedKey::Escape) => { + self.selection = None; + } + Key::Named(NamedKey::Backspace) | Key::Named(NamedKey::Delete) => { + if let Some(id) = self.selection { + let name = self.name_of(id); + self.push_undo(); + match ops::delete(&mut self.doc, id) { + Ok(n) => { + self.after_structural(); + self.log_push(LogEntry { + title: format!("delete '{name}'"), + writes: vec![format!("{n} node(s) removed")], + errors: vec![], + }); + } + Err(e) => { + self.undo.pop(); + self.log_push(LogEntry { + title: format!("delete '{name}'"), + writes: vec![], + errors: vec![format!("Err({e:?})")], + }); + } + } + } + } + Key::Named( + k @ (NamedKey::ArrowLeft + | NamedKey::ArrowRight + | NamedKey::ArrowUp + | NamedKey::ArrowDown), + ) => { + if let Some(id) = self.selection { + let step = if self.modifiers.shift_key() { 10.0 } else { 1.0 }; + let (dx, dy) = match k { + NamedKey::ArrowLeft => (-step, 0.0), + NamedKey::ArrowRight => (step, 0.0), + NamedKey::ArrowUp => (0.0, -step), + _ => (0.0, step), + }; + self.push_undo(); + let before = self.doc.get(id).header.clone(); + let resolved = resolve_doc(&self.doc); + let rx = ops::set_x(&mut self.doc, &resolved, id, resolved.box_of(id).x + dx); + let ry = ops::set_y(&mut self.doc, &resolved, id, resolved.box_of(id).y + dy); + let writes = diff_header(&before, &self.doc.get(id).header); + let mut errors = vec![]; + if let Err(e) = rx { + errors.push(format!("x: Err({e:?})")); + } + if let Err(e) = ry { + errors.push(format!("y: Err({e:?})")); + } + if writes.is_empty() && errors.is_empty() { + self.undo.pop(); + } else { + self.log_push(LogEntry { + title: format!("nudge '{}'", self.name_of(id)), + writes, + errors, + }); + } + } + } + Key::Character(ref c) if primary && c.as_str() == "z" => { + if self.modifiers.shift_key() { + self.redo(); + } else { + self.undo(); + } + } + Key::Character(ref c) if primary && self.modifiers.shift_key() && c.as_str() == "g" => { + if let Some(id) = self.selection { + let name = self.name_of(id); + let resolved = resolve_doc(&self.doc); + self.push_undo(); + match ops::ungroup(&mut self.doc, &resolved, id) { + Ok(n) => { + self.selection = None; + self.log_push(LogEntry { + title: format!("ungroup '{name}'"), + writes: vec![format!("{n} field writes (bake)")], + errors: vec![], + }); + } + Err(e) => { + self.undo.pop(); + self.log_push(LogEntry { + title: format!("ungroup '{name}'"), + writes: vec![], + errors: vec![format!("Err({e:?})")], + }); + } + } + } + } + Key::Character(ref c) if primary && c.as_str() == "0" => { + self.fit_artboard(); + } + Key::Character(ref c) if primary && (c.as_str() == "=" || c.as_str() == "+") => { + let size = self.window.inner_size(); + self.camera + .zoom_about((size.width as f32 / 2.0, size.height as f32 / 2.0), 1.2); + } + Key::Character(ref c) if primary && c.as_str() == "-" => { + let size = self.window.inner_size(); + self.camera + .zoom_about((size.width as f32 / 2.0, size.height as f32 / 2.0), 1.0 / 1.2); + } + _ => {} + } + } + + // ── paint ─────────────────────────────────────────────────────── + + fn draw(&mut self) { + let t0 = std::time::Instant::now(); + let resolved = resolve_doc(&self.doc); + let canvas = self.gpu.surface.canvas(); + canvas.clear(skia_safe::Color::from_argb(255, 0xF7, 0xF8, 0xF9)); + self.painter + .paint_scene(canvas, &self.doc, &resolved, &self.camera); + hud::paint_hud_dpr( + canvas, + &self.doc, + &resolved, + &self.camera, + self.selection, + self.hover, + &self.painter, + self.dpr, + ); + self.gpu.gr_context.flush_and_submit(); + // egui chrome — topmost, on the shared GL context. + self.paint_egui(resolved.reports.len()); + if let Err(e) = self.gl_surface.swap_buffers(&self.gl_context) { + eprintln!("swap_buffers: {e:?}"); + } + self.last_frame_ms = t0.elapsed().as_secs_f32() * 1000.0; + } + + // ── panels (egui) ─────────────────────────────────────────────── + + fn paint_egui(&mut self, _report_count: usize) { + use glow::HasContext as _; + + let raw = self.egui_winit.take_egui_input(&self.window); + let ctx = self.egui_ctx.clone(); + let full = ctx.run_ui(raw, |ui| self.build_panels(ui)); + self.egui_winit + .handle_platform_output(&self.window, full.platform_output); + let prims = ctx.tessellate(full.shapes, full.pixels_per_point); + let (w, h): (u32, u32) = self.window.inner_size().into(); + // Composite onto the frame Skia just drew (default framebuffer). + unsafe { + self.egui_painter + .gl() + .bind_framebuffer(glow::FRAMEBUFFER, None); + } + self.egui_painter.paint_and_update_textures( + [w, h], + full.pixels_per_point, + &prims, + &full.textures_delta, + ); + // egui issued raw GL — resync Ganesh before the next Skia frame + // (the grida_editor reset dance). + self.gpu.gr_context.reset(None); + if full + .viewport_output + .get(&egui::ViewportId::ROOT) + .map(|v| v.repaint_delay == std::time::Duration::ZERO) + .unwrap_or(false) + { + self.window.request_redraw(); + } + } + + fn build_panels(&mut self, ui: &mut egui::Ui) { + // Keep the draft mirroring the document until the user edits it. + if !self.ir_dirty { + self.ir_draft = anchor_lab::textir::print(&self.doc); + } + let resolved = resolve_doc(&self.doc); + + egui::Panel::right("spike-panel") + .exact_size(380.0) + .show(ui, |ui: &mut egui::Ui| { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.heading("anchor spike — E10"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("fit").clicked() { + self.fit_artboard(); + } + }); + }); + ui.label( + egui::RichText::new(format!( + "frame {:.2} ms · zoom {:.0}% · undo {} · redo {}", + self.last_frame_ms, + self.camera.zoom * 100.0, + self.undo.len(), + self.redo.len() + )) + .monospace() + .weak(), + ); + ui.separator(); + + // ── selection ──────────────────────────────────── + if let Some(id) = self.selection { + if let Some(node) = self.doc.get_opt(id) { + let b = resolved.box_of(id); + ui.label( + egui::RichText::new(format!( + "{} <{}> x {:.1} y {:.1} w {:.1} h {:.1}{}", + node.header.name.as_deref().unwrap_or("node"), + node.payload.kind_name(), + b.x, + b.y, + b.w, + b.h, + if node.header.rotation != 0.0 { + format!(" rot {:.1}°", node.header.rotation) + } else { + String::new() + } + )) + .monospace(), + ); + ui.label( + egui::RichText::new( + "reads are resolved xywh — intent may be end/span/auto", + ) + .weak() + .small(), + ); + ui.separator(); + } + } + + // ── resolver reports (this resolve) ────────────── + if !resolved.reports.is_empty() { + ui.label(egui::RichText::new("reports — this resolve").strong()); + for r in resolved.reports.iter().take(6) { + ui.label( + egui::RichText::new(format!("{r:?}")) + .monospace() + .small() + .color(egui::Color32::from_rgb(0xB9, 0x7F, 0x22)), + ); + } + if resolved.reports.len() > 6 { + ui.label( + egui::RichText::new(format!( + "… {} more", + resolved.reports.len() - 6 + )) + .weak() + .small(), + ); + } + ui.separator(); + } + + // ── writes log ─────────────────────────────────── + ui.label(egui::RichText::new("writes — per gesture, typed").strong()); + egui::ScrollArea::vertical() + .id_salt("log") + .max_height(170.0) + .show(ui, |ui| { + if self.log.is_empty() { + ui.label(egui::RichText::new("no gestures yet").weak()); + } + for e in self.log.iter().rev() { + ui.label( + egui::RichText::new(format!( + "{} — {} write{}", + e.title, + e.writes.len(), + if e.writes.len() == 1 { "" } else { "s" } + )) + .monospace() + .strong(), + ); + for wline in &e.writes { + ui.label( + egui::RichText::new(format!(" {wline}")) + .monospace() + .small() + .color(egui::Color32::from_rgb(0x2F, 0x7D, 0x5D)), + ); + } + for eline in &e.errors { + ui.label( + egui::RichText::new(format!(" {eline}")) + .monospace() + .small() + .color(egui::Color32::from_rgb(0xE2, 0x57, 0x4C)), + ); + } + } + }); + ui.separator(); + + // ── the IR, live + editable ────────────────────── + ui.horizontal(|ui| { + ui.label(egui::RichText::new("document IR").strong()); + if self.ir_dirty { + if ui.button("apply").clicked() { + self.apply_ir(); + } + if ui.button("discard").clicked() { + self.ir_dirty = false; + self.ir_error = None; + } + } else { + ui.label(egui::RichText::new("(edit to enable apply)").weak().small()); + } + }); + if let Some(err) = &self.ir_error { + ui.label( + egui::RichText::new(err) + .monospace() + .small() + .color(egui::Color32::from_rgb(0xE2, 0x57, 0x4C)), + ); + } + egui::ScrollArea::vertical().id_salt("ir").show(ui, |ui| { + let resp = ui.add( + egui::TextEdit::multiline(&mut self.ir_draft) + .code_editor() + .desired_width(f32::INFINITY) + .desired_rows(18), + ); + if resp.changed() { + self.ir_dirty = true; + } + }); + }); + } + + /// Apply the edited IR: parse → replace the document (undoable). + /// A bad IR is a TYPED parse error shown in place — the document + /// stays untouched (the op-layer doctrine at the text seam). + fn apply_ir(&mut self) { + match anchor_lab::textir::parse(&self.ir_draft) { + Ok(newdoc) => { + self.push_undo(); + self.doc = newdoc; + self.artboard = + scene::find_named(&self.doc, scene::ARTBOARD).unwrap_or(self.doc.root); + self.after_structural(); + self.ir_dirty = false; + self.ir_error = None; + self.log_push(LogEntry { + title: "apply IR".into(), + writes: vec!["document replaced (undoable)".into()], + errors: vec![], + }); + } + Err(e) => { + self.ir_error = Some(format!("{e}")); + } + } + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, _el: &ActiveEventLoop) { + self.window.request_redraw(); + } + + fn window_event( + &mut self, + el: &ActiveEventLoop, + _wid: winit::window::WindowId, + event: WindowEvent, + ) { + // UI-first arbitration (the grida_editor shell pattern): egui + // sees every event; input it is using must not also reach the + // canvas FSM. Non-input events always pass through. + let resp = self.egui_winit.on_window_event(&self.window, &event); + if resp.repaint { + self.window.request_redraw(); + } + let gated = match &event { + WindowEvent::CursorMoved { .. } + | WindowEvent::MouseInput { .. } + | WindowEvent::MouseWheel { .. } + | WindowEvent::PinchGesture { .. } => { + resp.consumed || self.egui_ctx.egui_wants_pointer_input() + } + WindowEvent::KeyboardInput { .. } | WindowEvent::Ime(_) => { + resp.consumed || self.egui_ctx.egui_wants_keyboard_input() + } + _ => false, + }; + if gated { + self.window.request_redraw(); + return; + } + match event { + WindowEvent::RedrawRequested => { + if !self.exiting { + self.draw(); + } + return; + } + WindowEvent::CloseRequested => { + self.exiting = true; + el.exit(); + return; + } + WindowEvent::Resized(size) => { + self.gl_surface.resize( + &self.gl_context, + NonZeroU32::new(size.width).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + NonZeroU32::new(size.height) + .unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + ); + self.gpu.recreate(size.width as i32, size.height as i32); + } + WindowEvent::ModifiersChanged(m) => { + self.modifiers = m.state(); + } + WindowEvent::CursorMoved { position, .. } => { + self.cursor = (position.x as f32, position.y as f32); + self.pointer_move(); + } + WindowEvent::MouseWheel { delta, .. } => { + let (dx, dy) = match delta { + MouseScrollDelta::LineDelta(x, y) => (x * 40.0, y * 40.0), + MouseScrollDelta::PixelDelta(p) => (p.x as f32, p.y as f32), + }; + if self.modifiers.super_key() || self.modifiers.control_key() { + self.camera.zoom_about(self.cursor, (dy / 240.0).exp()); + } else { + self.camera.pan(dx, dy); + } + } + WindowEvent::PinchGesture { delta, .. } => { + self.camera.zoom_about(self.cursor, (delta as f32).exp()); + } + WindowEvent::MouseInput { state, button, .. } => match (state, button) { + (ElementState::Pressed, MouseButton::Left) => self.pointer_down(), + (ElementState::Released, MouseButton::Left) => self.pointer_up(), + (ElementState::Pressed, MouseButton::Middle) => { + self.fsm = Fsm::Dragging(Drag::Pan { + last_screen: self.cursor, + }); + } + (ElementState::Released, MouseButton::Middle) => self.pointer_up(), + _ => {} + }, + WindowEvent::KeyboardInput { event, .. } => { + let pressed = event.state == ElementState::Pressed; + self.key(event.logical_key.clone(), pressed); + } + _ => return, + } + self.window.request_redraw(); + } +} diff --git a/model-v2/a/spike-canvas/src/shell/hud.rs b/model-v2/a/spike-canvas/src/shell/hud.rs new file mode 100644 index 0000000000..296a6e6d8c --- /dev/null +++ b/model-v2/a/spike-canvas/src/shell/hud.rs @@ -0,0 +1,238 @@ +//! HUD chrome — screen-space, painted AFTER the scene (counterpart: +//! grida_editor hud/chrome.rs). Everything here is derived from the +//! resolved tier + camera; the HUD owns no document state. +//! +//! The oriented selection outline uses the node's LOCAL box mapped +//! through its world transform — for derived kinds the local box is +//! recovered exactly as `box − origin` (E-A1: bindings place the origin; +//! the box is origin + union offset). + +use anchor_lab::math::RectF; +use anchor_lab::model::*; +use anchor_lab::resolve::Resolved; +use skia_safe::{Canvas, Color, Font, Paint, PaintStyle, PathBuilder, Rect}; + +use crate::camera::Camera; +use crate::paint::Painter; + +pub const HANDLE: f32 = 8.0; +pub const ROTATE_STICK: f32 = 26.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandleKind { + Corner(u8), // 0=TL 1=TR 2=BR 3=BL (local box order) + Edge(u8), // 0=T 1=R 2=B 3=L + Rotate, +} + +/// The node's local box: boxed kinds are (0,0,w,h); derived kinds recover +/// the union rect from box − origin (both in parent space, pre-rotation). +pub fn local_box(doc: &Document, r: &Resolved, id: NodeId) -> RectF { + let b = r.box_of(id); + if doc.get(id).payload.box_is_derived() { + let l = r.local_of(id); + RectF { + x: b.x - l.e, + y: b.y - l.f, + w: b.w, + h: b.h, + } + } else { + RectF { + x: 0.0, + y: 0.0, + w: b.w, + h: b.h, + } + } +} + +/// Screen-space corner positions of the selection chrome (TL,TR,BR,BL). +/// Boxed kinds: the oriented box through the world transform. Derived +/// kinds: the INK bounds (`world_aabb`) — under DEC-0/V-4 the derived +/// BOX is sizing-tier (unrotated union) and would under-cover rotated +/// members; chrome must cover what the user sees. +pub fn screen_corners(doc: &Document, r: &Resolved, cam: &Camera, id: NodeId) -> [(f32, f32); 4] { + if doc.get(id).payload.box_is_derived() { + let a = r.aabb_of(id); + let v = cam.view(); + return [ + v.apply((a.x, a.y)), + v.apply((a.x + a.w, a.y)), + v.apply((a.x + a.w, a.y + a.h)), + v.apply((a.x, a.y + a.h)), + ]; + } + let lb = local_box(doc, r, id); + let t = cam.view().then(&r.world_of(id)); + [ + t.apply((lb.x, lb.y)), + t.apply((lb.x + lb.w, lb.y)), + t.apply((lb.x + lb.w, lb.y + lb.h)), + t.apply((lb.x, lb.y + lb.h)), + ] +} + +/// Handle anchor points in screen space, shared by paint and hit-testing +/// (one geometry, two consumers — never disagree). +pub fn handles(doc: &Document, r: &Resolved, cam: &Camera, id: NodeId, dpr: f32) -> Vec<(HandleKind, (f32, f32))> { + let c = screen_corners(doc, r, cam, id); + let mid = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let top = mid(c[0], c[1]); + // "Up" along the box's own orientation: away from the bottom edge. + let bottom = mid(c[3], c[2]); + let up = { + let dx = top.0 - bottom.0; + let dy = top.1 - bottom.1; + let len = (dx * dx + dy * dy).sqrt().max(1e-3); + (dx / len, dy / len) + }; + let stick = ROTATE_STICK * dpr; + vec![ + (HandleKind::Corner(0), c[0]), + (HandleKind::Corner(1), c[1]), + (HandleKind::Corner(2), c[2]), + (HandleKind::Corner(3), c[3]), + (HandleKind::Edge(0), top), + (HandleKind::Edge(1), mid(c[1], c[2])), + (HandleKind::Edge(2), bottom), + (HandleKind::Edge(3), mid(c[0], c[3])), + (HandleKind::Rotate, (top.0 + up.0 * stick, top.1 + up.1 * stick)), + ] +} + +fn stroke(color: Color, width: f32) -> Paint { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_style(PaintStyle::Stroke); + p.set_stroke_width(width); + p.set_color(color); + p +} + +fn fill(color: Color) -> Paint { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_color(color); + p +} + +const BLUE: Color = Color::new(0xFF4A90D9); +const INK: Color = Color::new(0xFF171A1F); + +#[allow(clippy::too_many_arguments)] +pub fn paint_hud( + canvas: &Canvas, + doc: &Document, + resolved: &Resolved, + cam: &Camera, + selection: Option, + hover: Option, + painter: &Painter, +) { + paint_hud_dpr(canvas, doc, resolved, cam, selection, hover, painter, 1.0); +} + +#[allow(clippy::too_many_arguments)] +pub fn paint_hud_dpr( + canvas: &Canvas, + doc: &Document, + resolved: &Resolved, + cam: &Camera, + selection: Option, + hover: Option, + painter: &Painter, + dpr: f32, +) { + canvas.save(); + canvas.reset_matrix(); // HUD lives in screen space + + if let Some(id) = hover { + if Some(id) != selection && resolved.world_opt(id).is_some() { + let c = screen_corners(doc, resolved, cam, id); + let mut pb = PathBuilder::new(); + pb.move_to(c[0]); + pb.line_to(c[1]); + pb.line_to(c[2]); + pb.line_to(c[3]); + pb.close(); + canvas.draw_path(&pb.snapshot(), &stroke(Color::new(0x804A90D9), 1.5 * dpr)); + } + } + + if let Some(id) = selection { + if resolved.world_opt(id).is_some() { + let c = screen_corners(doc, resolved, cam, id); + let mut pb = PathBuilder::new(); + pb.move_to(c[0]); + pb.line_to(c[1]); + pb.line_to(c[2]); + pb.line_to(c[3]); + pb.close(); + canvas.draw_path(&pb.snapshot(), &stroke(BLUE, 1.5 * dpr)); + + let hs = HANDLE * dpr; + for (kind, (hx, hy)) in handles(doc, resolved, cam, id, dpr) { + match kind { + HandleKind::Corner(_) | HandleKind::Edge(_) => { + let r = Rect::from_xywh(hx - hs / 2.0, hy - hs / 2.0, hs, hs); + canvas.draw_rect(r, &fill(Color::WHITE)); + canvas.draw_rect(r, &stroke(BLUE, 1.2 * dpr)); + } + HandleKind::Rotate => { + // stick from the top-edge midpoint + let top = handles(doc, resolved, cam, id, dpr) + .iter() + .find(|(k, _)| *k == HandleKind::Edge(0)) + .map(|(_, p)| *p) + .unwrap(); + canvas.draw_line(top, (hx, hy), &stroke(BLUE, 1.2 * dpr)); + canvas.draw_circle((hx, hy), hs / 2.0, &fill(Color::WHITE)); + canvas.draw_circle((hx, hy), hs / 2.0, &stroke(BLUE, 1.2 * dpr)); + } + } + } + + // Ink readout (E-A7, demoted post-DEC-0): sizing never moves + // with rotation anymore; the readout explains box vs INK. + let node = doc.get(id); + if let Some(tf) = &painter.font { + let b = resolved.box_of(id); + let aabb = resolved.aabb_of(id); + let label = if node.header.rotation != 0.0 { + format!( + "{} — {:.0}×{:.0} box · {:.0}×{:.0} ink · {:.1}°", + node.header.name.as_deref().unwrap_or("node"), + b.w, + b.h, + aabb.w, + aabb.h, + node.header.rotation + ) + } else { + format!( + "{} — {:.0}×{:.0}", + node.header.name.as_deref().unwrap_or("node"), + b.w, + b.h + ) + }; + let font = Font::new(tf.clone(), 12.0 * dpr); + let anchor = c + .iter() + .fold((f32::MAX, f32::MAX), |acc, p| (acc.0.min(p.0), acc.1.min(p.1))); + let pos = (anchor.0, anchor.1 - 10.0 * dpr); + let mut bg = fill(Color::new(0xE6FFFFFF)); + bg.set_style(PaintStyle::Fill); + let (tw, _) = font.measure_str(&label, None); + canvas.draw_rect( + Rect::from_xywh(pos.0 - 3.0, pos.1 - 12.0 * dpr, tw + 6.0, 16.0 * dpr), + &bg, + ); + canvas.draw_str(&label, pos, &font, &fill(INK)); + } + } + } + + canvas.restore(); +} diff --git a/model-v2/a/spike-canvas/src/shell/mod.rs b/model-v2/a/spike-canvas/src/shell/mod.rs new file mode 100644 index 0000000000..c93eb08023 --- /dev/null +++ b/model-v2/a/spike-canvas/src/shell/mod.rs @@ -0,0 +1,13 @@ +//! The windowed shell — winit + glutin + Skia GL surface, with an +//! egui_glow overlay for the dev panels (the grida_editor shell recipe, +//! reduced). The shell owns no editing semantics: every edit flows +//! through the lab's ops layer via the interaction FSM. + +mod app; +pub mod hud; +mod window; + +pub fn run() { + let init = window::create_window("anchor spike — E10", 1280, 840); + app::run(init); +} diff --git a/model-v2/a/spike-canvas/src/shell/window.rs b/model-v2/a/spike-canvas/src/shell/window.rs new file mode 100644 index 0000000000..430643b362 --- /dev/null +++ b/model-v2/a/spike-canvas/src/shell/window.rs @@ -0,0 +1,206 @@ +//! Window + GL + Skia surface setup — cribbed from +//! `crates/grida_editor/src/shell/window.rs` (itself a spin-off of the +//! grida_dev winit host), reduced to what the spike needs and with the +//! surface state inlined (the spike does not depend on the grida crate). + +use gl::types::GLint; +use glutin::{ + config::{ConfigTemplateBuilder, GlConfig}, + context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext}, + display::{GetGlDisplay, GlDisplay}, + prelude::NotCurrentGlContext, + surface::{Surface as GlutinSurface, SurfaceAttributesBuilder, WindowSurface}, +}; +use glutin_winit::DisplayBuilder; +#[allow(deprecated)] +use raw_window_handle::HasRawWindowHandle; +use skia_safe::gpu; +use std::{ffi::CString, num::NonZeroU32}; +use winit::{ + dpi::LogicalSize, + event_loop::EventLoop, + window::{Window, WindowAttributes}, +}; + +/// The GPU surface bundle; `recreate` re-wraps the framebuffer after a +/// window resize (same fb_info, new dimensions). +pub struct GpuSurface { + pub gr_context: gpu::DirectContext, + pub fb_info: gpu::gl::FramebufferInfo, + pub num_samples: usize, + pub stencil_size: usize, + pub surface: skia_safe::Surface, +} + +impl GpuSurface { + pub fn recreate(&mut self, width: i32, height: i32) { + let backend_render_target = gpu::backend_render_targets::make_gl( + (width, height), + self.num_samples, + self.stencil_size, + self.fb_info, + ); + self.surface = gpu::surfaces::wrap_backend_render_target( + &mut self.gr_context, + &backend_render_target, + gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("could not recreate skia surface"); + } +} + +pub struct WindowInit { + pub gpu: GpuSurface, + pub el: EventLoop<()>, + pub window: Window, + pub gl_surface: GlutinSurface, + pub gl_context: PossiblyCurrentContext, + pub scale_factor: f64, + /// egui_glow's GL handle over the SAME context Skia renders on — + /// one context, two painters (the grida_editor egui-spike pattern). + pub glow_context: std::sync::Arc, +} + +pub fn create_window(title: &str, width: i32, height: i32) -> WindowInit { + let el = EventLoop::new().expect("failed to build event loop"); + + let window_attributes = WindowAttributes::default() + .with_title(title) + .with_inner_size(LogicalSize::new(width, height)); + + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_transparency(true); + + let display_builder = DisplayBuilder::new().with_window_attributes(window_attributes.into()); + let (window, gl_config) = display_builder + .build(&el, template, |mut configs| { + let mut best = configs.next().expect("no gl config available"); + for config in configs { + let transparency_check = config.supports_transparency().unwrap_or(false) + & !best.supports_transparency().unwrap_or(false); + if transparency_check || config.num_samples() < best.num_samples() { + best = config; + } + } + best + }) + .expect("failed to build window"); + let window = window.expect("could not create window with OpenGL context"); + #[allow(deprecated)] + let raw_window_handle = window + .raw_window_handle() + .expect("failed to retrieve RawWindowHandle"); + + let scale_factor = window.scale_factor(); + + let context_attributes = ContextAttributesBuilder::new().build(Some(raw_window_handle)); + let fallback_context_attributes = ContextAttributesBuilder::new() + .with_context_api(ContextApi::Gles(None)) + .build(Some(raw_window_handle)); + + let not_current_gl_context = unsafe { + gl_config + .display() + .create_context(&gl_config, &context_attributes) + .unwrap_or_else(|_| { + gl_config + .display() + .create_context(&gl_config, &fallback_context_attributes) + .expect("failed to create context") + }) + }; + + let (width, height): (u32, u32) = window.inner_size().into(); + + let attrs = SurfaceAttributesBuilder::::new().build( + raw_window_handle, + NonZeroU32::new(width).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + NonZeroU32::new(height).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + ); + + let gl_surface = unsafe { + gl_config + .display() + .create_window_surface(&gl_config, &attrs) + .expect("could not create gl window surface") + }; + + let gl_context = not_current_gl_context + .make_current(&gl_surface) + .expect("could not make GL context current"); + + gl::load_with(|s| { + let Ok(cstr) = CString::new(s) else { + return std::ptr::null(); + }; + gl_config.display().get_proc_address(cstr.as_c_str()) + }); + + let glow_context = std::sync::Arc::new(unsafe { + glow::Context::from_loader_function_cstr(|s| gl_config.display().get_proc_address(s)) + }); + + let interface = skia_safe::gpu::gl::Interface::new_load_with(|name| { + if name == "eglGetCurrentDisplay" { + return std::ptr::null(); + } + let Ok(cstr) = CString::new(name) else { + return std::ptr::null(); + }; + gl_config.display().get_proc_address(cstr.as_c_str()) + }) + .expect("could not create skia GL interface"); + + let mut gr_context = skia_safe::gpu::direct_contexts::make_gl(interface, None) + .expect("could not create skia direct context"); + + let fb_info = { + let mut fboid: GLint = 0; + unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; + gpu::gl::FramebufferInfo { + fboid: fboid.try_into().unwrap_or_default(), + format: skia_safe::gpu::gl::Format::RGBA8.into(), + ..Default::default() + } + }; + + let num_samples = gl_config.num_samples() as usize; + let stencil_size = gl_config.stencil_size() as usize; + + let backend_render_target = gpu::backend_render_targets::make_gl( + (width as i32, height as i32), + num_samples, + stencil_size, + fb_info, + ); + + let surface = gpu::surfaces::wrap_backend_render_target( + &mut gr_context, + &backend_render_target, + gpu::SurfaceOrigin::BottomLeft, + skia_safe::ColorType::RGBA8888, + None, + None, + ) + .expect("could not create skia surface"); + + WindowInit { + gpu: GpuSurface { + gr_context, + fb_info, + num_samples, + stencil_size, + surface, + }, + el, + window, + gl_surface, + gl_context, + scale_factor, + glow_context, + } +} diff --git a/model-v2/axes.md b/model-v2/axes.md new file mode 100644 index 0000000000..96af1af7c8 --- /dev/null +++ b/model-v2/axes.md @@ -0,0 +1,140 @@ +# The two axes — semantic model × representation + +The candidate set (`anchor`, `sheet`, `bake`) bundled decisions that are +actually separable. This document factors them, so the **main-model decision +comes first** and the representation decision is tuned afterward. + +--- + +## Axis 1 — the semantic model (primary; decide first) + +What is the _truth_ of a node's geometry — the vocabulary and its direction +of derivation. This axis answers P1–P5, P7: + +- **`anchor` semantics** — intent-canonical: bindings + size intent + θ; + the engine resolves. +- **`bake` semantics** — state-canonical: matrix + size; the editor + materializes. + +`sheet`'s _geometry semantics_ — presence-arbitrated CSS insets — is a third +point on this axis, and it is eliminated **as an editor IR** by H12 (below): +setting a value does not guarantee the value is effective; effectiveness +depends on which sibling keys happen to be present. CSS can afford this +because CSS is hand-authored by humans without mediating tooling — leniency +is a feature for that audience. A Grida document is written by a dedicated +editor; its IR can and should be strict. + +## Axis 2 — representation & mutation protocol (secondary; tunable) + +How the chosen semantics are stored and mutated: typed structs vs a flat +keyed sheet; key granularity; the edit protocol. This axis answers P8 and +sets CRDT granularity, tooling uniformity, and much of the encoding. It is +tunable **after** Axis 1 — with one hard constraint: + +> **The atom rule.** The semantic model defines its _atoms of intent_ — the +> smallest units that must merge as a whole to keep every merged document +> valid. Representation may group keys at or above the atom, never split +> below it. + +Examples: + +- `bake`'s `rt: [f32; 6]` is **one atom** — six separate keys could pair one + edit's `cos θ₁` with another's `sin θ₂`: a torn, non-rigid matrix. Sheet + representation of `bake` is fine _iff_ `rt` is a single key. +- `anchor`'s `Pin{anchor, offset}` safely splits into two keys + (`x-mode`, `x-offset`): any cross-merge of the two is a valid binding. +- List-valued properties (fill stacks, lens ops) choose atomicity per-element + or per-list — both above the atom, both legal, different merge behavior. + +## Yes — every semantic model is sheet-representable + +Any fixed schema flattens: field → key, absence = unset. The interesting +choice is how _unions/switches_ flatten, and there are exactly three ways: + +| flattening | shape | switch behavior | truth decidability | +| ------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| **(a) atomic union key** | `x = Pin{end, 24}` (one key) | switching replaces the value; no memory | trivial — the key is the truth | +| **(b) discriminant + retained role fields** | `x-mode = pin-end`, `x-offset = 24`, `x-start`, `x-end` | inactive fields **survive**; toggling back restores them | truth = read the discriminant; effectiveness of any field is locally decidable | +| **(c) presence-arbitrated** (CSS) | `left`, `right`, `width` — presence + precedence rules decide | dormancy with **resurrection**; no discriminant exists | effectiveness depends on sibling-key combinatorics — not locally decidable at write time | + +Only **(c)** changes _semantics_ — it is what made `sheet` a distinct model. +(a) and (b) are pure representation choices available to `anchor` and `bake` +alike. + +**(b) is the notable discovery of this factoring**: it delivers the +switch-memory UX a dedicated editor wants — toggle `width-mode` +fixed → auto → fixed and the retained `width-value` restores (the +hug/fixed toggle-memory pattern) — _while keeping truth deterministic_, +because an explicit discriminant always names which fields are live. Dormant +values under (b) are a feature; dormant values under (c) are a hazard. The +difference is the discriminant. + +## What survives from `sheet` regardless of Axis 1 + +These are Axis-2 / protocol features, available to either semantic model: + +- the **single-op mutation protocol** (`set/clear(node, key, value)`) — + uniform history, undo, diffing, multiplayer; +- the **closed property registry** as the normative spec artifact + (type · initial · applies-to per key); +- per-key **animation channels**; +- inspector/tooling uniformity. + +What does _not_ survive: presence-arbitration, resurrection, and the +"every write is legal" policy — killed by H12 for the editor IR. + +## Auxiliary stores are Axis 2 too + +A model's _derived_ tiers may be serialized as auxiliary stores without +changing the model: a baked-geometry snapshot section for dumb consumers +(thumbnails, CDN renderers, embedded viewers), dense projections for hot +loops, prebuilt spatial indexes. The current fbs draft's commented-out +`relative_transform_snapshot` is this idea by name. Rules: an auxiliary +store is always **marked as cache**, is never the merge target, and its +staleness policy is the writer's obligation. Consequence for triage +(harnesses scoring rule **T1**): no model may claim a derived-tier property +as a design virtue — only canonical-tier properties differentiate. + +## Orthogonality — confirmed, with its two exceptions named + +"CRDT/code-level layout can vary regardless of the layout model" is correct +**above the atom**. The two places representation is _not_ neutral: + +1. **Splitting below an atom** corrupts merges (the torn matrix). +2. **Choosing flattening (c)** silently changes write semantics from + strict to arbitrated. + +Avoid both, and Axis 2 is freely tunable per surface — the wire format, the +runtime structs, and the CRDT layer may even choose _different_ groupings of +the same semantic model, provided all respect the atoms. + +## Axis 3 — time (how truth persists) + +One more separable axis, named to keep it out of the model race: truth can +persist as **snapshots of state/intent** (a document) or as an **operation +log** (event-sourced; the fold is the document). This is a sync-layer choice +(state-based vs op-based CRDT, history compaction) parameterized by +whichever Axis-1 vocabulary the operations speak. It changes merge and +history mechanics, not what geometry _is_ — so it is tuned independently, +like Axis 2. + +## The taxonomy is now closed + +At this altitude, a geometry truth can only be: **given** per node +(intent — `anchor`; `sheet` was this with permissive writes, retired), +**computed** (state — `bake`), **relational** (wired to other nodes — +`wire`, [models/d.md](./models/d.md), disciplined to single-assignment +dataflow, not a solver), or **historical** (the log — Axis 3, not a model). +There is no fifth archetype to wait for; the Axis-1 decision can be made +final. + +## Consequence for phase 2 + +- The **main-model decision is `anchor` vs `bake`** (Axis 1). +- `sheet` is re-scoped: its geometry semantics are retired; its + representation and protocol become Axis-2 options evaluated _after_ the + main model is chosen (likely candidates: flattening (b) + single-op + protocol on top of the winner). +- New harnesses H11 (sentinel-freedom) and H12 (set-means-set) are added to + [`harnesses.md`](./harnesses.md); H12 is the formal reason `sheet` + semantics fail as editor IR. diff --git a/model-v2/conformance.md b/model-v2/conformance.md new file mode 100644 index 0000000000..34fea5a3cd --- /dev/null +++ b/model-v2/conformance.md @@ -0,0 +1,265 @@ +# Conformance & test corpus — model-agnostic + +What the winning model must be tested against — **regardless of which model +wins**. Every entry is phrased against the observable surface only: a +document in the winner's format, resolved geometry via a query API +(world transform, box, bounds per node), rendered pixels, and mutation +results. No entry may reference model internals. + +Where the two finalists ([`finale.md`](./finale.md)) _must_ answer +differently by design, the entry is marked **FORK** and carries two expected +columns — the fork rows are the executable form of the finale's deciding +question. + +## Verdict taxonomy + +| tag | meaning | reported as | +| ------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `INV` | invariant — must hold exactly | **Y/N** | +| `POL` | either behavior is acceptable, but the model must **declare one and never drift**; the test locks the declared answer | Y/N against the declaration | +| `SPC` | graded parity against an external oracle | **spectrum** (% of case matrix within tolerance) | +| `FORK` | finalists diverge by design; two expected columns | Y/N per finalist | +| `DEFER` | spec'd now, enforced when the feature lands (animation, wire) | — | + +## Oracles + +- **Chromium** — flex/CSS-family semantics. The bake methodology already + exists in-house (`htmlcss` parity loops); reuse it for layout `SPC` suites. +- **Figma** — canvas-family semantics, via `.fig` import fixtures + (io-figma corpus) and behavior tables captured from the product. +- **SVG (resvg + Chromium)** — transform/vector import semantics. +- **Self** — goldens, round-trips, determinism, and **metamorphic laws** + (algebraic invariants needing no external oracle — the strongest tier). + +--- + +## 1. Metamorphic laws (`MM-*`) — hold for any model + +The cheapest, most powerful tier: properties over _pairs_ of documents. + +| id | law | tag | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --- | +| MM-1 | Translate a parent by (dx,dy) → every descendant's world transform translates by exactly (dx,dy); nothing else changes | INV | +| MM-2 | rotate(θ) then rotate(−θ) restores the identical resolved geometry | INV | +| MM-3 | Resolved geometry is a pure function: same document + same fonts/resources + same viewport → identical output, run-to-run | INV | +| MM-4 | Viewport size affects only the viewport-bound root's subtree sizing paths; a free node's geometry is viewport-independent | INV | +| MM-5 | Order of writes to _independent_ fields commutes (set A then B ≡ set B then A) | INV | +| MM-6 | Hidden (`active:false`) children do not affect any sibling's or ancestor's resolved geometry — except derived-box parents, per declared policy | POL | +| MM-7 | Adding then deleting a node restores prior resolved geometry byte-for-byte | INV | +| MM-8 | Zoom/camera is renderer state: no resolved-geometry query changes under camera motion | INV | +| MM-9 | Deep-nesting associativity: reparenting A→B→C as A→C with composed local placement preserves world transforms within tolerance N-3 | INV | + +## 2. Geometry resolution (`G-*`) + +Position/size resolution across the full authoring vocabulary. + +**Matrix**: every position mode (per axis: start/end/center offsets; both-edge +span/insets) × every size mode (fixed/auto/fill-equivalent) × context +(free / in-flow / absolute-in-flex). + +| id | case | tag | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| G-1 | x/left offset resolves against parent box start; end/right against end; center against center — exact arithmetic per spec table | INV | +| G-2 | End-anchored intent survives parent resize: "right: 24" re-resolves to keep 24 after parent width change | **FORK** — `anchor`: Y (stored intent); `sheet`: Y (inset property) — both Y here; kept as guard | +| G-3 | Over-constraint (left+width+right all expressed) | **FORK** — `anchor`: unrepresentable / typed error; `sheet`: declared precedence, loser dormant. POL-locked per winner | +| G-4 | min/max clamp applied after size resolution, before drawing; min > max → declared rule | POL | +| G-5 | aspect-ratio resolves the under-specified axis only; never overrides explicit; never violates min/max | INV | +| G-6 | Percentage bases (if supported): declared reference box, exact | POL | +| **edges** | | | +| G-E1 | Zero-size parent: children with end/center anchors and percentage sizes — no NaN, declared results | INV | +| G-E2 | Negative resolved size (span offsets exceeding parent): declared rule (clamp-to-zero / flip) — never a negative-width box downstream | POL | +| G-E3 | Auto size on a kind with no natural size (pure shape): declared error-or-default; never silent 0-vs-unset ambiguity | POL | +| G-E4 | Content-origin offset: a path whose tight bounds start at (4,10) — box, bounds, and hit-test agree on the offset (today's `content_origin` cases) | INV | +| G-E5 | Line-like degenerate (height locked 0): layout, bounds, hit width behave; no MIN_SIZE-style hack observable (replaces today's `MIN_SIZE_DIRTY_HACK` with a declared rule) | POL | + +## 3. Rotation & transform (`R-*`) + +| id | case | tag | +| --------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --- | ------ | ------------------------------ | --- | +| R-1 | Pivot: rotating a boxed node preserves its box center in parent space (center pivot), or the declared pivot exactly | POL (locked to winner's spec) | +| R-2 | Rotated AABB arithmetic: world AABB = exact ` | w·cosθ | + | h·sinθ | ` envelope of the oriented box | INV | +| R-3 | **Rotation × layout participation**: rotated child inside a flex row | **FORK** — `anchor`: siblings make room for the rotated AABB; `sheet`: siblings do not move; overlap is correct. THE deciding-question row | +| R-4 | Rotation and resize commute visually per declared pivot: resize-then-rotate ≡ rotate-then-resize for center pivot | INV (if center pivot) | +| R-5 | Composition depth: 50-deep rotated nesting — world transform matches closed-form composition within N-3 tolerance | INV | +| R-6 | Exceptional transforms (skew / matrix / 3D — via quarantine node or transform property per winner): parity vs SVG/Chromium oracle | SPC | +| **edges** | | | +| R-E1 | θ ∈ {0, 90, 180, 270, 360, −90, 0.001, 1e−6}: exact expected matrices (90° multiples bit-clean, no drift) | INV | +| R-E2 | θ = 720 (winding): stored/reported as authored where the model represents it; interpolation midpoint 0→720 at t=.5 is 360 | DEFER-ANIM | +| R-E3 | NaN/Inf/−0.0 rotation rejected at the write boundary with typed error; never enters the document | INV | +| R-E4 | Accumulation: 360 × 1° successive rotations vs one 360° — divergence bounded by declared tolerance | INV | +| R-E5 | Flip/mirror content (negative-determinant transforms, e.g. Figma import): declared representation; round-trips through the format | POL | + +## 4. Layout / flex (`L-*`) — mostly SPC vs Chromium + +**Matrix**: direction × wrap × main-align × cross-align × gap × padding × +child size modes × grow — baked against Chromium, scored as spectrum. + +| id | case | tag | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | +| L-1 | Core flex matrix parity | SPC (Chromium) | +| L-2 | **Declared deviation**: default `flex-shrink = 0` (Grida) vs `1` (CSS) — canvas items don't shrink implicitly. Test locks _our_ declared behavior, and the deviation is listed in §9 | POL | +| L-3 | Hug (auto) container sizing with mixed fixed/grow children — no cycle; grow distributes only definite free space | INV | +| L-4 | Absolute child inside a flex parent: excluded from flow, resolves against parent box | INV | +| L-5 | Measured child (text) re-measures at layout-imposed width; final geometry = re-wrapped height | INV | +| L-6 | Nested flex (row-in-column-in-row, 3 deep) parity | SPC | +| L-7 | Rounding/pixel-snapping: declared policy (none / half-up at paint only); resolution itself unquantized | POL | +| **edges** | | | +| L-E1 | Empty container with padding + hug: size = padding box | INV | +| L-E2 | Zero-size and hidden children in flow: gap contribution declared | POL | +| L-E3 | Overflow (children exceed fixed parent): no shrink (per L-2), overflow geometry exact; clip flag affects paint only, never geometry | INV | +| L-E4 | grow with zero free space / negative free space: no shrink below basis (per L-2) | INV | +| L-E5 | min/max + grow + aspect-ratio simultaneously: resolution order declared and stable | POL | + +## 5. Measured content (`T-*`) + +| id | case | tag | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| T-1 | Fixed-width text wraps; auto-width text measures single-line max; height auto = measured height | INV | +| T-2 | max_lines/ellipsis alter _paint and reported natural size_ per declared rule — and "unset vs 0" is structurally distinct (today's `max_lines: Some(0) ≡ None` fbs-default hack must be unrepresentable) | INV (sentinel-freedom witness) | +| T-3 | Same document + same font set → identical measured geometry across runs and platforms within N-3 | INV | +| T-4 | Missing font: declared fallback chain; geometry deterministic under the fallback; a document must not silently change when a font later appears — declared staleness policy | POL | +| T-5 | Vector/network bounds as box source: editing a vertex updates box, bindings re-resolve against the new box | INV | +| **edges** | | | +| T-E1 | Empty string, whitespace-only, single glyph taller than line-height, BiDi/RTL text runs (text-internal only — the canvas has no RTL layout axis, declared) | POL | +| T-E2 | Text with height smaller than one line + vertical alignment: clip positioning per the documented top/center/bottom offset math | INV | +| T-E3 | Ellipsis override semantics: unset → "…", empty string → no ellipsis — structurally distinct states | INV | + +## 6. Derived boxes: groups & booleans (`D-*`) + +| id | case | tag | +| --------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| D-1 | Group bounds = union of children's **oriented** corners (not AABB-of-AABBs) | INV | +| D-2 | **Sibling stability**: editing child A of a rotated group does not move child B in world space | INV — the P6 instability test | +| D-3 | Child edits never require a write to the group node (observable via document diff) | **FORK-ish** — `anchor`: Y; `sheet`: Y; (`bake` failed this — kept as regression guard) | +| D-4 | Ungroup bakes: children's world transforms preserved within N-3 | INV | +| D-5 | Boolean bounds = bounds of the op _result_ (subtract-to-empty → declared empty-bounds behavior; hit-test and render agree) | POL | +| D-6 | Group participation in parent flex: declared (as its derived AABB, or excluded) | POL | +| **edges** | | | +| D-E1 | Empty group / group of hidden children: bounds, render, hit — declared triple | POL | +| D-E2 | Nested groups 10 deep with rotations: D-1/D-2 still hold; perf smoke | INV | +| D-E3 | Boolean of booleans; boolean containing a group: declared operand semantics | POL | + +## 7. Mutation & document validity (`M-*`) — the editor-IR suite + +| id | case | tag | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| M-1 | **Sentinel-freedom sweep**: for every field, "unset" is structurally distinct from every legal value (enumerated per field; the current `max_lines=0`, `(0,0)-aspect-ratio` class must fail-closed) | INV | +| M-2 | **Set-means-set**: after any accepted write, effectiveness is decidable from the node + declared discriminants (+ parent context); locked list of typed-error writes | **FORK** — `anchor`: model-level; `sheet`: editor-level discipline, model permits — test targets the _editor write path_ under sheet | +| M-3 | Switch-memory: mode toggle A→B→A restores retained inactive values (if flattening (b) adopted) | POL | +| M-4 | Unknown node kind / unknown field in a newer document: reader skips or errors per declared forward-compat rule, and **preserves unknown content through a read-modify-write round-trip** | INV | +| M-5 | Reparent (no geometry fields touched): declared world-position semantics (preserve-world vs preserve-local), locked | POL | +| M-6 | Every mutation rejected with a typed error leaves the document byte-identical | INV | + +## 8. Serialization & round-trip (`S-*`) + +| id | case | tag | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| S-1 | decode → encode → decode fixpoint (bytes or canonical-form equality, declared) | INV | +| S-2 | f32 fields survive bit-exact through the format | INV | +| S-3 | _Derivable ⇒ not encodable_ (if `anchor` wins): the format cannot express resolved boxes outside a marked cache section; caches carry a validity stamp and staleness is detectable | INV / POL per winner | +| S-4 | Fractional-index sibling order: stable sort, duplicate/empty position strings → declared tiebreak | POL | +| S-5 | Cross-boundary codec: Rust-encode → TS-decode equivalence on the full corpus (existing io practice, promoted to a gate) | INV | + +## 9. Concurrency / merge atoms (`C-*`) — executable H3 matrix + +Merged documents must **always be valid** (INV); intent survival is scored. + +| id | concurrent pair | valid? | intent survival | +| --- | ------------------------------------------------------ | ------------ | ----------------------------------------------- | +| C-1 | move ∥ rotate (same node) | INV Y | both survive — Y expected under either finalist | +| C-2 | resize ∥ rotate | INV Y | both survive | +| C-3 | move ∥ reparent | INV Y | declared (POL) | +| C-4 | child-edit ∥ group-rotate | INV Y | both survive (D-3 corollary) | +| C-5 | half-a-drag (x from A, y from B) | INV Y | valid-but-compound-lost — documented, accepted | +| C-6 | span/inset pair halves (start from A, end from B) | INV Y | same class as C-5 | +| C-7 | mode-switch ∥ retained-field write (flattening (b)) | INV Y | declared dominance | +| C-8 | wire-cycle creation (two acyclic edits merging cyclic) | DEFER (wire) | deterministic break rule per d.md | + +## 10. Numerical robustness (`N-*`) + +| id | case | tag | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | +| N-1 | Coordinates at ±1e7 (far-canvas): geometry error bounded; no catastrophic cancellation in world composition | INV | +| N-2 | NaN/Inf/subnormal inputs rejected at every write boundary — the document is NaN-free by construction | INV | +| N-3 | **Tolerance policy (normative)**: bit-exact within a platform; declared ε for cross-platform (native vs wasm) world-space comparisons — every INV above inherits this | POL | +| N-4 | 10k-node resolution: cost scales ~linearly; editing one leaf invalidates a bounded set (locality smoke — deep perf stays with the render-perf discipline) | SPC | + +--- + +## 11. Compatibility checklist (`X-*`) — reproduce, deviate, or defer + +Each row: **Y** (we reproduce), **N** (deliberate deviation, rationale +required), **SPC** (graded parity), **DEFER**. + +### vs CSS / Chromium (`X-CSS-*`) + +| id | behavior | verdict | +| ------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| X-CSS-1 | Flex core algorithm (basis/grow/align/gap/wrap) | SPC — target ≥ high-90s% on the L-1 matrix | +| X-CSS-2 | `flex-shrink: 1` default | **N** — Grida defaults 0; canvas items keep authored size (already today's deliberate deviation) | +| X-CSS-3 | min-/max-content intrinsic sizing keywords | DEFER | +| X-CSS-4 | Margins | **N** — gap + padding only; margin is not in the model | +| X-CSS-5 | Post-layout transforms (rotated flow child overlaps) | **FORK** — `sheet`: Y; `anchor`: N by design for base rotation (lens/motion lane only) | +| X-CSS-6 | Specified-vs-computed two-tier reads | Y (both finalists, different mechanisms) | +| X-CSS-7 | Writing modes / RTL block axes | **N** — the canvas has no RTL layout axis (text-internal BiDi only, T-E1) | +| X-CSS-8 | CSS Anchor Positioning | DEFER — the `wire`/Level-4 future under either finalist | +| X-CSS-9 | Baseline alignment in flex | DEFER | + +### vs Figma (`X-FIG-*`) + +| id | behavior | verdict | +| ------- | ----------------------------------------------------------------- | ------------------------------------------------------------ | +| X-FIG-1 | Rotated child's AABB participates in auto-layout | **FORK** — `anchor`: Y; `sheet`: N (structurally impossible) | +| X-FIG-2 | Constraints Min/Max/Center/Stretch re-derive on parent resize | Y (mapping table per winner's vocabulary) | +| X-FIG-3 | Scale constraint (proportional) | DEFER | +| X-FIG-4 | Group re-fit + compensation writes on child edit | **N** — derived bounds instead (D-2/D-3 are the tests) | +| X-FIG-5 | Center-pivot rotation _gesture_ | Y (gesture-level under any storage) | +| X-FIG-6 | `.fig` import corpus renders within tolerance (io-figma fixtures) | SPC | +| X-FIG-7 | Hug/fixed toggle restores prior value (switch memory) | Y via M-3 | + +### vs SVG (`X-SVG-*`) + +| id | behavior | verdict | +| ------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| X-SVG-1 | translate/rotate/scale transform lists import losslessly | Y | +| X-SVG-2 | skew / arbitrary `matrix()` import | Y-with-structure (lens wrap under `anchor`; transform property under `sheet`) — never silent loss (T1/H8) | +| X-SVG-3 | `x/y` attr + `translate()` double-translation | Y — defined single mapping | +| X-SVG-4 | viewBox / preserveAspectRatio | SPC (import corpus) | +| X-SVG-5 | `` ↔ group semantics | Y | +| X-SVG-6 | 3D (`matrix3d`-class content) | `anchor`: lens vocabulary (DEFER impl); `sheet`: transform prop (DEFER impl) | + +### vs current Grida engine (`X-SELF-*`) — migration honesty + +For each existing observable behavior: **keep** or **break** (with migration +note). Breaks are features here — they are the flaws this redesign exists +to fix, and each needs a regression-direction test proving the _new_ +behavior. + +| id | current behavior | verdict | +| -------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| X-SELF-1 | Rotation pivots at local top-left (`T·R`) | **break** → declared pivot (R-1); migration converts stored values | +| X-SELF-2 | Layout path destroys leaf scale/skew (`new(x,y,rotation())`) | **break** → structurally impossible (S-3 / model shape) | +| X-SELF-3 | `MIN_SIZE_DIRTY_HACK` (1px floors in text/markdown resolution) | **break** → declared rule (G-E5) | +| X-SELF-4 | `max_lines: 0 ≡ unset`, `(0,0) ≡ no aspect-ratio` sentinels | **break** → M-1 | +| X-SELF-5 | ICB children's transforms ignored (special regime) | **break** → regularized root (MM-4 covers) | +| X-SELF-6 | Group bounds via oriented-corner union | **keep** (D-1) | +| X-SELF-7 | Render-bounds inflation (stroke align + effects) separate from layout bounds | **keep** — inflation affects render bounds only, never layout (INV) | +| X-SELF-8 | Flex `flex-shrink: 0` default | **keep** (X-CSS-2) | +| X-SELF-9 | Fractional-index sibling ordering | **keep** (S-4) | + +--- + +## 12. Corpus mechanics (brief) + +- **Tiers**: (1) metamorphic + INV unit tests in Rust (`cargo test -p grida`); + (2) resolved-geometry goldens (document → JSON of world transforms/boxes); + (3) render reftests per the render-reftest discipline (oracle choice per + suite); (4) oracle bakes (Chromium/Figma/SVG) producing the SPC scores. +- **IDs are stable** and referenced from the phase-3 spec (`covered_by` + links, per the repo's test-tracking practice). A spec clause without a + covering test ID is a spec bug. +- **FORK rows** double as the finale's §Decision-procedure probes: run them + first; they are few and they are the decision. +- Every `POL` row's declared answer gets recorded in the winner's spec at + phase 3 — a `POL` test failing later means drift, which is exactly what + it exists to catch. diff --git a/model-v2/editor.md b/model-v2/editor.md new file mode 100644 index 0000000000..82f4e6fa6e --- /dev/null +++ b/model-v2/editor.md @@ -0,0 +1,172 @@ +# Editor experience — the operation catalog + +"How do you do X → what happens." Every editor operation, model-agnostic, +as **gesture → writes → effect → ripple**: + +- **writes** — exactly which document fields change, on which nodes (the H2 + probe, fulfilled per-op). Phrased in `anchor` vocabulary where a concrete + field name is needed; `sheet` equivalents are the corresponding + properties. **FORK** marks where finalists diverge. +- **effect** — the resolved-geometry change on the target. +- **ripple** — who _else_ moves (siblings reflow, parents re-hug, measured + ancestors re-fit). + +IDs are stable (`OP-*`); each becomes a manual TC in `test/` when the +winner lands, and conformance rows reference them. + +## Doctrine — six laws every operation obeys + +1. **Gestures write intent; the engine resolves.** No gesture ever writes a + resolved value into the document. Screen-space deltas are transformed + (camera⁻¹, then parent-world⁻¹) into parent-space field writes. +2. **One gesture = one undo step = a named set of field writes.** The write + set is part of the op's spec (this catalog), not an implementation + detail. (Merge granularity note: a gesture's writes are fields — C-5's + "half a drag merges valid-but-uncompound" is accepted and documented.) +3. **State→intent capture is gesture-only.** Exactly three op families are + sanctioned to read _resolved_ geometry and capture it into new intent: + re-anchoring (OP-INSPECT-4), layout attach/detach (OP-LAYOUT-1/2), and + ungroup/bake (OP-TREE-2). The engine itself never does this. Everything + else writes deltas to existing intent. +4. **Layout-owned fields redirect, never no-op.** A gesture aimed at a + layout-owned field either redirects to its flow meaning (drag = reorder) + or surfaces the detach affordance — silent swallowing is banned (H12). +5. **Mode switches use switch-memory** (where flattening (b) is adopted): + the displaced value is retained and restored on toggle-back (M-3). +6. **Multi-selection = the same gesture applied per node** (independent + write sets, one undo step). No transient group node is created. + +--- + +## 1. Create (`OP-CREATE-*`) + +| id | how | writes | effect / ripple | +| ----------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| OP-CREATE-1 | drag out a shape on free canvas | new node: parent, order, x/y = pin(start, drag-origin in parent space), w/h = fixed(drag extent), payload | appears under cursor; no ripple | +| OP-CREATE-2 | single click place | same, with kind-default size | — | +| OP-CREATE-3 | drag out _inside a flex frame_ | new node + `flow: InFlow`, order = insertion index from pointer (gap highlight); x/y not written (layout-owned) | siblings make room; parent re-hugs if auto | +| OP-CREATE-4 | drop an image file | new `image` node; size = intrinsic px (clamped by policy); placement as OP-CREATE-1/3 | — | +| OP-CREATE-5 | draw a text box (drag = fixed width) vs click (auto width) | width mode differs by gesture: drag → fixed(w), click → auto | click-text grows as typed (OP-CONTENT-1) | + +## 2. Move (`OP-MOVE-*`) + +| id | how | writes | effect / ripple | +| --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| OP-MOVE-1 | drag a free node | x.offset, y.offset (2 fields) — rewritten so resolved position tracks the pointer, whatever the anchor (end-anchored: the _end offset_ is what changes) | node follows; no ripple | +| OP-MOVE-2 | drag an **in-flow** child | **order** (fractional index, 1 field) — drag is reorder, per doctrine 4; x/y untouched | siblings shuffle; drop-gap preview | +| OP-MOVE-3 | drag in-flow child _out_ of its container | parent, order, flow→(free), x/y captured from resolved drop point (doctrine 3, via OP-LAYOUT-2 semantics) | old siblings close the gap; old parent re-hugs | +| OP-MOVE-4 | drag free node _into_ a flex container | parent, order (insertion index); x/y become layout-owned (retained per switch-memory) | new siblings make room | +| OP-MOVE-5 | arrow-key nudge (±1 / shift ±10) | x.offset or y.offset — deltas in **screen axes**, transformed to parent space (a nudge of a rotated node's child still moves it visually right) | — | +| OP-MOVE-6 | drag a rotated node | same as OP-MOVE-1 — pointer delta through parent-world⁻¹; rotation does not complicate a move (center pivot: translation and rotation commute) | — | +| OP-MOVE-7 | drag multi-selection | per node: its own 2 offsets (doctrine 6) | mixed free/in-flow selection: free members move, in-flow members reorder — or policy: in-flow members require detach; **POL, locked in spec** | +| OP-MOVE-8 | shift-drag (axis lock) | 1 field (the unlocked axis) | — | + +## 3. Resize (`OP-SIZE-*`) + +The richest family. Pivot doctrine (center) determines write counts. + +| id | how | writes | effect / ripple | +| ---------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| OP-SIZE-1 | corner handle, free unrotated node | w, h, **and** x.offset, y.offset (4 fields) — opposite corner stays fixed, so the center moves and position must compensate | — | +| OP-SIZE-2 | edge handle | 1 size + 1 offset (2 fields) | — | +| OP-SIZE-3 | **alt-resize (from center)** | w, h only (2 fields) — center pivot means no compensation. The cheapest resize is the symmetric one; that asymmetry is a feature of center-pivot, worth teaching in UI | — | +| OP-SIZE-4 | shift-resize (aspect lock) | as OP-SIZE-1 with both sizes coupled by current ratio (aspect_ratio field itself untouched — it is intent, not a gesture artifact) | — | +| OP-SIZE-5 | **resize a rotated node** | 4 fields — handles track the node's local orientation; the world-fixed point is the opposite corner: `new_center = fixed_corner_world + R(θ)·(±w′/2, ±h′/2)`; x/y compensate accordingly | free: no ripple; in-flow: **FORK** — `anchor`: siblings re-space to the new rotated AABB; `sheet`: no sibling motion | +| OP-SIZE-6 | resize an in-flow child | w/h intent (+ mode switch if it was auto/fill → fixed, memory retained) | siblings reflow; parent re-hugs | +| OP-SIZE-7 | resize a **hug** (auto) container by handle | width/height mode → fixed(value); prior auto retained (M-3) | children unaffected unless stretch-aligned | +| OP-SIZE-8 | resize a **container** with anchored children | container w/h (+offsets per OP-SIZE-1) — **zero writes to children**; their bindings re-resolve (end-pinned stays 24 from the right, spanned stretches) | children move/stretch _by resolution, not by write_ — the model's core payoff made visible | +| OP-SIZE-9 | resize a **group** (derived box) | writes to **all descendants**: sizes and offsets scaled proportionally (the one inherently multi-node resize; group node itself: at most x/y/θ) | POL: whether strokes/text scale = separate scale-tool semantics (OP-SIZE-10) | +| OP-SIZE-10 | scale tool (K) | as OP-SIZE-9 plus payload-level scaling (stroke widths, font sizes) per declared scale-tool policy | POL | +| OP-SIZE-11 | resize past zero (drag through the opposite edge) | flip behavior: **POL** — clamp at 0, or flip (writes flip representation per R-E5) | — | +| OP-SIZE-12 | resize text box | width → fixed (wrap constraint); height typically stays auto — mode switches with memory | re-wrap; hug ancestors re-fit | + +## 4. Rotate (`OP-ROT-*`) + +| id | how | writes | effect / ripple | +| -------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OP-ROT-1 | rotation handle, free boxed node | **θ only (1 field)** — center pivot means the box center is invariant; no compensation | free: none. **This is the model's cleanest gesture** | +| OP-ROT-2 | rotate an **in-flow** child | θ (1 field) | **FORK — the deciding question live**: `anchor`: siblings re-space to the rotated AABB, parent re-hugs; `sheet`: nothing else moves, overlap is correct | +| OP-ROT-3 | rotate a **group / derived-box node** | θ + x, y (3 fields) — stored pivot is the node's origin; gesture pivots at the visual center and compensates position (the legible-scalar version of Figma's matrix compensation) | children: **zero writes** (D-2/D-3) | +| OP-ROT-4 | rotate multi-selection | per node: θ + x/y compensation, orbiting the **selection** center | one undo step; each node's writes independent | +| OP-ROT-5 | shift-rotate | θ snapped to 15° increments | — | +| OP-ROT-6 | numeric rotation in inspector | θ (1 field), pivot = own center — **note**: differs from OP-ROT-4's selection-center orbit; the discrepancy is standard (Figma does the same) and is declared, not accidental | — | +| OP-ROT-7 | reset rotation (0) | θ = 0; position untouched (center invariant) | in-flow: AABB shrinks back → siblings close in (`anchor`) | + +## 5. Hierarchy (`OP-TREE-*`) + +| id | how | writes | effect / ripple | +| --------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| OP-TREE-1 | group selection (⌘G) | new group node (parent, order, x/y/θ); each member: parent → group, geometry re-expressed in group space (offsets rewritten; world position preserved — M-5) | visual: nothing moves | +| OP-TREE-2 | ungroup (⌘⇧G) | group θ/position baked into each child's fields (doctrine 3); children reparent to grandparent; group deleted | nothing moves (D-4); winding/precision per N-3 | +| OP-TREE-3 | wrap in frame | as OP-TREE-1 with a `frame` (declared size = selection AABB) | — | +| OP-TREE-4 | reparent by drag (into a non-layout container) | parent, order; x/y rewritten so world position holds (M-5 declared semantics) | nothing moves | +| OP-TREE-5 | reorder in layers panel / bring-to-front | order only | paint order changes; geometry untouched — in flex parents, order is _also_ flow order: **the same field means both**; declared and tested | +| OP-TREE-6 | delete | node (+subtree) removed | flex siblings close the gap; hug parents re-fit; (future `wire`: dangling-referent fallback fires) | + +## 6. Layout (`OP-LAYOUT-*`) + +| id | how | writes | effect / ripple | +| ----------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| OP-LAYOUT-1 | **add auto-layout** to a frame | frame: layout mode/direction/gap (inferred from current child geometry); children: flow=InFlow, order = sort by axis position; children's x/y become layout-owned (retained) | ideally nothing moves on the first frame — inference quality is a UX metric, not a model concern | +| OP-LAYOUT-2 | **remove auto-layout** | frame: layout mode → none; each child: x/y captured from _resolved_ positions (doctrine 3 — the sanctioned bake moment) | nothing moves; intent is re-materialized | +| OP-LAYOUT-3 | toggle a child absolute ↔ in-flow | child flow field; on →absolute: x/y captured from resolved (doctrine 3); on →in-flow: x/y owned again (retained) | siblings reflow around the departure/arrival | +| OP-LAYOUT-4 | drag the gap handle / padding handle | gap or padding (1 field) | all children re-space | +| OP-LAYOUT-5 | "fill container" button | main axis: grow = 1 (+ basis per policy); cross axis: self_align = stretch — the UI concept maps to two model mechanisms; the button hides that, the spec doesn't | siblings share remaining space | +| OP-LAYOUT-6 | "hug contents" button | size mode → auto (fixed value retained) | parent chain may re-hug | +| OP-LAYOUT-7 | reorder within flow by drag | order (see OP-MOVE-2) | — | + +## 7. Inspector — numeric & mode writes (`OP-INSPECT-*`) + +| id | how | writes | effect / ripple | +| ------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| OP-INSPECT-1 | type X (or Y) | the axis offset, **re-targeted**: whatever the anchor, the offset is rewritten so resolved x equals the input (end-anchored nodes keep their anchor) | — | +| OP-INSPECT-2 | type X on an **in-flow** child | **FORK** — `anchor`: typed error surfaced as a disabled field + "detach" affordance; `sheet`: editor-level equivalent (field disabled by discipline). Never a silent no-op | — | +| OP-INSPECT-3 | type W/H | size intent; on auto/fill nodes: mode → fixed with memory (as OP-SIZE-7) | flow ripple as OP-SIZE-6 | +| OP-INSPECT-4 | **switch anchor/constraint mode** (left↔right↔center↔span) | axis binding variant + a _captured_ offset computed from resolved geometry so **the node does not move** (doctrine 3) | changes future behavior (parent resize), not present geometry — test: G-2 after a parent resize | +| OP-INSPECT-5 | type rotation | θ, own-center pivot (OP-ROT-6) | — | +| OP-INSPECT-6 | mixed multi-selection numeric edit | same field on every node (absolute set, not delta — POL, declared) | — | +| OP-INSPECT-7 | clear/reset a field (e.g. remove max-width) | field → structurally unset (H11 — never a sentinel write) | — | + +## 8. Content edits with geometric consequence (`OP-CONTENT-*`) + +| id | how | writes | effect / ripple | +| ------------ | -------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| OP-CONTENT-1 | typing in auto-width text | **text content only — zero geometry writes** | measured box grows → bindings re-resolve → flex siblings move → hug ancestors re-fit: the full one-way pipeline exercised by a keystroke | +| OP-CONTENT-2 | vector vertex edit | payload (network) only | measured box changes; same ripple chain | +| OP-CONTENT-3 | corner-radius handle drag | payload style field | paint only — never geometry/layout (X-SELF-7 separation) | +| OP-CONTENT-4 | flip horizontal/vertical buttons | per R-E5 declared representation (payload mirror or flip encoding) | POL | + +## 9. Clipboard & duplication (`OP-CLIP-*`) + +| id | how | writes | effect / ripple | +| --------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| OP-CLIP-1 | copy/paste in same parent | new nodes, fresh ids, order appended; position offset per declared rule (+10,+10 or in-place — POL) | — | +| OP-CLIP-2 | paste into a different parent / document | geometry re-expressed in the destination space; world-position-preserve vs viewport-center per declared rule (POL); unknown/foreign fields preserved (M-4) | — | +| OP-CLIP-3 | alt-drag duplicate | clone + OP-MOVE-1 on the clone, one undo step | in flex: clone inserts at index (OP-CREATE-3 semantics) | + +## 10. History (`OP-HIST-*`) + +| id | how | guarantee | +| --------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| OP-HIST-1 | undo | restores the exact prior document (byte/canonical equality — MM-7 class); resolved geometry follows deterministically (MM-3) | +| OP-HIST-2 | redo | inverse of OP-HIST-1 | +| OP-HIST-3 | undo across a collaborative gap | undoes _own_ ops only (op-granular, field-set scoped); merged-in remote writes survive — the C-matrix governs conflicts | + +--- + +## What this catalog exposed (doctrine found by enumeration) + +1. **Write-count as a design signal.** Center pivot makes the gesture costs + legible: rotate = 1 field, alt-resize = 2, corner-resize = 4, group + rotate = 3, container resize = parent-only. Any model change that + inflates these counts is regressing the editor. +2. **The three sanctioned bake moments** (doctrine 3) are the complete list + of state→intent flow. If a fourth ever appears in code review, it is + either this list growing (spec change) or a bug. +3. **`order` is one field with two meanings** (paint order & flow order) — + cheap and mostly right, but it forbids "visually behind yet first in + flow"; declared as a known limit (OP-TREE-5). +4. **The FORK rows are few and vivid here too**: OP-ROT-2 is the finale's + deciding question as a literal drag — rotating a card in a list either + makes room or overlaps. Whoever decides the finale should perform that + gesture in their head first. diff --git a/model-v2/finale.md b/model-v2/finale.md new file mode 100644 index 0000000000..553321c259 --- /dev/null +++ b/model-v2/finale.md @@ -0,0 +1,101 @@ +# Phase-2 finale — `anchor` vs `sheet` + +Status: **DECIDED — `anchor`** (owner triage, 2026-07-07; see +[`triage.md`](./triage.md)). The deciding question was answered from both +sides without naming a model: _the web is wrong when it disagrees with the +canvas_ (triage #1), and _the adopt-CSS path already exists in the product +as the `htmlcss` engine — the new taxonomy exists to introduce a new, +editor-first standard_ (triage #6). `anchor` proceeds to phase 3 carrying +five triage amendments (no serialized switch-memory; strict states + +lenient writes; the agent text IR as a first-class surface; familiar +vocabulary; SVG-import ≈100%). The rotation-in-flow fork stays open with a +recorded tilt, to be decided by measured prototype. + +The record below is preserved as written before the decision. + +--- + +Previously: **finalists locked** (owner decision, 2026-07). The main-model +race reduces to two, and the axis between them is not technical shape but +provenance: **invent vs adopt**. + +## The finalists, restated precisely + +### `anchor` — invent the right model + +[models/a.md](./models/a.md) as written: intent-canonical bindings, +XYWH-first, layout as a feature, layout-visible rotation, set-means-set. +The Figma/SVG lineage, done without their legacies. **We author the +normative spec, prove it, and teach it.** + +### `sheet` — adopt the proven model, top to bottom + +**Not** the model H12 eliminated. The re-reading: we invent _nothing_ — +CSS semantics wholesale. Insets, auto, percentages, flex (grid later), +post-layout transforms with center origin, specified-vs-computed values. + +- **Normative text**: the CSSWG specs. We write zero layout spec — we cite. +- **Oracle**: Chromium. The repo already runs Chromium-parity loops for its + `htmlcss` renderer; the conformance methodology exists in-house today. +- **Implementations**: Taffy speaks these semantics natively; the DOM canvas + backend renders them _identically by construction_; web export is the + identity function. +- **Write discipline**: H12's arbitration hazard is answered at the editor + layer, not the model layer — the editor is the only writer and never + produces over-constrained sheets except deliberately (switch-memory). +- **Existence proof**: Webflow — a successful design tool whose IR _is_ + CSS properties under editor discipline. This path is proven not just by + browsers but by a design-tool peer. + +### Eliminated, on record + +- **`bake`** — after scoring rule T1, its intrinsic residue (snapshot + coherence, trivial conformance spec) lost to its intrinsic costs (intent + loss, write amplification, coarse merges). Runner-up, retired. +- **`wire`** — real, priced, deferred; re-enters `anchor` additively + (`Pin.to`) if the product ever demands cross-node attachment. Under + `sheet`, its equivalent is CSS Anchor Positioning — also additive. + +## What each finalist concedes — the honest bill + +| concern | `anchor` pays | `sheet` pays | +| -------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| spec authorship | **full** — normative text, conformance corpus, teaching burden, novel-model risk | ~zero — cite CSSWG, test against Chromium | +| rotation × layout | wins: rotated flow child makes room (canvas truth) | **unrepresentable, period** — CSS has no layout-visible rotation; rotated flex children overlap | +| geometry-first authoring | wins: XYWH + bindings native | geometry is layout _output_; "what you define is not what you get" | +| set-means-set (H12) | model-level pass, typed errors | model-level **fail standing**; mitigated by editor-only writes (Webflow-style) | +| founding judgments | consistent with WG feat-layout §1 and the schema's own "Why not just the CSS Box Model" doctrine | **consciously reverses both** — allowed, but it must be signed for, not slid into | +| web/site-builder alignment | lossless _transpile_ to CSS (a projection, maintained by us) | **identity** — the DOM backend and tenant sites render the model natively | +| conformance/oracle | we build the oracle (reftests, corpus) | Chromium is the oracle; WPT-style method already in-house (`htmlcss` parity loops) | +| ecosystem/familiarity | new vocabulary to learn | every web developer already knows it | +| CRDT | atoms designed for merge | property-LWW workable; dormancy interacts with merge (a merged-in key can flip arbitration) | +| animation | two-lane by design | CSS animation model 1:1 — the best story of any candidate | + +## The deciding question + +Both are coherent. The tie-breaker is product identity, not a scorecard: + +> **When the canvas and the web disagree — rotation-in-flow is the crispest +> case — which one is lying?** + +- If the canvas is the truth and the web is a _projection_ of it, the model + must say things CSS cannot (layout-visible rotation, geometry-first + intent) → **`anchor`**, and the site-builder surface consumes a + transpilation. +- If the web is the truth and the canvas is a _preview_ of it, inventing + semantics the deploy target cannot honor manufactures permanent + mismatch → **`sheet`**, and the graphics canvas accepts CSS's limits. + +Secondary form of the same question: is the Rust engine's document model a +**graphics engine's** model (mission statement: "high performance +interactive graphics engine") that also exports web, or a **web renderer's** +model that also draws shapes? + +## Decision procedure + +1. Owner answers the deciding question (this is not delegable to harnesses). +2. Run the finalists through the worked probes (H1 quartet, H3 concurrency + matrix, H4 definedness answers) — as confirmation, not as the decider. +3. Winner proceeds to phase 3: normative spec + `grida.fbs` draft; loser's + file stays as the record of the road not taken and the bill it would + have paid. diff --git a/model-v2/harnesses.md b/model-v2/harnesses.md new file mode 100644 index 0000000000..63da2a2cf8 --- /dev/null +++ b/model-v2/harnesses.md @@ -0,0 +1,217 @@ +# Design harnesses + +The constraints any candidate model must pass. Each has a **concrete probe** — +a test you can actually run against a design on paper — because "friendly" and +"correct" are not evaluable without one. Sources: the stated project +requirements, the current codebase's realities, and the format's encoding +rules. + +A candidate model in phase 2 gets a scorecard: one row per harness, +pass / fail / trade (with the trade named). + +## Scoring rule T1 — the auxiliary-store test + +A claimed virtue counts **only if it is intrinsic to the canonical design** — +i.e., it cannot be obtained by attaching a derived auxiliary store (cache, +projection, materialized snapshot sidecar) to a rival model. Derived-tier +virtues are commodities: any intent-canonical model can serialize a baked +geometry snapshot for dumb consumers; any state-canonical model can be +projected into dense hot-loop records. Neither scores. + +The rule is symmetric: a claimed _cost_ that a one-time derived store pays +off (read indirection, hot-loop projection) is likewise not decisive. + +What survives T1 — and is therefore the only legitimate scoring material — +are canonical-tier properties: what the store refuses, what intent survives +in it, what merges at what granularity, what a writer must maintain, and +what the format's conformance spec must define. The honest residue of a +derived-store claim is usually a _coherence_ difference: a canonical value +is true by construction, while an auxiliary store is only as fresh as +writer discipline keeps it. Score the residue, not the headline. + +--- + +## H1. Raw-representation readability (the XML test) + +**Statement.** The stored form, transliterated to XML, must be readable and +_predictable_ by a human without running code. + +**Probe.** Write, in the candidate's storage vocabulary: (a) a rectangle +rotated 15°; (b) a rectangle pinned `right: 24`; (c) a flex column with one +fill-width child; (d) a rotated group of two shapes. A designer-engineer must +be able to sketch the render from the text alone. Any field a human cannot +read without a calculator (e.g. a 6-float matrix) counts against, unless +quarantined (→ H8). + +## H2. Authoring ergonomics (gesture → write mapping) + +**Statement.** Direct manipulation must map to small, local, meaningful +writes; common reads must always be answerable. + +**Probe.** For each gesture — drag, resize (each handle), rotate, reparent, +group/ungroup — list the exact fields written and nodes touched. Flags: +a gesture that writes a field the user didn't conceptually touch; a gesture +that must touch more than one node (except group/ungroup baking); a read +(`x`, `y`, `width`, `height`, `rotation`) that is unanswerable for some node +kind. + +## H3. CRDT mergeability + +**Statement.** Concurrent edits must merge at property granularity without +producing states no user authored; no edit may require cross-node atomic +writes. + +**Probe.** Run the concurrency matrix: move ∥ rotate, resize ∥ rotate, +move ∥ reparent, child-edit ∥ group-rotate, style ∥ geometry. For each pair: +do both intents survive per-property LWW? Structural flags: any stored tuple +whose components are semantically coupled (merging halves produces invalid +values — the matrix-tearing case); any operation whose correctness depends on +two nodes updating together. + +**Caveat to carry:** fine-grained merge is _not_ automatically +merge-_correct_ — a drag writes x and y as one intent; per-field LWW can merge +half a drag. The harness demands _validity_ under partial merge (every merged +state is a legal document), not intent-perfection. Name where the candidate +draws that line. + +## H4. Layout resolvability + +**Statement.** Layout must resolve in a single deterministic pass, and every +interaction between user-set geometry and layout-owned geometry must be +defined. + +**Probe.** (a) Show the resolution order: stored intent → measures → boxes → +transforms, with no cycles (a child's layout contribution may not depend on +its assigned position). (b) Answer, from the spec alone: what does a rotated +child occupy in a flex row? What happens when the user sets x on an in-flow +child (error / detach / ignore — which, and is it typed)? What does `fill` +mean under a `hug` parent (the classic circular case)? + +## H5. Single source of truth + +**Statement.** Every queryable value has exactly one canonical home; all other +appearances are derived, and derivation runs one way. + +**Probe.** For each of {position, rotation, size, transform, bounds}: name the +canonical field and the derivation direction. Flags: any lossy extraction in +the steady-state pipeline (`atan2`, matrix decomposition) — permissible only +at import boundaries; any mutator that silently destroys sibling components +(the `set_rotation`-clobbers-scale class); any pair of fields that can +disagree with no defined winner. + +## H6. Defined-semantics coverage (the CSS lesson) + +**Statement.** Wherever the schema does not _refuse_ an invalid or inert +combination, a written rule must define it. Flat property surfaces don't come +for free — CSS's flat list is backed by an engine spec that defines every +property × display-type × context cell. Whatever flatness a candidate adopts, +it inherits that authorship burden. + +**Probe.** Build the applicability matrix: property × node-kind × context +(in-flow / absolute / free-canvas / inside-group / inside-transform-wrapper). +Every cell is one of {effective, ignored-by-rule, error-by-rule, +unrepresentable}. Zero cells may read "undefined". Count the +`ignored-by-rule` cells — each is documentation debt the candidate is +choosing over type-system refusal (H5 tension, see map below). + +## H7. Animation readiness (shape, not feature) + +**Statement.** The model must not preclude an animation system: channels have +defined interpolation, evaluated values are overrides (never document +writes), and layout-coupled vs compositor-only motion are distinguishable. + +**Probe.** For each geometric property: is it interpolable as stored (scalar +lerp) or does it require decompose/recompose (with the known pathologies)? +Is multi-turn rotation (720°) representable or explicitly out of scope? Where +does an evaluated value live such that sync/CRDT never sees animation +traffic? Which lane does an animated rotation take, and does the spec make +both lanes expressible? + +## H8. Capability ceiling & loss policy + +**Statement.** Everything the renderer can draw is either representable, or +excluded by an _explicit, named_ policy (quarantine construct or defined +degradation) — never silently capped. + +**Probe.** Walk the capability list — translation, rotation, uniform scale, +non-uniform scale, skew, arbitrary 2×3, perspective/3D — and mark each: +representable-everywhere / representable-in-quarantine / degraded-by-rule / +rejected. Then run the import test: an SVG with `transform="skewX(20)"` and a +Figma file with a flipped node — state exactly what the document stores. +Silent loss fails. + +## H9. Format-encoding reality (FlatBuffers rules) + +**Statement.** The candidate must be encodable under the binding rules in the +`grida.fbs` header: scalars cannot express "unset"; structs are frozen forever +and always-present; unions/tables are the only homes for optionality and +intent; evolution is additive. + +**Probe.** For every field with an "unset / auto / inherit" meaning: is that +state structural (nullable table, union, mode enum) rather than sentinel? +For every struct: is all-zeros the true semantic default forever? For the +node-kind story (P8): does adding a kind or property require only additive +changes? + +## H10. Hot-loop projectability + +**Statement.** Whatever the canonical model, it must project to dense, +`Copy`, cache-friendly per-node records for the render/layout DFS — the +engine's performance posture depends on it. + +**Probe.** Sketch the projection: canonical → per-node geometry record +(fixed-size, no heap, no string keys) built once per structural change. +Flags: any per-frame canonical lookup that is a map-of-properties walk; any +projection that cannot be invalidated per-field. (Evidence this is binding: +`NodeGeoData` ~48-byte `Copy`, `NodeLayerCore` ~16-byte `Copy` exist precisely +because the current schema enum was too fat for the DFS.) + +## H11. Sentinel-freedom + +**Statement.** No in-band magic values, at any layer of the model — not just +the wire format (H9 covers encoding) but the model itself: its editor IR, +runtime structs, and API. "0 means auto", "(0,0) means unset", "empty string +means none" are all failures. Unset/none/auto must be structural: key +absence (sheet), an Option/union (struct), or an explicit mode discriminant. + +**Probe.** Enumerate every field. For each: what does "not set" mean, and +how is it encoded? Any overlap between the value domain and an unset marker +fails. Special attention to switch-adjacent fields: when a mode discriminant +exists, the _retained_ inactive values must not need sentinels either. + +## H12. Set-means-set (deterministic effectiveness) + +**Statement.** The model must be a natural **editor IR**: after +`set(node, field, value)`, whether the value is _effective_ must be +(a) locally decidable — from the node and its explicit discriminants, +possibly plus its parent context, never from sibling-key presence +combinatorics — and (b) guaranteed-or-rejected: a write is either effective +or fails with a typed error. Arbitration ("write anything; rules pick +winners later") is disqualified for the canonical model. CSS affords +arbitration because humans hand-author it without tooling; a +dedicated-editor document does not need or want that leniency. + +**Probe.** For every writable field: after a successful write, can a reader +determine effectiveness without global rules over which _other_ keys happen +to exist? Retained dormant values are permitted **only** under an explicit +discriminant (the axes.md flattening (b) pattern — switch-memory with +readable truth); presence-arbitrated dormancy (flattening (c)) fails. + +--- + +## Tension map + +Harnesses conflict pairwise; a candidate that claims to max all of them is +hiding a trade. Naming the axis is phase-1 work; _choosing_ a point on it is +phase 2. + +| tension | axis | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **H5 ↔ H6/H3-flatness** | Refusal-by-construction wants per-type schemas; uniform merge/codecs want flat property space. The more uniform the surface, the more semantics migrate from types into a rulebook someone must write (and validators must enforce). | +| **H1 ↔ H8** | Named scalars read; full capability (skew, 3D, arbitrary matrices) doesn't. Can't have both _in the same place_ — hence the quarantine option, which is itself a new node kind to justify (P9). | +| **H3 ↔ H2** | Property-granular merge vs gesture-granular intent: a drag is one intent over two fields. Finer merge granularity = more valid-but-unintended merged states. | +| **H4 ↔ P3** | Layout wants to own the box; shape geometry _is_ the box for parametric shapes. Resolved-size must feed drawing without feeding back into storage (P4's wall). | +| **H2 ↔ H7** | Gestures write the document; animation must not. Two mutation systems address the same conceptual properties through different tiers — the tiers must be explicit or writes will leak. | +| **H10 ↔ P8-flat** | Property bags are hostile to dense hot-loop records; per-type structs project trivially. Flat-canonical designs pay a projection layer; typed-canonical designs pay the N×M threading. | +| **H9 ↔ everything** | FlatBuffers pushes toward tables/unions (nullable, evolvable) and away from the compact structs that H1/H10 instincts produce. The encoding is not neutral. | +| **H12 ↔ switch-memory UX** | A dedicated editor wants inactive values to survive mode toggles (hug↔fixed restores the old size); strict determinism forbids ambient dormancy. Resolved, not traded: retained values under an explicit discriminant (axes.md flattening (b)) give both — the discriminant keeps truth readable while inactive fields persist. | diff --git a/model-v2/models/a.md b/model-v2/models/a.md new file mode 100644 index 0000000000..5ddab1bffd --- /dev/null +++ b/model-v2/models/a.md @@ -0,0 +1,459 @@ +# Model A — `anchor` + +**anchor** — the anchored box model. Geometry is _bound_, not placed: +position is a relation the engine must resolve. The name prices the model's +cost — an anchor is nothing until resolved against what holds it. + +Status: **proven in lab** (2026-07-07 — decided winner, then implemented +and run through experiments E1–E6; see [`../a/REPORT.md`](../a/REPORT.md)). +This document is still the phase-2 draft: the phase-3 rewrite must fold in +the earned amendments **E-A1…E-A7** (derived-box origin placement, native +flip, the two stretches, grow×rotation, underdetermined bindings, M-4 +policy, envelope-peak note) and lock rotation-in-flow as **layout-visible** +(E1). Originally designed from scratch against +[`../problems.md`](../problems.md) and [`../harnesses.md`](../harnesses.md); +implements the ratifiable parts of [`../paradigm.md`](../paradigm.md) as a +concrete schema. + +This document is the model: types, fields, defaults, resolution algorithm, +applicability matrix, and harness scorecard. Field names are proposals; +semantics are the commitment. + +--- + +## 0. Ground commitments + +- The document stores **declared intent only**. Resolved geometry (boxes, + matrices, bounds) is engine output and is never serialized. +- **No stored matrices** outside the `lens` kind. No `transform` field exists + on any node. +- Angles are **degrees**, `f32`, positive = clockwise in screen space + (y-down; CSS convention). Lengths are logical px, `f32`. +- Document structure is unchanged from the current fbs draft: flat node map, + parent reference + fractional index for sibling order. + +--- + +## 1. The node + +Every node is **header + payload**. The header is identical for all kinds +(one spec, one applicability matrix). The payload is typed per kind. + +``` +Node { + // ---- identity (unchanged from SystemNodeTrait) ---- + id + name? + active = true + locked = false + + // ---- hierarchy (unchanged) ---- + parent?: { id, order: FractionalIndex } + + // ---- geometry intent ---- + x: AxisBinding = pin(start, 0) + y: AxisBinding = pin(start, 0) + width: SizeIntent = kind default (§4) + height: SizeIntent = kind default (§4) + min_width?, max_width?, min_height?, max_height?: f32 + aspect_ratio?: (f32, f32) // advisory; resolves an under-specified axis + rotation: f32 = 0 // degrees; pivot per §5 + + // ---- flow intent (child-side layout) ---- + flow: InFlow | Absolute = InFlow // meaningful only under a layout parent + grow: f32 = 0 // main-axis growth factor + self_align: Auto | Start | Center | End | Stretch = Auto // cross-axis + + // ---- layer (unchanged semantics) ---- + opacity: f32 = 1 + blend_mode = pass_through + mask?: MaskType + effects: LayerEffects + + // ---- typed payload ---- + payload: +} +``` + +Deliberately absent from the header: `transform` (abolished), `position` mode +enums (abolished — replaced by per-axis bindings), per-kind size fields +(abolished — one size intent). + +--- + +## 2. Geometry intent types + +### 2.1 AxisBinding — position as a relation, not a coordinate + +``` +AxisBinding = + | Pin { anchor: Start | Center | End, offset: f32 } + | Span { start: f32, end: f32 } // both edges bound; extent derived +``` + +Resolution against the parent's resolved box (extent `E`), for a node of +resolved extent `w` on that axis: + +| binding | resolved start (`x0`) | resolved extent | +| ---------------- | --------------------- | --------------- | +| `Pin{Start, o}` | `o` | from SizeIntent | +| `Pin{End, o}` | `E − o − w` | from SizeIntent | +| `Pin{Center, o}` | `(E − w)/2 + o` | from SizeIntent | +| `Span{a, b}` | `a` | `E − a − b` | + +- `x = 10` is sugar for `Pin{Start, 10}`. "x" _is_ "left". +- "right: 24" is `Pin{End, 24}` — **the intent is stored** (P2, P4). +- `Span` **owns the axis extent**: SizeIntent on a spanned axis is + `ignored-by-rule` (§8). The classic left+width+right conflict is thereby + a documented no-op, not a runtime surprise. +- Bindings bind the **unrotated box's** edges; rotation applies afterward + about the box center (§5). With a center pivot the box and its rotated AABB + are concentric, so `Pin{Center}` is exactly visual-center regardless of θ. + _(Open refinement, recorded: AABB-edge binding for Start/End pins — + θ-dependent but visually tighter. v1 binds the box.)_ +- Offsets are px in v1. Percent/scale anchoring is an additive variant later + (`Pin` offset becomes a length union in the encoding; no layout change). + +### 2.2 SizeIntent — two values, not three + +``` +SizeIntent = Fixed(f32) | Auto +``` + +- `Fixed` — declared extent. +- `Auto` — the kind's natural extent: measured kinds → measure result under + constraints; `frame` → hug children; declared-shape kinds → **invalid** + (`error-by-rule`, §8). +- There is **no `Fill`**. Growth and stretching are expressed where they + belong, eliminating redundant encodings (H5): + - main-axis fill under flex → `grow: 1` (+ `Auto`/`Fixed` basis) + - cross-axis fill under flex → `self_align: Stretch` + - fill of an absolute/free child → `Span{0, 0}` + +Min/max clamp the resolved extent last. `aspect_ratio` resolves an axis only +when exactly one axis is under-specified; it never overrides `Fixed` and +never violates min/max (current semantics, kept). + +--- + +## 3. Kinds and payloads + +Ten kinds. Cross-cutting style is one shared component, not per-kind fields: + +``` +SurfaceStyle { fills: [Paint], strokes: [Paint], stroke_width, stroke_style, + corner_radius: RectangularCornerRadius, corner_smoothing } +``` + +| kind | payload | box source | children | +| -------- | ------------------------------------------------------------------ | -------------------------- | --------------------------------------------- | +| `frame` | `LayoutBehavior` (§3.1) + `SurfaceStyle` + `clips_content: bool` | declared (Auto = hug) | yes | +| `tray` | `SurfaceStyle` | declared | yes (canvas organization; no clip, no layout) | +| `shape` | `ShapeDescriptor` (§3.2) + `SurfaceStyle` | declared | no | +| `image` | `ResourceRef` + `ImageFit` + `SurfaceStyle` | declared | no | +| `text` | content + `TextStyle` + align + overflow (`max_lines`, `ellipsis`) | measured | no | +| `embed` | `format: markdown \| html` + source + `SurfaceStyle` | measured | no | +| `vector` | `VectorNetwork` + `SurfaceStyle` + markers | measured (network bounds) | no | +| `group` | — (empty) | derived (children union) | yes | +| `bool` | `op: union\|intersect\|subtract\|xor` + `SurfaceStyle` | derived (op result bounds) | yes (operands) | +| `lens` | `ops: [LensOp]` (§3.3) | derived (children union) | yes | + +Kind consolidations vs today: Rectangle/Ellipse/Polygon/RegularPolygon/Star/ +Path collapse into `shape` (adopting the fbs `BasicShapeNode` + +`CanonicalLayerShape` direction); `InitialContainer` is abolished as a kind — +the scene root is a `frame` whose bindings span the viewport (P7 +regularized); `TextSpan`/`AttributedText` are one `text` payload (attributed +runs optional within it); Markdown/HTML embeds are one `embed`. + +### 3.1 LayoutBehavior (frame only — container side) + +Unchanged in spirit from today; restated for completeness: + +``` +LayoutBehavior { + mode: None | Flex = None + direction: Row | Column = Row + wrap: bool = false + main_align: Start | Center | End | SpaceBetween | SpaceAround | SpaceEvenly = Start + cross_align: Start | Center | End | Stretch = Start + padding: EdgeInsets = 0 + gap: { main: f32, cross: f32 } = 0 +} +``` + +`mode: None` = the frame positions children by their bindings (free canvas +inside a box). `mode: Flex` = the frame owns in-flow children's position. +Grid is an additive future `mode` variant. + +### 3.2 ShapeDescriptor — geometry is a function of the box + +Size-free, normalized descriptors evaluated at the resolved box (the fbs +`CanonicalLayerShape` position, made runtime-true — P3): + +``` +ShapeDescriptor = + | Rect + | Ellipse { inner_ratio: f32 = 0, start_angle: f32 = 0, sweep: f32 = 360 } + | RegularPolygon { points: u32 } + | Star { points: u32, inner_ratio: f32 } + | Polygon { points: [(f32, f32)] } // normalized 0..1, scaled to box + | Path { d: string } // normalized 0..1, scaled to box + | Line // the box's horizontal midline; height intent must be Fixed(0) +``` + +`vector` is deliberately _not_ a descriptor: its network is absolute-local +editable geometry, hence a measured box. `shape/Path` (render-only, +normalized) vs `vector` (editable, measured) is the same split the format +draft already makes. + +Stroke endpoint markers live in `SurfaceStyle`-adjacent stroke props and are +honored only on open geometry (`Line`, open `Path`, open network) — +`ignored-by-rule` elsewhere. + +### 3.3 LensOp — the capability quarantine + +``` +LensOp = // ordered list; applied in sequence, post-resolution + | Translate { x, y } + | Rotate { deg, origin: Alignment = center } + | Scale { x, y, origin: Alignment = center } + | Skew { x_deg, y_deg } + | Matrix { m00..m12 } // 2×3 escape hatch + | Perspective { d } // 3D vocabulary, spec-reserved + | Rotate3D { x_deg, y_deg, origin } // (implementation staged) + | Matrix3D { m: [16] } +``` + +The `lens` node is the **only** home for skew, arbitrary matrices, and 3D. +It is layout-transparent: its derived (pre-ops) box is what participates in +the parent; ops never affect layout. Imports that hit non-decomposable +transforms **wrap in a lens instead of degrading** (H8). Each op parameter is +a named scalar — an animation channel for free (P10). + +Ordinary rotation must never be authored as a lens — validators flag a lens +containing only `Rotate` as "use header rotation" (guards the dual-source +hole, H5). + +--- + +## 4. Kind defaults (size intent) + +| kind | width default | height default | +| --------------------------------- | --------------------------------------------------- | ------------------- | +| `frame`, `tray`, `shape`, `image` | `Fixed` (required at creation) | `Fixed` | +| `text` | `Auto` | `Auto` | +| `embed` | `Fixed` | `Auto` | +| `vector`, `group`, `bool`, `lens` | _(no size intent — derived; field ignored-by-rule)_ | — | +| `shape/Line` | `Fixed` | `Fixed(0)` (locked) | + +--- + +## 5. Rotation semantics + +- **Pivot**: boxed and measured kinds — the **box center**, always. No + stored origin. (Center is the one pivot where the box and its rotated AABB + are concentric, so layout placement and center-pins need zero correction + terms.) +- **Derived-box kinds** (`group`, `bool`, `lens`): pivot = the node's **own + local origin** (the point its bindings place). A center-feel rotation + gesture writes `rotation` _and_ compensates `x`/`y` offsets — the Figma + compensation trick, performed over three legible scalars instead of a + matrix. Child edits never write the group (P6). +- **Layout participation** (P5): a rotated in-flow child contributes its + oriented AABB — `w' = |w·cosθ| + |h·sinθ|`, `h' = |w·sinθ| + |h·cosθ|` — + computed from its resolved size only, never its assigned position + (single-pass safe). Its box center is then placed at the slot center. +- Rotation that would change _wrap constraints_ (rotated text reflowing + against a rotated width) is explicitly not modeled: children measure + unrotated, then the AABB wraps the result — the same dodge every studied + system takes. +- Motion rotation (animation) targets a lens channel, not this field, unless + layout-coupled motion is explicitly intended (P10 two-lane). + +--- + +## 6. Resolution algorithm + +Pure function: `resolve(document, fonts, resources, viewport) → Resolved`. + +``` +Phase M — measure (bottom-up) + text/embed: natural size under intent constraints + (Fixed width ⇒ wrap constraint; Auto ⇒ unconstrained) + vector/bool: geometry bounds (bool: after path op) + frame(Auto): hug = children extents (uses phases M+L of the subtree) + +Phase L — layout (top-down, per parent) + parent frame with mode: Flex, child flow: InFlow + → flex negotiation (Taffy) over child AABB contributions (§5), + grow / self_align / min-max applied; measured kinds re-measure at + layout-imposed extents (standard measure-function flex) + → child box center := assigned slot center + otherwise (Absolute child, or parent without layout) + → bindings resolve per §2.1 against parent's resolved box + → SizeIntent per §2.2, clamped by min/max, aspect_ratio if applicable + +Phase T — transforms + boxed/measured: local = T(x0, y0) · R_center(θ, w, h) // = from_box_center + derived-box: local = T(x0, y0) · R(θ) // origin pivot + lens: world = parent_world ∘ local ∘ eval(ops) + all: world = parent_world ∘ local + +Phase B — bounds + oriented corners → world AABBs → render-bounds inflation (unchanged) +``` + +No cycles: measure never reads position; AABB contribution never reads +assigned position; `Span`/`Pin` read only the parent's resolved box. + +**Reads and writes** (P4): `x, y, width, height, rotation` are always +readable — from the resolved tier. Writes re-target intent: setting `x` on a +`Pin{End}` axis rewrites the end offset so the resolved x becomes the given +value; setting `width` on a spanned axis is a **typed error** +(`AxisOwnedBySpan`); setting `x` on an in-flow child under flex is a typed +error (`OwnedByLayout`) — the editor's affordance is "set flow: Absolute". +Resolution never writes back to the document. + +--- + +## 7. Worked examples (H1 — the XML quartet) + +```xml + + + + + + + + + + hello + + + + + + + + + + + + +``` + +--- + +## 8. Applicability matrix (H6 — the header, complete) + +Contexts: **free** (parent has no layout / child `Absolute`), **flow** +(parent flex + child `InFlow`), **derived** (kind has derived box). +Every cell defined; zero "undefined". + +| header field | free | flow | derived-box kinds | +| ------------------------------------------ | --------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------ | +| `x`, `y` | effective | **ignored-by-rule** (layout owns; write = typed error) | effective (places the space) | +| `width`, `height` | effective (`Span` overrides: ignored-by-rule) | effective as basis (grow/stretch may override resolved value) | **ignored-by-rule** (box derived) | +| `min/max_*` | effective | effective | ignored-by-rule | +| `aspect_ratio` | effective when one axis under-specified | same | ignored-by-rule | +| `rotation` | effective (center pivot) | effective (AABB participates) | effective (origin pivot) | +| `flow` | inert (no layout parent) — ignored-by-rule | effective | same rules as boxed | +| `grow`, `self_align` | ignored-by-rule | effective | same rules as boxed | +| `opacity`, `blend_mode`, `mask`, `effects` | effective | effective | effective (`group`: effects ignored-by-rule, as today) | +| `Auto` size on `shape`/`image` | **error-by-rule** (no natural size) | error-by-rule | n/a | + +The matrix is nine rows — the price of L5's uniform header, and it is now +paid in full rather than owed. + +--- + +## 9. Encoding sketch (H9 — FlatBuffers) + +Respects the fbs header rules: intent states are structural (unions/tables), +never scalar sentinels; evolution is additive. + +```fbs +table Pin { anchor: AnchorEdge = Start; offset: float = 0; } +table Span { start: float = 0; end: float = 0; } +union AxisBinding { Pin, Span } // absent ⇒ Pin{Start, 0} + +table SizeFixed { value: float; } +table SizeAuto {} +union SizeIntent { SizeFixed, SizeAuto } // absent ⇒ kind default (§4) + +table NodeHeader { + id: NodeIdentifier (required); + parent: ParentReference; // + fractional index (unchanged) + x: AxisBinding; y: AxisBinding; + width: SizeIntent; height: SizeIntent; + min_width: SizeFixed; max_width: SizeFixed; // nullable tables, not sentinels + min_height: SizeFixed; max_height: SizeFixed; + aspect_ratio: CGSize; // (0,0) ⇒ unset (existing convention) + rotation: float = 0; + flow: Flow = InFlow; grow: float = 0; self_align: SelfAlign = Auto; + opacity: float = 1; blend_mode: LayerBlendMode = PassThrough; + mask_type: LayerMaskType; effects: LayerEffects; + active: bool = true; locked: bool = false; name: string; +} + +union NodePayload { FramePayload, TrayPayload, ShapePayload, ImagePayload, + TextPayload, EmbedPayload, VectorPayload, GroupPayload, + BoolPayload, LensPayload } + +table NodeSlot { header: NodeHeader (required); payload: NodePayload; } +``` + +Consequences for the current draft: `LayerTrait.layout`, +`LayoutPositioningBasis`, `post_layout_transform`-as-rotation-home are +superseded; `post_layout_transform(_origin)` slots are retired in favor of +the `lens` kind (one home, not two). `CanonicalLayerShape` survives nearly +verbatim as `ShapePayload`'s descriptor. + +--- + +## 10. Harness scorecard + +| harness | verdict | note | +| ---------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| H1 readability | **pass** | §7; no matrix outside `lens` | +| H2 gestures | **pass** | drag → 2 offsets; resize → 1–2 fields; rotate boxed → 1 field; rotate group → 3 fields (compensation, single node); reads always answerable | +| H3 CRDT | **pass w/ named caveat** | all intent = scalars/enums; no coupled tuples except gesture-level pairs (drag x+y, Span a+b) — merged states valid, compound intent not reconstructed | +| H4 layout | **pass** | §6 single-pass; rotated-in-flex defined (§5); x-write under flow = typed error; fill-under-hug resolved by standard flex (grow distributes _available_ space; hug uses basis sizes — no cycle) | +| H5 single source | **pass** | matrices tier-2 only; no atan2/decompose anywhere in steady state; lens-only-rotate flagged | +| H6 coverage | **pass** | §8 matrix complete, 0 undefined cells | +| H7 animation | **pass** | channels = header scalars (layout-coupled lane) or lens op params (compositor lane); winding representable (scalar degrees); overrides tier-3, never serialized | +| H8 capability | **pass** | full 2×3 + 3D representable via `lens`; import policy = wrap, never silent loss | +| H9 encoding | **pass** | §9; unset structural, evolution additive | +| H10 hot loops | **pass** | resolved tier materializes to the existing dense-record shape (`NodeGeoData` becomes the tier-2 record, minus its schema/rotation dualities) | + +**Declared trades** (from the tension map): header uniformity costs the +`ignored-by-rule` cells in §8; legibility costs a wrapper node for skew; +layout-visible rotation costs reflow on rotation _edits_ under flex (motion +goes to lens channels); intent storage costs read-indirection (already paid — +the resolved tier is today's geometry cache). + +--- + +## 11. What this abolishes (runtime + format deltas, coarse) + +- Per-node `transform: AffineTransform` fields — gone (P1). `math2`'s + `rotation()` / `set_rotation()` leave the steady-state pipeline; the + orphaned `from_box_center` becomes _the_ transform constructor. +- `LayoutPositioningBasis` (Cartesian/Inset/deprecated-Anchored) — gone (P2). +- Container `position + rotation` vs leaf `transform` split — gone; one + header (P1, P8). +- `resolve_layout`'s per-kind branch forest — replaced by §6's four phases. +- `NodeGeoData.schema_transform`/`rotation` duality — the tier-2 record holds + resolved values only. +- Six shape node kinds → one `shape` kind + descriptor. +- `InitialContainer` as a node kind — a viewport-bound `frame`. + +## 12. Open items this model defers + +Percent/scale offsets in `Pin` (additive); AABB-edge vs box-edge binding for +rotated absolute pins (§2.1 note); grid mode; anchor-to-node (WG Level 4 — +`Pin` grows a target ref, additive); attributed-text run encoding inside +`TextPayload`; `bool` operand semantics (does a `bool` child render +standalone — kept as today); tray's exact root-treatment semantics. diff --git a/model-v2/models/b.md b/model-v2/models/b.md new file mode 100644 index 0000000000..193874e60f --- /dev/null +++ b/model-v2/models/b.md @@ -0,0 +1,262 @@ +# Model B — `sheet` + +**sheet** — the property sheet model. A node _is_ a flat sheet of registered +properties, arbitrated by rulebook. The name prices the model's cost — sheets +accumulate: entries can coexist, contradict, go dormant, and resurrect. + +Status: **finalist, under a re-reading** (see [`../finale.md`](../finale.md)). +History of this status, kept honest: + +1. First **re-scoped** by [`../axes.md`](../axes.md): as an _invented_ + CSS-lookalike IR, its arbitration semantics fail **H12 (set-means-set)** + and its representation/protocol survive as Axis-2 options. +2. Then **re-instated to the finale** under the _adopt_ reading: CSS + top-to-bottom, nothing invented — CSSWG specs as the normative text, + Chromium as the conformance oracle (the repo's `htmlcss` parity loops + already practice this), Taffy/DOM-backend as native implementations, + editor-layer write discipline answering H12 (the Webflow precedent: + CSS-as-design-tool-IR is proven). The H12 model-level fail _stands_ and + is consciously carried, not waved away. + +The document below is the original undivided design; read it with the +adopt re-reading in mind. + +Originally: **proposed** (phase 2 candidate, rival to +[`anchor`](./a.md)). CSS-faithful, taken seriously end-to-end: a node is a +sparse sheet of registered properties; conflicts are resolved by rulebook, +not by type structure; transforms are post-layout. + +Designed best-faith: this is what a 2026 flat model looks like when built by +someone who believes in it, with its costs named rather than hidden. + +--- + +## 0. Ground commitments + +- **One node shape.** A node is `{ id, parent+order, kind, sheet }` where + `sheet` is a sparse map over a **closed, versioned property registry**. + There are no per-kind fields; `kind` selects render behavior and an + applicability column, like CSS `display`. +- **One mutation op.** The entire edit protocol is + `set(node, property, value)` / `clear(node, property)`. History, undo, + diffing, patching, multiplayer, inspector tooling — all uniform over + property triples. This is the model's crown jewel. +- **Conflicts are resolved, not prevented.** Over-constrained geometry is + legal in the document; the registry's resolution rules pick winners. + The price is _dormancy_ (§3). +- **Transforms are post-layout** (CSS semantics). Layout never sees rotation; + a rotated flex child occupies its untransformed box and may visually + overlap siblings. Defined, documented, designer-surprising. +- Unset property = the registry's **initial value** (CSS "initial value" + concept). Unset is absence in the sheet — cheap and CRDT-clean. + +--- + +## 1. The property registry (v1) + +The registry is the spec: every property row defines +**type · initial · applies-to · resolution/conflict rule**. Grouped: + +``` +identity name, active=true, locked=false +geometry left?, right?, top?, bottom? // px; each independently optional + width?=auto, height?=auto // px | auto + min-width?, max-width?, min-height?, max-height? + aspect-ratio? +transform rotation=0, scale-x=1, scale-y=1, translate-x=0, translate-y=0 + transform?=[] // op list incl. skew/matrix/3D + transform-origin=(50%, 50%) +flow layout=none|flex, direction=row, wrap=false, + main-align=start, cross-align=start, padding=0, gap=(0,0) + position=in-flow|absolute, grow=0, self-align=auto +layer opacity=1, blend-mode=pass-through, mask?, effects? + clips-content=false +surface fills=[], strokes=[], stroke-width, stroke-style, + corner-radius, corner-smoothing +content content? // one slot, typed union (§4) +``` + +~40 properties. Every property exists on every node; whether it _does +anything_ is the registry's applies-to column (§6). Adding a capability = +adding a registry row — no node kind is ever touched (additive evolution at +its purest). + +--- + +## 2. Geometry resolution (the rulebook core) + +Per axis, from the sheet (parent's resolved extent `E`): + +``` +extent w := width if set + else E − left − right if both left,right set + else natural(content) if measurable + else initial per kind (shape: error-by-rule, as Model A) +start x0 := left if left set + E − right − w else if right set + (E − w)/2 else if neither (free default: 0 — see rule R1) +clamp: min/max, then aspect-ratio if one axis under-specified +``` + +**Conflict rules (fixed, direction-independent — a canvas has no RTL):** + +- R1 unset/unset → `x0 = 0` (top-left default; no CSS "static position" + mystery). +- R2 `left` beats `right` when `width` is also set (`right` goes dormant). +- R3 `left+right` beat `width` for extent **only when `width` is unset**; + a set `width` wins and `right` goes dormant (R2). + +**Dormancy — the model's characteristic hazard, stated:** +a losing property stays in the sheet and **resurrects** when its winner is +cleared. Set `left=10, width=120`, later also `right=24` (dormant); clear +`width` → the node silently becomes a span. This is exactly CSS's behavior, +it is _defined_, and it is genuinely surprising. Model B accepts it as the +cost of never rejecting a write. (Contrast: Model A makes the conflict +unrepresentable; Model B makes it legal-and-arbitrated.) + +Under a flex parent with `position: in-flow`, the geometry insets are +_ignored-by-rule_ (layout owns position; the properties stay in the sheet +and resurrect on detach — dormancy again, here doing useful work: detaching +restores the pre-layout position intent). + +--- + +## 3. Transforms — post-layout, CSS Transforms Level 2 shape + +Composition, after layout has produced the box, about `transform-origin` +(default center): + +``` +final = translate(translate-x, translate-y) + · rotate(rotation) + · scale(scale-x, scale-y) + · eval(transform) // the op list: skew | matrix | perspective | rotate-3d | matrix-3d +``` + +- The individual properties (`rotation`, `scale-*`, `translate-*`) are the + **animation channels** — scalar, interpolable, winding-preserving. The + `transform` list is the capability escape hatch, **on every node** — no + quarantine wrapper exists or is needed (H8 everywhere; H1 pays, §7). +- **Layout never sees any of it.** The AABB fed to flex is the untransformed + box. Rotated children overlap; the canvas visually lies relative to what a + designer expects from auto-layout (the CSS trade, taken knowingly). +- This _is_ the two-lane animation story with only one lane: all motion is + compositor-side. Layout-coupled motion (Figma smart-animate push) is out of + scope by design — animating `width` re-layouts, animating `rotation` never + does. + +## 4. Content — one slot + +`content` is a single property whose value is a typed union: + +``` +content = Shape(descriptor) // size-free, box-mapped (same descriptors as Model A §3.2) + | Text(content, style, align, overflow) + | Image(ref, fit) + | Vector(network) + | Embed(markdown | html) +``` + +One slot means "a node with both text and shape" is unrepresentable — the +flat model borrows exactly one page from the typed book, because multi-content +precedence rules are the rulebook nobody can write well. `kind` and +`content` must agree (`kind` is derivable = redundant; kept as a fast +discriminant, validator-enforced — a named single-source concession, H5). + +Groups/booleans/frames: `content` unset; children + `layout`/`bool-op` +properties define behavior. (`bool-op` is one more registry row, effective +only on nodes with children.) + +## 5. Box sources, rotation pivot, groups + +Orthogonal choices are inherited from Model A where B has no philosophical +stake: measured kinds (text/embed/vector) measure under +width-intent-as-constraint; groups/booleans derive bounds from children; +group `rotation` pivots at own origin with gesture compensation +(`transform-origin` applies to _transform properties_, which groups may also +use — dormancy rules arbitrate). Center pivot default via +`transform-origin: 50% 50%`. + +## 6. Applicability + +The registry's applies-to column, by property group × behavior context. +Because _every_ property exists on _every_ node, the matrix is +**~40 rows × contexts** — several times Model A's. Sample of the non-obvious +cells: + +| property | on childless node | on in-flow child | on group/bool | +| ----------------------------- | ----------------------------- | ---------------------------------------------- | ---------------------------- | +| `layout`, `direction`, `gap`… | ignored-by-rule (no children) | effective (it may itself be a container) | ignored-by-rule (group), n/a | +| `left/right/top/bottom` | effective | **dormant** (layout owns; resurrect on detach) | effective | +| `width/height` | effective / measure | effective as basis | ignored-by-rule (derived) | +| `grow`, `self-align` | ignored-by-rule | effective | same as boxed | +| `transform`, `rotation`… | effective (post-layout) | effective (post-layout — overlap allowed) | effective | +| `content` | effective | effective | ignored-by-rule | + +The full matrix is normative and must ship with the format — **this is the +CSS lesson as a deliverable**: B is only viable if this rulebook is written +with CSS-spec seriousness. Estimated at 5–10× Model A's documentation +surface. + +## 7. Worked examples (H1 quartet) + +```xml + + + + + + + + +``` + +Flat attributes are XML-native — B arguably reads _best of the three_ at +rest. The illegibility risk is different: nothing in the tree marks node (e) +as exotic, and a sheet can carry dormant properties invisible to a reader +who doesn't know the rulebook. + +## 8. Encoding sketch (H9) + +Two honest options, both with costs: + +1. **Wide optional table** — one FlatBuffers table, ~40 nullable + table/union fields. Encodable, statically typed, additive. It is exactly + the "all-flat mega-table" the current fbs commentary (§ "union-per-variant + rationale") argues against — adopting B means _reversing that recorded + decision_, not sneaking past it. +2. **Registry key–value vector** — `[{prop: PropId, value: PropValue-union}]`. + Maximally uniform (mirrors the mutation protocol), but loses static field + typing and pays a union dispatch per property. + +v1 recommendation within B: option 1 (typed, tooling-friendly), with the +mutation protocol living above the encoding. + +## 9. Harness scorecard + +| harness | verdict | note | +| ---------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| H1 readability | **pass** | best-of-three attribute ergonomics; risk = dormant props + unmarked exotic transforms | +| H2 gestures | **pass** | uniform triples; every write legal (no typed errors — conflicts arbitrated instead) | +| H3 CRDT | **pass+** | per-property LWW over one op type; the uniformity is unmatched. Gesture-coupling caveat same as A | +| H4 layout | **pass w/ named lie** | single-pass, transforms excluded; rotated in-flow child _overlaps_ — defined but violates canvas-truth expectation | +| H5 single source | **trade** | dormancy = defined-but-resurrecting dead intent; `kind`/`content` redundancy validator-held | +| H6 coverage | **pass at high cost** | the rulebook is the product; ~40-row normative registry, CSS-scale authorship burden | +| H7 animation | **pass+ (best of three)** | scalar channels native, all compositor-lane, CSS animation model maps 1:1; _no_ layout-coupled motion lane | +| H8 capability | **pass** | full transform stack incl. 3D on every node; no quarantine — capability is ambient (and invisible, see H1) | +| H9 encoding | **trade** | viable, but reverses the fbs draft's recorded union-per-variant rationale | +| H10 hot loops | **trade** | canonical reads are sheet lookups; dense projection is mandatory, invalidation per property-key | + +**Where B beats A:** mutation/tooling/sync uniformity, animation, raw +attribute readability, additive evolution. +**Where B loses to A:** layout visually lies under rotation, dormancy, +rulebook mass, refusal (nothing is unrepresentable), hot-loop distance. + +## 10. Deferred + +Registry versioning/feature-query mechanism; percent insets; whether `kind` +can be dropped entirely (pure `content`-discriminated nodes); custom/vendor +property namespace policy. diff --git a/model-v2/models/c.md b/model-v2/models/c.md new file mode 100644 index 0000000000..88bf69723a --- /dev/null +++ b/model-v2/models/c.md @@ -0,0 +1,202 @@ +# Model C — `bake` + +**bake** — the materialized matrix model. Layout is _baked_ into stored +state at edit time; the file is the resolved picture. The name prices the +model's cost — a bake must be re-baked when any input changes, and you +cannot un-bake it to recover the intent. + +Status: **proposed** (phase 2 candidate, rival to [`anchor`](./a.md)). +Matrix-canonical and state-canonical, Figma-faithful — but designed +best-faith for 2026: the known Figma scars that are fixable _within_ the +paradigm are fixed; the ones that are the paradigm are kept and priced. + +Its deep bet is different from A's and B's: **the document is the resolved +picture, not a program that produces one.** + +--- + +## 0. Ground commitments + +- **Geometry is a matrix plus a size.** Every node stores + `rt: [f32; 6]` (2×3 affine, parent-relative) and `size: (w, h)` — + uniformly, all kinds (fixing current-Grida's container/leaf split _within_ + the matrix paradigm). +- **The document stores state, not intent.** Layout runs at **edit time** + and materializes its results into the stored matrices and sizes. What is + on disk is what is on screen. +- Rotation, x, y are **derived lenses** over the matrix — never stored. +- Node kinds and typed payloads are kept as in Model A (flatness is B's + axis; C isolates the geometry/state axis). + +**A note on the "dumb renderer" claim — demoted by scoring rule T1** +([`../harnesses.md`](../harnesses.md)). An earlier draft scored "renders +with no layout engine" as `bake`'s unique win. It is not: _any_ model can +emit a materialized geometry snapshot as an auxiliary store for dumb +consumers — the current fbs draft's commented-out +`relative_transform_snapshot` field even names that idea (a _snapshot_, +riding alongside intent). What is intrinsic to `bake` is narrower and +double-edged: the snapshot **is** the canonical store. That buys +coherence-by-construction (the baked values cannot be stale, because there +is no upstream intent to drift from) and a trivial conformance spec (the +meaning of a file is "compose and paint" — no layout semantics to +standardize). It is also precisely where the model's costs originate: +intent loss, write amplification, coarse merges. Score the residue, not the +headline. + +--- + +## 1. The node + +``` +Node { + id, name?, active, locked + parent?: { id, order: FractionalIndex } + + rt: [f32; 6] // parent-relative affine. THE geometry. + size: (w: f32, h: f32) // unrotated box extent + + // ---- re-derivation intent (edit-time only; renderers ignore) ---- + constraint_x: Min | Max | Center | Stretch | Scale = Min + constraint_y: Min | Max | Center | Stretch | Scale = Min + flow: InFlow | Absolute = InFlow + grow: f32 = 0 + self_align: Auto | Start | Center | End | Stretch = Auto + size_mode_w, size_mode_h: Fixed | Hug | Fill = Fixed // frames/text + + // ---- layer (as Model A) ---- + opacity, blend_mode, mask?, effects + + payload: +} +``` + +The split to understand: `rt`/`size` are **the truth**; the +constraint/flow/size-mode block is **advice to editors** about how to +_rewrite_ the truth when context changes. A renderer never reads the advice. + +## 2. Derived lenses (API surface) + +- `x, y` — the translation column of `rt`. +- `rotation` — `atan2(m10, m00)`, degrees. **Setter fixed relative to + Figma:** setting `rotation` rebuilds `rt` pivoting at the **box center** + (Figma's API pivots at the origin while its UI pivots at center — the + scar is a pure API choice and C repairs it for free). +- `width/height` — `size`; the scale tool bakes into `size` (matrices stay + rigid + flip in UI practice), but the matrix _may_ carry scale/skew — + imports land losslessly with no wrapper (§4). +- All setters are matrix rewrites; all getters are closed-form. Lossy- + extraction hazards (atan2 under skew) exist **only at the lens**, read-only + — the stored value never round-trips through them (single-source is + genuinely clean here; cleaner than current-Grida's dual model). + +## 3. Edit-time materialization (the paradigm's engine) + +Layout and constraints run **when edits happen**, inside the editing session, +and write their results into `rt`/`size` of affected nodes: + +- **Auto-layout frame changes** (child added/removed/resized, gap/padding + edits, text reflow): the editor re-runs flex for the frame and writes each + in-flow child's new translation (and stretched sizes), plus the frame's own + hug size. Rotation participates as the child's rotated AABB (Figma + semantics — canvas never lies). +- **Parent resize**: constraints re-derive children (`Max` keeps + right-distance, `Scale` interpolates, `Stretch` adjusts size), writing + matrices/sizes. +- **Group edits**: groups store a fitted `size` + `rt`; any child geometry + edit triggers the **re-fit procedure** — recompute the fitted bounds, + rewrite the group's `rt`/`size`, and counter-adjust all children so world + positions hold. This is the known Figma dance, inherent to storing fitted + state; C owns it as a specified edit-time obligation rather than an + accident. + +Consequences, both directions: + +- Reads are free and exact everywhere — `node.x` is a number, not a query + (P4 answered by _abolishing_ the resolved tier, not by walling it). +- Intent is partially lost — "right: 24" exists only as + `constraint_x: Max` + current state; the offset is re-derived, never + stored. This is the exact WG-doc complaint, kept knowingly: in C, _state + is the point_. +- Write amplification — one text edit inside a deep auto-layout stack writes + matrices across the subtree; every such write is CRDT traffic and history + noise. Concurrent relayout ∥ manual-move conflicts are C's characteristic + merge hazard (arbitrated by property-atomic LWW on `rt`: someone's intent + loses whole). + +## 4. Capability + +The 2×3 matrix natively carries the full affine group — translation, +rotation, scale, flip, skew — **on every node, with no quarantine wrapper**. +SVG/Figma imports land verbatim (H8's 2D row: best of the three models). + +**3D does not exist** in C. A 2×3 cannot hold perspective; admitting an +`M44` would forfeit the compact-struct virtues the paradigm buys. 3D is +declared out of the document model (renderer-side effects only) — +an honest cap, priced in the scorecard. + +There is no `lens` kind; motion/animation overrides are runtime-tier +concerns outside the document (as in A), but their channels are weak (§6 H7). + +## 5. Worked examples (H1 quartet) + +```xml + + + + + + + + + + hello + + + + + + + +``` + +Example (a) _is_ the H1 verdict: a human cannot read `0.966 -0.259` as +"15°" without a calculator. A canonical serializer MAY emit sugar +(`rotation="15"`) when `rt` is rigid — but sugar is a codec courtesy; the +canonical form is six floats. + +## 6. Harness scorecard + +| harness | verdict | note | +| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| H1 readability | **fail (mitigable)** | matrices at rest; serializer sugar only for the rigid subset | +| H2 gestures | **pass** | drag/rotate/resize = 1–2 node writes (matrix+size); group edits = subtree re-fit writes (flag) | +| H3 CRDT | **trade** | `rt` merges atomically — no tearing, but move ∥ rotate = one intent lost whole; materialization = write amplification and relayout∥edit conflicts | +| H4 layout | **pass, relocated** | no runtime resolution at all; all definedness questions move to edit-time procedures, which must be specified with equal rigor | +| H5 single source | **pass** | one home (`rt`+`size`); lenses read-only; _cleaner than the current dual system_ | +| H6 coverage | **medium** | applicability matrix small (advice fields inert per context), but the edit-time procedure book (re-fit, re-derive, materialize) replaces it — comparable total mass | +| H7 animation | **fail-ish** | matrix channel = decompose/interpolate pathologies; no winding; would need bolted-on decomposed channels (CSS's retrofit, replayed) | +| H8 capability | **split** | 2D affine: best of three (ambient, lossless import). 3D: excluded by construction | +| H9 encoding | **pass (leanest)** | `CGTransform2D` struct exists; the current fbs draft's commented-out `relative_transform_snapshot` is literally this field waiting | +| H10 hot loops | **pass (native)** | matrix+size IS the hot-loop record; rivals pay a projection layer — a T1-mitigable cost, so not decisive | +| — dumb renderer | **T1-demoted** | replicable on any model via an auxiliary snapshot store; intrinsic residue = snapshot coherence-by-construction + trivial conformance spec (§0 note) | + +**Where C beats A — intrinsic only (post-T1):** single-source cleanliness +(one home, lenses read-only); trivial conformance spec (file meaning = +compose + paint, no normative layout spec); ambient 2D-affine import +fidelity (no wrapper ceremony); encoding leanness; no dual-representation +invalidation machinery (though edit-time relayout machinery takes its +place — partially a wash). +**Where C loses to A:** authoring legibility, intent preservation (the +original WG complaint is _kept_), merge granularity, write amplification, +animation, 3D ceiling. + +## 7. Deferred + +Exact re-fit/compensation procedures (normative pseudo-code needed if C +advances); whether `size_mode`/`constraint` advice suffices to reconstruct +intent for responsive re-targeting (the known ceiling of state-canonical +systems); flip representation conventions; text: stored size vs re-measure +obligations on font-availability drift (a real risk: a file materialized +with font X renders differently when X falls back — the dumb-renderer +guarantee is only as strong as resource pinning). diff --git a/model-v2/models/d.md b/model-v2/models/d.md new file mode 100644 index 0000000000..e5a50da0a6 --- /dev/null +++ b/model-v2/models/d.md @@ -0,0 +1,135 @@ +# Model D — `wire` + +**wire** — the wired geometry model. Geometry values are _wired_ to +referents anywhere in the document, not only to the parent; the document is +a dataflow graph, not a tree-scoped program. The name prices the model's +costs — a cut wire dangles (deleted referent), and wires can tangle +(merge-created cycles). + +Status: **proposed** (phase 2 candidate). The relational archetype of the +canonical-truth taxonomy — the last unexplored point on Axis 1. Not a blend +of the other models: its single new primitive (referent-general binding) +changes what the canonical store _is_. It is, deliberately, the model the +WG feat-layout doc's Level 4 ("anchor to arbitrary nodes, named anchors, +inter-element constraints") implies as a destination. + +--- + +## 0. Ground commitments + +- **Single-assignment dataflow, not a solver.** Each axis of each node is + bound to exactly **one** expression over referent boxes. The document is a + DAG evaluated in one topological pass — the _spreadsheet_ discipline + (every cell has one formula), explicitly **not** the Cassowary/Auto-Layout + discipline (simultaneous equations, priorities, multiple solutions). + Determinism and set-means-set (H12) survive because assignment is single + and directed. +- **The tree keeps its non-geometric jobs.** Parent-child still owns paint + order, clipping, opacity/blend inheritance, and coordinate spaces. Wires + carry _geometry dependency only_. The two graphs may disagree — that is + the point. +- Everything not named here is inherited unchanged from + [`anchor`](./a.md): size intent, rotation semantics (center pivot, AABB + under flow), payloads, kinds, lens quarantine, tiers. + +## 1. The one new primitive + +`anchor`'s binding gains a referent: + +``` +Pin { to: Referent = parent, to_edge: Start|Center|End, + from_edge: Start|Center|End = same-as-to_edge, offset: f32 } +Span { start: (Referent, Edge, offset), end: (Referent, Edge, offset) } + +Referent = Parent | Node(id) | Guide(id) +``` + +- `x = pin(start, 10)` still means "my left, 10 from parent's left" — + `anchor` is literally the `to = Parent` restriction of `wire`. +- `x = pin(to: #sidebar, to_edge: end, offset: 8)` — my left, 8 right of + the sidebar's right edge. Sibling chaining, badges, callouts. +- `Span` between two _different_ referents — the WG doc's auxiliary use + cases (comment bubbles, link lines, attached annotations) become ordinary + geometry instead of a separate overlay system. +- Referent geometry is read as the referent's **resolved world AABB**, + transformed into the bound node's parent space (a rotated referent binds + by its AABB — defined, if blunt). + +## 2. Resolution + +Build the axis-level dependency graph (node-axis → referent), topologically +sort, evaluate in one pass; in-flow children under a flex parent are owned +by layout exactly as in `anchor` (wires on owned axes: `ignored-by-rule`). +Still single-pass, still deterministic — the cost moves to _graph +maintenance_, not solving. + +## 3. The new hazard class (the honest core) + +`wire` introduces failure modes none of a/b/c have, and each needs a +normative rule: + +1. **Dangling wires** — the referent is deleted. Rule: fall back to + `Parent` with the last-resolved offset captured at fallback time, plus a + document diagnostic. (CSS Anchor Positioning ships fallbacks for the same + reason; the WG doc itself flags orphaned targets as ConstraintLayout's + danger.) +2. **Tangled wires** — cycles. Locally, a write that would create a cycle + is a **typed error** (H12-conform). But two concurrent writes, each + acyclic alone, can merge into a cycle — a CRDT hazard class beyond + anything in a/b/c. Rule: resolve-time deterministic cycle-break (drop the + edge with the greatest (node-id, axis) order, treat as parent-bound, + diagnostic). Merges stay valid; someone's wire goes slack. +3. **Locality loss** — a node's geometry no longer depends only on its + ancestors. Invalidation becomes a dirty-graph walk (spreadsheet + machinery), not a subtree walk; render caching and culling get a harder + dependency story (H10 hit). +4. **Comprehension** — "why is this here?" now has non-local answers. + Editor tooling must render wires visibly, or documents become haunted. + +## 4. Worked examples + +```xml + + + + +Name + +``` + +## 5. Scorecard (delta from `anchor` — all else equal) + +| harness | verdict | note | +| ---------------- | --------------------- | --------------------------------------------------------------------------------------------------------------- | +| H1 readability | pass | wire syntax reads; non-local reasoning cost is real but visible in the text | +| H2 gestures | pass | binding gestures write one axis; _creating_ a wire is a deliberate act | +| H3 CRDT | **trade (new class)** | per-field merge intact; merge-created cycles need the §3.2 break rule — valid-but-slack outcomes | +| H4 layout | pass | still single-pass (topo); definedness rules grow by the §3 rulebook | +| H5 single source | pass | single-assignment; fallback state is explicit | +| H6 coverage | pass at cost | §3 rules + referent × context cells added to the matrix | +| H7 animation | pass | channels unchanged; wires make _rigged_ motion expressible (a referent moves, dependents follow) | +| H8 capability | pass | unchanged (lens quarantine inherited) | +| H9 encoding | pass | `Pin.to` is an additive table field; a `Parent`-only document is byte-compatible with `anchor`'s shape | +| H10 hot loops | **trade** | dirty-graph invalidation machinery replaces subtree invalidation wherever wires exist | +| H11/H12 | pass | single-assignment keeps set-means-set; cycle-writes are typed errors locally, broken deterministically on merge | + +**Intrinsic (T1-clean) virtues:** cross-node intent is _stored_ (the WG +Level-4 promise, natively); auxiliary attachments (comments, link lines, +badges) become ordinary geometry; reparenting a wire-bound node does not +move it (geometry decoupled from tree — no other model has this). +**Intrinsic costs:** the §3 hazard class exists _at all_ — every rule in §3 +is spec surface and editor UX that a/b/c simply do not need. + +## 6. Verdict (proposer's own) + +`wire` is real, coherent, and not needed now. Its distinctive power is an +**additive extension of `anchor`'s vocabulary** — FlatBuffers table +evolution makes `Pin.to` a zero-cost future field, so nothing must be +reserved today and no migration is baked in by choosing `anchor` first. +Its hazard class, by contrast, is paid in full the day the first wire +exists: fallback rules, cycle-break rules, dirty-graph invalidation, wire +visualization. Adopt `anchor`; record `wire` as the priced Level-4 +destination that `anchor` grows into if and when the product demands +cross-node attachment — with this document as the bill of costs to re-read +on that day. diff --git a/model-v2/paradigm.md b/model-v2/paradigm.md new file mode 100644 index 0000000000..86b3756f80 --- /dev/null +++ b/model-v2/paradigm.md @@ -0,0 +1,183 @@ +# Paradigm proposal — "one box, one way" + +Status: **candidate** (phase 2). To be ratified against +[`harnesses.md`](./harnesses.md) before any phase-3 spec work. +Alternative working name: _the box/lens model_. + +> **A Grida document declares boxes; the engine resolves them; lenses present +> them. Every node is one oriented box — however sourced — and data flows one +> way.** + +This is a paradigm, not a schema: a small set of nouns and laws from which the +schema falls out. It exists to kill the meta-problem in +[`problems.md`](./problems.md): every field of the current model is +polysemous. The paradigm names the roles and assigns each exactly one home. + +--- + +## 1. The nouns + +**Tiers** — three, strictly ordered: + +| tier | name | contains | who writes | serialized? | +| ---- | ------------- | ------------------------------------------------------- | ------------------------------ | ----------------------- | +| 1 | **declared** | intent: bindings, size intent, orientation, payload | author (gestures, API, import) | **yes — and only this** | +| 2 | **resolved** | boxes, matrices, bounds — pure function of tier 1 | the engine only | no (caches at most) | +| 3 | **presented** | lens output: composited appearance, animation overrides | the engine only | no | + +**Box** — the one axis-aligned local rectangle every node resolves to. +The universal geometric noun: all vocabulary is defined against it. + +**Source** — where a node's box comes from. Exactly three: + +- `declared` — from size intent (shapes, containers, trays) +- `measured` — from content, under intent-as-constraints (text, path, vector, + markdown; a text's declared width is a _wrap constraint on the measure_, + not a rectangle) +- `derived` — from children (group, boolean) + +**Binding** — how a node's box attaches to its parent's resolved box: +per-axis, as a relation (anchored to start/end/center with offset, or +stretched between edges). _Position is a binding, not a coordinate_ — "x" is +the degenerate binding (anchored-start on both axes). + +**Orientation** — a scalar angle on the box. Geometry, not paint (see L6). + +**Payload** — what fills the box: a size-free shape descriptor, text content, +a vector network, container behavior. Payloads are _functions evaluated at +the resolved box_; they never own size. + +**Lens** — any appearance operation applied after geometry resolution, owning +no box and contributing nothing to layout. Lenses have exactly two homes: + +- **lens nodes** — structural, serialized: the transform-quarantine wrapper + (skew, arbitrary matrix, 3D/perspective). The tree shows what fields would + hide. +- **lens channels** — runtime, never serialized: animation overrides + evaluated over tier 2. + +The quarantine node and the animation override are the same concept in two +homes — that identity is what unifies P9 and P10. + +--- + +## 2. The laws + +**L1 — One-way flow.** +The document stores declared intent only. Resolution is a pure function of +intent (+ fonts/resources/viewport). Presentation composes over resolution. +No tier ever writes upstream. Corollary, and the format's drift-guard: +**if the engine can compute it, the file must not store it.** +_(P4, P10, P11 · H5, H7)_ + +**L2 — One role, one home.** +Attachment → binding. Extent → size intent. Orientation → θ. Form → payload. +Exception → lens. Each role has exactly one field; each field exactly one +role. Polysemy is a spec bug, not a modeling technique. +_(meta · H5, H6)_ + +**L3 — The box mediates.** +Layout negotiates _boxes_ (via their oriented AABBs), never payloads. +Measurement produces boxes. Payloads are evaluated at resolved boxes. +Children bind to the parent's resolved box — it is the only reference object +(percentages, anchors, and the ICB all resolve against it). +_(P3, P5, P6, P7 · H4)_ + +**L4 — Legibility is a hard bound.** +The stored vocabulary must pass the XML test: named scalars and enums a human +can predict pixels from. Whatever cannot (matrices, skew, 3D) exists only +inside an explicit lens node. Matrices appear in tier 2 as derived artifacts — +never in tier 1 outside a lens. +_(P1, P9 · H1, H3, H8)_ + +**L5 — Uniform header, typed payload.** +One shared core for every node kind — identity, binding, size intent, θ, +layer properties — specified once, with its (small) applicability matrix +written. Typed payload only where behavior genuinely differs. Per-kind +geometry fields are abolished. +_(P8 · H6, H9, H10)_ + +**L6 — Static is geometry; motion is lens.** +The pre/post-layout question is dissolved, not answered: it was ill-posed +because "rotation" named two roles. _Declared_ orientation is geometry — +layout sees the oriented box's AABB (single-pass safe: the AABB depends on +w, h, θ, never on assigned position). _Animated or exceptional_ transforms +are lens — compositor-only, layout-blind. Same word, two roles, two homes, +per L2. +_(P5, P10 · H4, H7)_ + +--- + +## 3. How the catalog lands + +| problem | resolution under the paradigm | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **P1** canonical form | Falls out of L1+L4: tier 1 stores named scalars; the matrix is a tier-2 artifact. Lossy extraction becomes structurally impossible — there is nothing to extract _from_. | +| **P2** position basis | L2: position is a per-axis binding relation. XY and "right: 10" are the same construct at different anchors; both are stored intent. Over-constraint is unrepresentable (a stretched axis owns its extent). | +| **P3** size roles | L2+L3, four roles → four homes: declared size = intent (constraints, for measured sources); resolved box = tier 2; shape = payload(resolved box) — the `CanonicalLayerShape` size-free position becomes runtime-true; measurement = a box source. | +| **P4** intent vs state | L1: reads resolve (tier 2), writes re-target intent (tier 1). Writing a layout-owned field is a typed error — a named cell in the applicability matrix, not a silent ignore. | +| **P5** rotation × layout | L6: declared θ feeds the oriented-box AABB. Pivot for boxed nodes = box center (the only pivot where box-center and AABB-center coincide, so layout placement needs no correction terms). Motion rotation = lens channel. | +| **P6** box-less nodes | L3 derived source: groups/booleans declare attachment (+θ) but never size; the box is derived; **child edits never write the group** — no re-fit transactions. Exact pivot mechanics → phase 3. | +| **P7** coordinate space | L3: the parent's resolved box is the sole reference object. ICB regularizes to "a node whose box is bound to the viewport." Border-vs-content-box origin = one phase-3 matrix cell. | +| **P8** property model | L5: the partition. Uniform header (one spec) + typed payload (shape descriptor, text, network, container behavior, lens ops). | +| **P9** capability ceiling | L4: the lens node is the sole home for beyond-vocabulary transforms; imports **wrap instead of degrade**; 3D enters as additive lens vocabulary, never as node fields. | +| **P10** animation | L1+L6: channels over tier-1 scalars (deliberate, layout-coupled lane) or lens channels (default, compositor lane); overrides live in tier 3 and never serialize. | +| **P11** normativity | L1's corollary is the mechanical drift-guard: the file encodes exactly the declared tier; anything derivable is not encodable. Conformance = equality of `resolve(decode(file))` across implementations. | + +--- + +## 4. Trades, declared + +Per the tension map — what this paradigm _pays_: + +- **L5 costs refusal-by-construction at the header.** Header fields are inert + on some kinds (size intent on a derived-source node). Every inert cell must + be written down as `ignored-by-rule` — accepted documentation debt, kept + small by keeping the header small. +- **L4 costs common-vocabulary capability.** Skew and matrices require a + wrapper node even when "one field would do." Accepted: the tree-visibility + of exotic content is the point. +- **L6 costs relayout on declared-rotation edits** under layout parents. + Accepted: continuous motion belongs in the lens lane; editing intent + _should_ reflow. +- **L1 costs read indirection** — every `node.x` read is a tier-2 query. + Accepted and already paid: the resolved tier _is_ the dense geometry cache + the engine maintains today (H10 aligns rather than fights). +- **H3's granularity caveat stands.** Bindings and gestures couple fields + (a drag writes two offsets; a stretch writes two edges). The paradigm + guarantees merged states are _valid_, not that they reconstruct compound + intent. + +## 5. What this paradigm does **not** decide (phase 3) + +Deliberately open, in catalog order: exact binding vocabulary (center? +scale-anchor? — P2 options 2/3), size-intent modes and min/max interaction, +measured-source constraint semantics per kind, group pivot mechanics and +gesture compensation, border-vs-content-box child origin, the header +applicability matrix itself, lens-op vocabulary and its fbs encoding, +degrees-vs-radians, and every field name. + +## 6. Falsification + +The paradigm is wrong — not merely incomplete — if any of these hold: + +1. **A role genuinely needs two homes.** E.g. if rotated-text-reflow + (orientation participating in wrap-constraint negotiation) becomes a + requirement, L3's "layout never sees payloads" breaks. (No studied system + attempts this; we bet with the field.) +2. **The legible vocabulary is too small in practice** — if a material share + of real imports (SVG/Figma corpora) lands in lens wrappers, the common + vocabulary is mis-sized and L4's bound is doing harm. +3. **Measured-source constraints can't express real text sizing modes** + (auto-width / auto-height / fixed with truncation) without re-introducing + polysemous size. +4. **Tier-2 query cost is unacceptable for editor-side hot reads** even with + materialized caches — would force resolved values back into the document, + breaking L1. + +Phase-2 exit: run this document against every harness probe in +[`harnesses.md`](./harnesses.md) with a worked example set (the H1 XML +quartet, the H3 concurrency matrix, the H6 applicability matrix for the +header), alongside at least one rival candidate for contrast — the natural +rival is **flat-property CSS-style** (P8 option 2), which maximizes exactly +what this paradigm trades away. diff --git a/model-v2/problems.md b/model-v2/problems.md new file mode 100644 index 0000000000..4da694a4c7 --- /dev/null +++ b/model-v2/problems.md @@ -0,0 +1,530 @@ +# Problem catalog + +Each entry: **statement** → **why it's hard** (the tension) → **option space** +(neutral; costs stated against harnesses, no recommendation) → **evidence** +(current code / format / peers). + +Problems are grouped, but they interlock — cross-references are marked +`(→ Pn)`. Harness references are `(H n)` into [`harnesses.md`](./harnesses.md). + +Status legend: `open` (no decision), `probed` (a candidate position was argued +in-session but not ratified). + +--- + +## A. State model — what is stored + +### P1. Canonical geometric state — matrix, scalars, or hybrid `probed` + +**Statement.** What is the single stored truth for a node's placement: +an affine matrix (rotation/skew derived), decomposed scalars +(position + rotation stored, matrix derived), or a hybrid +(layout box + separate post-transform, CSS-style)? + +**Why it's hard.** + +- _Dual-source is the one indefensible position_, and it is the current one: + leaves store matrices, containers store scalars, and the runtime constantly + re-derives one from the other. Every derivation is a hazard: `atan2` + extraction is lossy under skew/non-uniform scale; rebuilding a matrix from + extracted (x, y, θ) silently destroys any other components. +- Matrix-canonical gives full affine expressiveness but makes rotation a + _derived_ property (Figma's model, with its documented API/UI pivot + mismatch) and is hostile to per-field merge (H3): matrix components are + semantically coupled — merging `m00` from one write with `m10` from another + yields a non-rotation. +- Scalar-canonical reads and merges well (H1, H3) but caps expressiveness + (skew, arbitrary matrices from imports) unless capability is housed + elsewhere (→ P8). +- Whatever is canonical, _projections in both directions already exist_ in the + codebase; the question is which direction is definitional and which is + cache. + +**Option space.** + +1. Matrix-canonical, scalars virtual (Figma, SVG `matrix()`). +2. Scalars-canonical, matrix virtual (tldraw; CSS individual transform + properties). +3. Hybrid: layout-resolved box + separate post-layout transform (CSS box + + `transform`). +4. Mixed per node partition (e.g. scalars for boxed nodes, matrix for a + dedicated transform node) — interacts with P6 and P8. + +**Evidence.** + +- Three coexisting encodings: `crates/grida/src/node/schema.rs` — + `RectangleNodeRec.transform` (baked matrix) vs + `ContainerNodeRec { position, rotation }` vs `format/grida.fbs` + `LayerTrait { layout, post_layout_transform }` (unimplemented; its own + comment: _"this is currently how grida handles rotation … in the future, + this might change"_). +- Lossy extraction in the live pipeline: `crates/math2/src/transform.rs:231` + (`rotation()` = `atan2`), `transform.rs:48` (`set_rotation` overwrites the + 2×2 block); `crates/grida/src/cache/geometry.rs:959` (layout path rebuilds + leaf transforms as `new(x, y, rotation())`, discarding scale/skew). +- Figma's matrix is effectively rigid+flip in practice — scaling bakes into + `size`; the general affine capacity is unused by the product (see + `study.md`). + +--- + +### P2. Position basis — XY vs TRBL vs anchors `probed` + +**Statement.** How are "place at (x, y)" and "pin to right: 10" both +representable — as first-class _stored intent_ — without two modes fighting? + +**Why it's hard.** + +- XY is the graphics-natural flow (SVG, direct manipulation); TRBL is the + constraint-natural flow (CSS, pinning). Users legitimately want both, often + per-axis, sometimes per-edge. +- The current model is a node-level either/or + (`LayoutPositioningBasis::Cartesian | Inset`) — both axes must share one + mode, `Cartesian` vs `Inset.left/top` encode the same fact two ways, and + the enum has a third deprecated variant (`Anchored`) that was never + finished. This is the "two positioning modes, each fighting each other" + the project never resolved. +- Over-constraint (left + width + right) needs _some_ rule: CSS drops one + side (direction-dependent); Figma stores state (x) and re-derives, losing + the offset intent ("right: 10" is not in the document). Both approaches + leak into P7 (intent vs state). +- Whether position intent lives per-node, per-axis, or per-edge changes the + merge granularity (H3) and the applicability matrix (H6). + +**Option space.** + +1. Node-level mode enum (status quo). +2. Per-axis spec: each axis independently anchored (start/end/center) or + stretched (both edges) — XY becomes sugar for start/start. +3. Full anchor model (WG feat-layout Level 4): anchors to arbitrary targets; + parent-only as Level 1. +4. CSS-faithful insets + a resolution rule for over-constraint. + +**Evidence.** + +- `crates/grida/src/node/schema.rs:800` (`LayoutPositioningBasis`, deprecated + `Anchored` variant, `x()/y()` reading `Inset.left/top` as if Cartesian). +- `format/grida.fbs:1177–1204` (`LayoutPositioningCartesian` / + `LayoutPositioningInset` union — same either/or, plus per-side px/percent). +- `docs/wg/feat-layout/index.md` — the anchor vision, including the recorded + complaint about Figma storing state instead of intent. + +--- + +### P3. Size — one word, four roles `open` + +**Statement.** W/H simultaneously serves as: (1) **shape parameter** — for +parametric shapes the W/H _is_ the geometry (ellipse axes, star bounding box); +(2) **layout input** — the preferred/basis size that flex negotiates over, +with fixed/auto/fill modes and min/max clamps; (3) **layout output** — the +resolved box after negotiation, which is what the shape must actually be +_drawn at_; (4) **content measurement** — text/path/vector/markdown derive +natural size from content, inverting the flow (size is an output of measure, +optionally constrained: text `width` is a wrap constraint, not a rect). +What is stored, what is derived, and what does "size" even mean per node kind? + +**Why it's hard.** + +- The same field is read as geometry by the renderer and as a negotiation + input by the layout engine; when flex stretches a node, the _drawn_ size and + the _stored_ size diverge unless resolution feeds back into drawing — and + whether that feedback ever writes back to the document is P7. +- Content-defined nodes break the "size is spec" assumption: a path's tight + bounds need not start at (0,0) (the runtime carries `content_origin_x/y` + precisely for this), text height is a measure result, and a line is + degenerate (height locked to 0). +- Box-less nodes (group, boolean) have _fully derived_ size (→ P5), so any + uniform "every node has W/H" claim is already false today. +- Aspect ratio is a _relation between the two axes_ — the current schema + carries it as advisory; where it binds in the resolution order is undefined. +- Percentage/relative sizing exists in the format draft but not the runtime. + +**Option space** (per sub-question, combinable): + +- Stored size as: raw floats; a mode enum (fixed/auto/fill) per axis; + or absent for content/derived kinds. +- Shape geometry as: sized (status quo runtime) vs normalized descriptor + mapped into the resolved box at render (the `grida.fbs` + `CanonicalLayerShape` position: _"this union intentionally does NOT encode + size"_). +- Resolution → drawing coupling: renderer reads resolved box always; or + renderer reads spec except under layout. +- Write-back: never (pure derivation) vs on-commit state capture (Figma-like). + +**Evidence.** + +- Two size encodings already: leaf `size: Size` vs container + `layout_dimensions: LayoutDimensionStyle` (`crates/grida/src/node/schema.rs`). +- `format/grida.fbs:1080–1115` — `CanonicalLayerShape` doc comment: shape + descriptors are size-free, box-mapped at render (the format already takes a + position the runtime doesn't implement). +- `crates/grida/src/node/scene_graph.rs:182` — `content_origin_x/y` (evidence + that "shape ≠ (0,0,w,h) box" is real). +- `crates/grida/src/cache/geometry.rs:812` — `MIN_SIZE_DIRTY_HACK` (evidence + that size-resolution edge semantics are unresolved). +- Text wrap-constraint semantics: `TextSpanNodeRec.width: Option` + + the long `height` doc block (`schema.rs:2582–2634`). + +--- + +### P4. Intent vs state — what is stored vs what is exposed `probed` + +**Statement.** Does the document store _intent_ (pin right: 10; fill width; +rotate 30°) with state derived, or _state_ (x = 314 at this instant) with +intent re-derived — and what do API reads/writes address? + +**Why it's hard.** + +- Figma stores state (x/y) plus a constraint enum; the offset intent is never + in the document — the recorded complaint in the WG doc. Storing intent + instead means every read of "x" is a _resolution_ (needs parent geometry), + and every write of "x" must be _re-targeted_ onto the intent field. +- Under a layout parent, position fields are layout-owned: a write to x is + meaningless unless the model defines whether it errors, detaches the node + from flow, or is silently ignored (each observable today in different + tools). +- Layout resolution producing values that _look like_ the stored fields + invites accidental write-back — the classic corruption path. CSS's + specified-vs-computed two-tier is the strongest precedent for keeping the + wall explicit. +- Animation adds a third tier: evaluated override values that must _never_ + write back (→ P9). + +**Option space.** + +1. State-canonical with intent enums (Figma). +2. Intent-canonical with resolved values as a query-only tier (CSS + specified/computed analog). +3. Dual-stored with reconciliation rules (rejected by H5 unless rules are + total). + +**Evidence.** + +- `docs/wg/feat-layout/index.md` §1 ("What user sets is NOT ALWAYS what they + get"; "the schema does not contain the 'right is 10' but rather the current + 'state' x"). +- Layout-owned overrides in the runtime: `cache/geometry.rs:784–806` + (layout result replaces schema position for containers) and `:940–973` + (leaves under layout containers) — resolved values exist only in caches + today (no write-back), but nothing _names_ this contract. + +--- + +## B. Composition & space — how stored state becomes pixels + +### P5. Rotation × layout coupling — pre vs post, and the pivot `probed` + +**Statement.** Is rotation a layout-visible geometric property (Figma: +the rotated AABB participates in auto-layout) or a paint-only effect (CSS: +transform never moves siblings)? And around what pivot does stored rotation +apply — baked into translation (matrix), a stored origin, or a fixed +convention (center / top-left)? + +**Why it's hard.** + +- On a free canvas the two regimes are indistinguishable; they diverge exactly + under layout, where designer expectation (no overlap surprise) and + animation/composition stability (no relayout per frame) pull opposite ways. +- Arbitrary-angle _layout-visible_ rotation is only tractable one-pass if the + child is measured unrotated and its rotated AABB (a function of w, h, θ + only — never of assigned position) is what layout packs. Rotation that + changes a child's _wrap constraints_ (rotated text reflowing) is a harder + class no studied system attempts; Flutter explicitly restricted + layout-visible rotation to quarter-turns to dodge it. +- Pivot interacts with everything: top-left pivot makes the rotated AABB + offset quadrant-dependent and couples badly with resize; center pivot makes + box-center and AABB-center coincide; matrix-baked pivot (Figma) makes the + gesture pivot a UI fiction implemented by translation compensation. +- The `post_layout_transform` slot in the format conflates two different + things unless the model separates them: _where static rotation lives_ vs + _where motion effects live_ (→ P9). + +**Option space.** + +1. Layout-visible rotation, AABB-fed (Figma) — with a defined pivot + convention. +2. Paint-only rotation (CSS/SwiftUI/Flutter-Transform). +3. Split: restricted layout-visible (quarter-turns) + unrestricted paint-only + (Flutter). +4. Both lanes exist, distinguished as static-intent vs motion-effect. + +**Evidence.** + +- Runtime today: layout is rotation-blind (`ComputedLayout {x,y,w,h}`, + `crates/grida/src/layout/mod.rs`), rotation slapped on after, pivot = + local top-left everywhere (`T·R` via `AffineTransform::new`); the correct + center-pivot primitive exists unused + (`crates/math2/src/transform.rs:98`, `from_box_center`). +- `format/grida.fbs:1330` — `post_layout_transform` as the declared-but- + provisional home. +- Peer positions in `study.md` (Figma AABB behavior; Flutter's RotatedBox + restriction; CSS/SwiftUI paint-only consensus). + +--- + +### P6. Box-less nodes — group and boolean geometry `probed` + +**Statement.** Group and BooleanOperation own no box — bounds derive from +children (boolean: from the _path op result_, one step more derived). What +geometry, if any, do they store; around what does a stored rotation pivot; +and do child edits ever require writes to the group? + +**Why it's hard.** + +- A stored rotation pivoting on a _derived_ center is unstable: moving one + child moves the pivot and therefore shifts every sibling's world position. +- Fixing that by storing the fitted box (Figma) buys the re-fit dance: every + child edit triggers a compensating group write — a cross-node transaction + (hostile to H3) and a well-known complexity sink in Figma's own model. +- Fixing it by baking group transforms into children on commit makes group + rotation an N-node write and destroys the group-local coordinate space + (SVG `` semantics) the engine currently relies on. +- Ungrouping must bake regardless; the question is what the _steady state_ + stores. +- Boolean additionally raises whether it is a geometry node at all or a + _modifier_ over children (its fills/strokes apply to the op result; its + "size" is the result's bounds). + +**Option space.** + +1. Store `transform`/scalars on the group; box always derived; pivot = own + origin; center-feel is gesture-level compensation (status quo shape, + scalarized or not per P1). +2. Store a fitted box + transform, re-fit on child change (Figma). +3. Groups are ephemeral (selection-only); rotation always bakes into + children. +4. Boolean: geometry node vs modifier node (orthogonal sub-decision). + +**Evidence.** + +- `crates/grida/src/node/schema.rs:1457` (`GroupNodeRec` — only + `transform: Option`), `:2012` (`BooleanPathOperationNodeRec` + same shape). +- Derived bounds machinery: `crates/grida/src/cache/geometry.rs:992` + (`collect_oriented_corners` — oriented union in group space). +- `format/grida.fbs:1381` — `// TODO: review the group model from scratch.` + and `:1403` `// TODO: review the shape, should boolop have direct child ?` + (the format already flags both as unresolved). + +--- + +### P7. Coordinate-space contract `open` + +**Statement.** Which nodes establish a coordinate space for their children, +and where is the child origin — the parent's border-box top-left, its +content/padding box, or something else? What are percentages relative to? + +**Why it's hard.** + +- Today every node implicitly establishes a space + (`world = parent_world ∘ local`, uniformly), but the _origin_ semantics are + unstated: does container padding shift child origin (CSS content-box vs + border-box question)? The answer is currently "whatever Taffy does" for + flow children and "no" for absolute ones. +- `InitialContainer` is a special case (viewport-filling; _direct children's + transforms are ignored_ by doc-comment) — a second, undocumented positioning + regime. +- The format draft has percentage units for insets and dimensions with no + defined reference box. +- Transform-carrying wrappers (→ P8) and groups (→ P6) create spaces without + boxes — percentages and anchors inside them need a defined basis or an + explicit rule that they're invalid there (H6). + +**Option space.** Origin at border-box vs content-box; percentage bases per +CSS convention vs simplified single rule; ICB regularized as a normal flex +container vs kept special. + +**Evidence.** + +- `crates/grida/src/node/schema.rs:1634–1653` (`InitialContainerNodeRec` doc: + "Direct children are positioned by layout engine (their transforms + ignored)"). +- `format/grida.fbs:1124–1199` (`LayoutDimensionUnit::Percentage`, + `PositioningSideOffsetKind::Percent` — bases undefined). + +--- + +## C. Schema architecture — how the model is written down + +### P8. Property model — per-node-type spec vs flat property space `reframed` + +> **Reframed by [`axes.md`](./axes.md):** this problem is +> _representation-level_ (Axis 2), secondary to and separable from the +> semantic-model decision (Axis 1), bounded by the atom rule. The option +> space below stands, but it is tuned after the main model is chosen. + +**Statement.** Is the schema a set of node _types_, each declaring its own +fields (status quo Rust structs; fbs union-of-variant-tables) — or a flat +property list where every node is the same bag and properties are simply +inert where they don't apply (CSS-style)? Do we need a per-node spec at all? + +**Why it's hard.** + +- The domain has a visible partition: a few kinds carry _behavioral_ + semantics (container/layout, group, boolean, text/content), and the rest + are "just shape" — the fbs already acts on this by bundling shapes into + `BasicShapeNode` + a size-free shape descriptor. A per-type answer and a + flat answer may score differently on each side of that partition. +- Per-type buys refusal-by-construction (H5): `VectorNetworkData` implies + Vector; impossible states are unrepresentable. It costs N×M threading: + every cross-cutting concern touches every variant — the runtime's 19-arm + `match` blocks for opacity/blend/mask, `layout_child: Option<...>` + duplicated onto ~12 structs, and every new trait repeats the tour. +- Flat buys uniform codecs, uniform tooling, natural property-level CRDT + merge (H3), and additive property evolution — but the comparison to CSS is + **not apples to apples**: CSS's flat list works only because thousands of + pages of engine spec define, per property × display-type × context, whether + it applies, what it computes to, and how it interacts. A flat schema + _exports_ the semantics from the type system into a rulebook that must + actually be written (H6) and enforced by validators rather than by + construction. +- The runtime needs dense, `Copy`, cache-friendly projections in hot loops + regardless of the choice (H10) — it already builds them + (`NodeLayerCore`, `NodeGeoData`), and it already needs a _flat_ projection + too (`UnknownNodeProperties` exists as "a standard spec for each exposed + property names and types"). Both projections exist; the question is which + direction is canonical and which is derived. + +**Option space.** + +1. Per-type (status quo), with cross-cutting concerns as composed traits + (fbs `SystemNodeTrait`/`LayerTrait` direction). +2. Flat property bag + written applicability matrix (CSS direction; Figma's + plugin surface approximates this). +3. Partitioned: flat/uniform property core (identity, geometry, paint, + layer) + typed payload only where behavior genuinely differs + (shape descriptor, text content, vector network, layout container). +4. Flat storage, typed _views_ (schema-level bag; SDK exposes typed node + classes). + +**Evidence.** + +- `crates/grida/src/node/schema.rs` — 19-arm matches (`opacity()`, + `blend_mode()`, `mask()`, `fills()`…); `UnknownNodeProperties` (`:256`); + `layout_child` duplication across leaf structs. +- `format/grida.fbs:1510–1589` — the union-per-variant rationale comment + (the argued case _for_ per-type), alongside `BasicShapeNode` (`:1415`), + which is the argued case for _merging_ variants where payloads coincide. +- CSS precedent and its hidden cost: see `study.md` (applicability rules as + the price of flatness). + +--- + +### P9. Capability ceiling — full affine, 3D, and where power lives `probed` + +**Statement.** The renderer can draw more than the model can say: full affine +(skew), and plausibly 3D/perspective (Skia `M44`). By spec, should the +primitive support everything drawable — and if full power on _every_ node +fights practicality (merge, authoring, layout), does capability live in an +exceptional construct (a `TransformGroup`-like node) instead? + +**Why it's hard.** + +- Capping the spec below the renderer means imports (SVG skew, matrix-authored + content) silently degrade — a loss policy nobody chose. Uncapping every + node re-imports all of P1's matrix problems everywhere. +- Quarantining power in a wrapper node keeps the common case clean and makes + exotic content structurally visible, but adds a node kind whose layout + semantics need defining (transparent? sizing?) and whose existence must not + become a second home for ordinary rotation (re-opening dual-source, H5). +- 3D raises follow-on costs wherever it's allowed: bounds, culling, + hit-testing through perspective. + +**Option space.** + +1. Full transform capability on every node. +2. Restricted per-node model + dedicated transform/wrapper node carrying an + op-list or matrix (WPF `TransformGroup`, SVG nested ``, + Flutter `Transform` widget precedents). +3. Restricted model + explicit import degradation policy (no quarantine). +4. 3D excluded from the document model entirely (renderer-only effects). + +**Evidence.** + +- Import limitations already documented (io-svg: transform handling caveats). +- `format/grida.fbs` has no skew/3D representation; runtime leaf matrices + _can_ hold skew but the layout path destroys it + (`cache/geometry.rs:959`) — an accidental, undocumented cap. + +--- + +### P10. Animation readiness — channels, overrides, and lanes `probed` + +**Statement.** The model must decide _now_ (shape-wise, not feature-wise): +what is an animatable channel, where do evaluated animation values live +relative to stored values, and does animated geometry re-enter layout? + +**Why it's hard.** + +- Interpolation is only well-defined over decomposed values; animating a + matrix requires decompose/interpolate/recompose with known pathologies + (CSS's matrix-interpolation flips — the reason CSS later shipped individual + `rotate`/`translate`/`scale` properties). The channel question therefore + back-pressures P1. +- Winding (720° = two turns) is representable in a scalar, not in a matrix. +- The SMIL/WAAPI/DCC consensus is override-not-write-back: animation produces + an evaluated tier above the stored tier (third tier on P4's two). A model + that lets animation write stored fields corrupts the document and floods + sync. +- Whether an animated rotation reflows siblings is P5's question wearing a + different hat: base-field animation (layout-coupled) vs post-layout channel + (compositor-only) are _both_ legitimate — a per-lane answer needs the lanes + to exist in the model. +- Spin-around-a-point needs an origin _somewhere_; if stored rotation has a + fixed pivot convention, animated rotation needs its own origin slot + (the format's `post_layout_transform_origin` is the existing candidate + slot). + +**Option space.** Channels over stored scalars only; channels over a separate +post-layout transform; both lanes; animation out of document-model scope +entirely (pure runtime concern) with the format merely not _precluding_ it. + +**Evidence.** + +- `format/grida.fbs:1330–1332` (`post_layout_transform`, + `post_layout_transform_origin` — unimplemented slots). +- No animation system exists in the engine; this problem is purely + shape-of-the-spec today, which is exactly why it's cheap now and expensive + later. + +--- + +### P11. Normative artifact — which document is the spec `open` + +**Statement.** `grida.fbs` says "aligned to Rust runtime model"; the runtime +says otherwise. When model-v2 lands, which artifact is normative — the format +schema, the Rust types, or a prose spec both must satisfy — and what +mechanically prevents re-drift? + +**Why it's hard.** + +- "Aligned to" comments are aspiration, not enforcement; the current + three-way divergence (P1) grew _under_ such comments. +- The format has binding encoding constraints of its own (FlatBuffers + scalar-default semantics, tables-over-structs, additive unions — the rules + in the fbs header), which a runtime-first spec can accidentally violate. +- Cross-boundary codec tests exist for some flows (Rust encode → TS decode in + the SVG import corpus) but nothing tests _model_ equivalence. + +**Option space.** Format-first (fbs is the spec; runtime is a decoder); +runtime-first (fbs is a codec artifact); prose-spec-first (both are +implementations; conformance corpus enforces). Each needs a drift-detection +mechanism to be credible. + +**Evidence.** + +- `format/grida.fbs:1–113` (header rules + "Aligned to" claims); + the `LayerTrait` vs `crates/grida/src/node/schema.rs` divergence this + catalog documents. + +--- + +## Meta + +The recurring pattern across P1–P11: **every field the current model stores is +secretly polysemous** — `transform` is placement _and_ orientation _and_ +(accidentally) skew; `size` is shape _and_ preference _and_ result; `position` +is coordinate _and_ constraint; group `transform` is space _and_ gesture +residue. The unclear parts "keep arising" because each polysemy resolves only +by naming the roles and assigning each role exactly one home. That naming — +not any particular encoding — is what phase 2 candidates must be judged on. diff --git a/model-v2/study.md b/model-v2/study.md new file mode 100644 index 0000000000..5a3ece234e --- /dev/null +++ b/model-v2/study.md @@ -0,0 +1,128 @@ +# Comparative study — peer systems + +Facts we reason with, not designs we copy. Each system is read against the +problem catalog (P-refs) — what it stores, how it couples transform to +layout, and what its choice cost it. Confidence notes are explicit: most of +this is documented, stable behavior; Figma internals are partly inferred from +product behavior and the plugin API. + +--- + +## CSS / HTML + +- **Canonical geometry:** none stored — the box is layout _output_. `transform` + is a separate property; **post-layout** by definition: a rotated element + still occupies its untransformed box in flow; siblings never move (P5). + `transform-origin` defaults to center. +- **Position basis:** normal flow, or `position:absolute` + TRBL insets. + Over-constraint (left+width+right) resolved by _dropping a side_ + (direction-dependent) — a rulebook answer, not a schema answer (P2). +- **The flat-property price:** every property exists on every element; whether + it _applies_ is defined per property per display type per context by the + spec corpus. Flatness works because the rulebook is enormous and normative + (P8, H6). +- **Two-tier values:** specified vs computed/used — the strongest precedent + for an explicit intent/state wall (P4). +- **Late correction worth noting:** CSS Transforms Level 2 shipped individual + `rotate` / `translate` / `scale` properties (~2022) because animators needed + independently addressable channels the composite matrix couldn't give — the + incumbent matrix system retrofitting scalar channels (P1, P10). +- Also relevant: CSS Anchor Positioning (2024–25 drafts) — the standards-track + version of the WG feat-layout anchor vision (P2). + +## SVG + +- **Canonical geometry:** per-element attributes (`x/y/width/height`, + `cx/cy/r`, `d`) **plus** a `transform` list. The authoring form is _named + ops_ (`translate(10,20) rotate(30)` — and `rotate(a, cx, cy)` makes the + pivot first-class syntax); `matrix()` is the escape hatch. Named-ops-first + is the readability precedent (H1). +- **No layout.** SVG is the pure geometry-first pole — Grida's origin point. + "Introduce a layout primitive to SVG" is a fair statement of this whole + project (P3, P5). +- **Groups:** `` — box-less, transform-holding, children in + group-local space. Exactly the current Grida Group (P6). +- **Known wart:** `x/y` attributes _and_ `translate()` are two translation + homes on one element — a small dual-source problem SVG never fixed (P1, H5). + +## Flutter + +- **Canonical geometry: nothing persisted** — geometry is per-frame layout + output (constraints down, sizes up, parent positions child). A retained + _document_ can't copy this, but the pipeline discipline (intent in, geometry + out, never stored) is the pure form of P4's wall. +- **The rotation split, made explicit:** `Transform.rotate` = paint-only, + arbitrary angle, alignment pivot, layout unaffected. `RotatedBox` = + layout-visible, **quarter-turns only**. Flutter looked at arbitrary-angle + layout-visible rotation and _restricted the domain_ rather than solve it + (P5). +- `Transform` takes a full `Matrix4` — 3D/perspective lives in a wrapper + widget, not in the box protocol (P9). +- Position idiom: `Positioned(TRBL)` inside `Stack` — insets as the + absolute-positioning vocabulary (P2). + +## SwiftUI + +- Same family: proposal/response layout, nothing stored. +- `.rotationEffect(angle, anchor:)` — **paint-only**, anchor defaults center; + the layout frame is unchanged (P5). `.offset` likewise paint-only. +- `.position(x, y)` is **center-based** — a mainstream system that rejected + top-left as the position reference (P5-pivot, P7). +- Modern-declarative consensus with Flutter: arbitrary rotation is a visual + effect; layout sees unrotated boxes. + +## Figma + +- **Canonical geometry:** `relativeTransform` (2×3, parent-relative) + + `size (w, h)`. Rotation is **derived** (atan2-family) and exposed as a + scalar lens; `x/y` are the matrix translation (P1, P4). +- **The matrix is under-used by its own product:** in practice transforms are + rigid + flip — the scale tool bakes into `size`; skew is not authorable in + the UI. Six floats carrying ~3 degrees of freedom (P1). _(Confidence: + documented API shape + product behavior; not a spec quote.)_ +- **Pivot legacy scar:** the UI rotates around the selection center by + compensating translation; the model/API rotation applies around the node's + own origin. The center-pivot everyone experiences is a gesture-level + fiction over the stored form (P5). +- **Rotation is layout-visible:** a rotated child of an auto-layout frame + participates via its rotated AABB — the frame hugs the rotated bounds + (P5). Tractable because the AABB is `f(w, h, θ)`, independent of assigned + position — single-pass survives. _(Confidence: observed product behavior.)_ +- **Constraints store state, not intent:** stored `x/y` + per-axis constraint + enum (Min/Max/Center/Stretch/Scale); "right: 10" is re-derived, never + stored — the complaint recorded in `docs/wg/feat-layout/index.md` (P2, P4). +- **Groups:** transform-holding with a fitted, cached size; child edits + trigger re-fit + compensation writes — the cross-node transaction cost + named in P6. +- **Multiplayer:** property-atomic LWW; the transform merges as one value — + no tearing, but concurrent move ∥ rotate = one user's intent lost (H3). + +## tldraw (2023-era cleanroom, aside) + +- Shape record: `x`, `y`, `rotation` scalars + typed `props` (w/h inside + props). **No matrix, no skew**; scale bakes into size on commit. +- The one studied system designed _after_ multiplayer CRDTs were table + stakes, and it chose decomposed scalars as canonical (P1, H3). + _(Confidence: public schema; details of group rotation handling not + re-verified.)_ + +--- + +## Cross-cutting observations + +1. **Nobody stores both a matrix and scalars.** Every coherent system picks + one canonical form and derives the other; the current Grida runtime is the + only dual-source design in the room (P1). +2. **Nobody does arbitrary-angle layout-visible rotation "properly".** Figma + feeds AABBs (measure-unrotated, wrap, pack); Flutter restricts to 90°; + CSS/SwiftUI opt out entirely. The constraint-propagation-through-rotation + problem (rotated text reflow) is universally dodged (P5). +3. **Flat property surfaces are paid for in rulebook.** CSS is the existence + proof both that flatness works and that it costs a spec corpus (P8, H6). +4. **Center pivot is the modern consensus** for the _experienced_ rotation + (CSS origin default, SwiftUI anchor default, Figma gesture), regardless of + what the stored form does (P5). +5. **Full-power transforms live in wrappers** where they exist at all: + WPF `TransformGroup` / `RenderTransform` vs `LayoutTransform` (the explicit + pre/post split, named), Flutter's `Transform` widget, SVG's nested + `` (P9). diff --git a/model-v2/survey.md b/model-v2/survey.md new file mode 100644 index 0000000000..b9803d07a7 --- /dev/null +++ b/model-v2/survey.md @@ -0,0 +1,191 @@ +# The shape survey — 32 gut questions + +The instrument, saved without answers or verdict. (The 2026-07-07 run's +answers and synthesis live in [`triage.md`](./triage.md); this document is +the reusable questionnaire itself.) + +**Purpose.** Decide the shape of a document/geometry model without arguing +about models. Every question is high-level, model-blind, and answerable by +gut. The questions triangulate the axes a model must serve — product truth, +pattern-following, authoring, engineering discipline, LLM/agent fit, +collaboration, evolution, capability, effort — so that a shape _falls out_ +of the answers instead of being debated into existence. + +**How to administer.** + +- Answer fast; the gut is the instrument. If a question forces engineering + analysis, skip or annotate — a refusal is itself signal. +- Never name candidate models in questions or options. +- Free-text answers beat the offered options when the gut says something + the options don't. +- Keep any scoring key hidden until all rounds are answered. +- 8 rounds × 4 questions; each should take seconds. + +--- + +## Round 1 — truth & identity + +**1. Truth.** When the canvas and the deployed web page disagree about the +same design, which one is wrong? +— The web is wrong (canvas is source of truth; web chases it) · +The canvas is wrong (the deploy target defines reality) · +Depends on surface (two regimes). + +**2. Document nature.** In your gut, a document file is closest to… +— A program (engines evaluate it into the picture) · +A picture (a finished arrangement; consumers draw it) · +A recording (the history of what the author did). + +**3. Rotate + list.** You rotate a card inside an auto-flowing list. Should +the list make room for the rotated card? +— Yes, always (layout reacts to the rotated footprint) · +No — rotation is visual-only (overlap is correct) · +Per-container switch. + +**4. Author.** Who is the primary author of documents long-term? +— Humans in the editor · Agents & code · Both equally. + +## Round 2 — pattern-following & invention appetite + +**5. Standards.** A proven external standard exists but conflicts with your +ideal semantics. Default posture? +— Adopt, warts included · Ideal first (pay authorship) · +Ideal, borrow freely where compatible. + +**6. Proven-ness.** How much does "battle-tested by the entire web for 25 +years" weigh in a design decision? +— Decisive · Strong but overridable · Weak — the domain differs. + +**7. Legacy.** Which 2030 review makes you prouder? +— "Finally fixed it" · "Instantly familiar". + +**8. Refusal.** OK for the model to make some rare designer wishes +structurally unrepresentable, to stay simple? +— Yes, refusal is a feature · No, expressiveness first · +Only with an escape hatch. + +## Round 3 — the file & authoring promises + +**9. Raw read.** A stranger opens the raw file in a text editor. Predicting +the render just by reading matters… +— Critically · Nice-to-have · Barely (tooling mediates). + +**10. Set promise.** Which promise to the user matters more? +— "Stored exactly as you said" (your `right: 24` lives in the file) · +"Stored as you see" (the file always equals the current visual result). + +**11. Memory.** Should the FILE remember currently-inactive values, so +toggling a mode away and back restores them? +— Yes, the file remembers · No, the file is current truth only · +Editor-session memory only. + +**12. Verbatim.** Must human-typed numbers (x, width, 15°) appear verbatim +in the file? +— Yes, verbatim · Equivalent-derived is fine if round-trip is exact. + +## Round 4 — engineering discipline + +**13. Bad write.** A write can't take effect in the current context. The +system should… +— Reject with a typed error · Store it; it may apply later · +Auto-convert the context so it applies now. + +**14. Determinism.** Same file on two platforms (native, wasm): +— Bit-identical geometry required · Tolerance-equal is the honest bar. + +**15. Rules home.** Where should the model's semantics live? +— In the type system (invalid = unrepresentable) · +In a written rulebook (everything encodes; prose defines) · +Types for the core, rulebook for the edges. + +**16. Oracle.** What anchors conformance testing? +— An external reference implementation · Our own spec + goldens · +External where overlap exists, own the rest. + +## Round 5 — LLM & agent fit + +**17. LLM predict.** Should an LLM writing a document be able to predict +the resulting geometry _without running the engine_? +— Critical · Helpful, not critical · Unnecessary (agents get tools). + +**18. LLM safety.** For agent edits, the safer model shape: +— Invalid states unrepresentable (writes can fail, typed) · +All writes legal (never a wall; rules arbitrate). + +**19. LLM prior.** "LLMs already know this vocabulary deeply from training +data" — worth how much? +— A lot (meet models where they are) · Some (a tiebreaker) · +Little (a clean in-context spec closes the gap). + +**20. Agent API.** Agents should manipulate documents primarily through… +— The same fields humans see (the format is the API) · +A higher-level op layer (gesture-like named operations) · +Ops first, fields as fallback. + +## Round 6 — collaboration & evolution + +**21. Co-edit.** Two users, same node, simultaneous move + rotate. Both +intents surviving the merge is… +— Non-negotiable · Nice to have · Whole-op last-write-wins is fine. + +**22. Blast radius.** Editing one node requiring the editor to rewrite +_other_ nodes (siblings, parents): +— Never acceptable · A few sanctioned, named cases · Fine, normal. + +**23. Growth.** Five years out, the model grows mostly by… +— New node kinds · New properties on existing kinds · Both, heavily. + +**24. Breakage.** Before the format locks, breaking changes with migration +scripts are… +— Fine until lock · Never — additive-only from day one. + +## Round 7 — capability & scope + +**25. 3D.** 3D / perspective on the canvas: +— Real roadmap item · Someday-maybe (don't preclude, don't spend) · +Not our product. + +**26. Exotic 2D.** Skew and arbitrary matrices (mostly via imports): +— First-class on every node · Preserved but quarantined and marked · +Approximate/degrade acceptable. + +**27. Animating layout.** A list reflowing live while a card grows or +turns: +— Core scenario · Occasional (OK if it's the expensive path) · +Out of scope (motion is a visual overlay). + +**28. Layout scope.** Beyond flex/grid — radial, text-on-path, masonry: +— The core must anticipate (pluggable layout) · Payload/plugin concern · +Never core. + +## Round 8 — effort & rollout + +**29. Timeline.** Gut timeline for the new model becoming the engine +default: +— Months (ship, iterate) · ~A year (once, right) · +No deadline (correctness sets the pace). + +**30. Consumers.** Third-party renderers / SDK consumers of the format +within ~2 years: +— Plan for them · Unlikely (we own all implementations) · +Actively want an ecosystem. + +**31. Spec duty.** Appetite for maintaining a normative written spec, +standards-body style: +— Yes, sign me up · Tests are the spec · Code is the spec. + +**32. Migration.** Existing documents and the current editor model: +— Must migrate losslessly · Best-effort OK · Free to break. + +--- + +## Reading the results (after answering) + +Interpretation happens per-run, not in this document. Guidance for whoever +scores: look for (a) the two or three answers that decide the _truth model_ +(1, 2, 6, 10 are the usual deciders); (b) answers that _reshape_ rather +than pick — memory (11), write policy (8 vs 18), agent surface (17/20), +vocabulary (7/19); (c) honest refusals — a punted question with a stated +constraint is often worth more than a forced choice; (d) contradiction +pairs (8↔18, 9↔17) — they are not errors, they locate a layer boundary the +model must implement. diff --git a/model-v2/triage.md b/model-v2/triage.md new file mode 100644 index 0000000000..f770099f04 --- /dev/null +++ b/model-v2/triage.md @@ -0,0 +1,116 @@ +# Triage — 32 gut answers and what they decide + +Owner-answered 2026-07-07. Questions were model-blind by design; the +scoring key (which axis each answer pulls) was hidden until all rounds +closed. Verbatim answers condensed; owner's own words quoted where they +carried more than the options offered. + +## The answers + +| # | question (short) | gut answer | pulls | +| --- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| 1 | canvas vs web disagree — who's wrong? | **the web is wrong** | canvas-truth → intent model; finale question, side 1 | +| 2 | file is a program / picture / recording | **a program** | intent-canonical | +| 3 | rotated card in a list — make room? | **punted, deliberately** — "I will not force-engineer my gut"; constraint given instead: Figma designs must convert in (tree surgery fine); model must be genuinely agnostic-good | fork stays open; Figma-_convertibility_ required, Figma-_identity_ not | +| 4 | primary author | humans #1 in priority; **agents do most of the work** in reality | both surfaces; agent IR elevated | +| 5 | proven standard vs ideal | **challenge the ideal empirically** — measure, not paper | decision-by-prototype posture | +| 6 | weight of web-proven-ness | strong, with caveat — _"we already built an html/css render engine; the whole reason building the node taxonomy and new system is to introduce a 'future', new standard, editor-first"_ | finale question, side 2 — the adopt-path already exists **elsewhere in the product**; the new model exists to be the new standard | +| 7 | 2030 legacy | **"instantly familiar"** | new semantics must wear familiar vocabulary | +| 8 | unrepresentable rare wishes OK? | **yes — refusal is a feature** | strict state model | +| 9 | raw-file human readability | **barely** | H1 (human door) demoted | +| 10 | stored-as-said vs stored-as-seen | **stored as you said** | intent-canonical | +| 11 | file remembers inactive values? | **no — file is truth only** | switch-memory = editor session, never serialized | +| 12 | human-typed numbers verbatim in file? | derived is fine | binary canonical OK; text form is a projection | +| 13 | ineffective write → reject/store/convert | no preference | open (resolved via #18, see synthesis) | +| 14 | native vs wasm | **tolerance-equal** | N-3 policy set | +| 15 | semantics in types vs rulebook | "something else" (unspecified) | open | +| 16 | conformance oracle | **external where exists** | matches conformance.md doctrine | +| 17 | LLM predicts geometry without engine | **critical** | mental simulability = design input | +| 18 | agent writes: can-fail vs never-fail | **all writes legal** | lenient write path (see synthesis — key tension with #8) | +| 19 | LLM training-data prior worth | some — tiebreaker | familiar vocabulary again | +| 20 | agent API surface | **text-file based, even if a different IR** — the agent should be "tricked" it's writing a file/code | an agent-facing textual IR is a first-class product surface | +| 21 | concurrent move∥rotate both survive | doesn't matter much ("even Figma fails") — but engineer-gut: **almost must** | fine-grained merge kept; cheap under scalars | +| 22 | cross-node write cascades | **few sanctioned cases** | editor.md doctrine 3 confirmed | +| 23 | growth axis: kinds vs properties | **new properties** | small stable kind set + additive property evolution | +| 24 | pre-lock breaking changes | fine until lock | migration freedom now | +| 25 | 3D | someday-maybe | don't preclude, don't spend (reserved vocabulary suffices) | +| 26 | skew/matrices | torn: "first-class, as it will be svg-import heavy (**model should be almost always 100% SVG compatible**) — but honestly? degrade acceptable" | SVG-import ~100% is the hard requirement; the carrying mechanism is open, degradation tolerable | +| 27 | animating layout itself | **core scenario** | layout-coupled motion is headline; re-resolution must be cheap | +| 28 | exotic layouts (radial, masonry…) | never core — "but I would LOVE to see what lands if I picked 'core must anticipate'" | parked; one exploratory sketch owed | +| 29 | timeline | **~a year — once, right** | deliberate build funded | +| 30 | third-party consumers | unlikely — we own all | external conformance simplicity deprioritized (last `bake` residue gone) | +| 31 | normative spec appetite | **yes — sign me up** | invention path fully resourced | +| 32 | existing docs/TS model | **free to break** | X-SELF breaks are free; no migration constraint | + +## Verdict + +**The finale is decided: `anchor`.** The deciding question was answered from +both sides without ever naming a model: + +- #1: when canvas and web disagree, _the web is wrong_ — the canvas is the + truth, web output is a projection. +- #6: the adopt-CSS path _already exists in the product_ (the `htmlcss` + engine); the entire reason for a new node taxonomy is to introduce a new, + editor-first standard. Adopting CSS as the model would build what we + already have. + +Reinforced by #2 (a program), #10 (stored as you said), #8 (refusal is a +feature), #31 + #29 (spec discipline and a deliberate year — the invention +path is resourced), #30 + #32 (no external-consumer or migration drag). + +## Amendments — the triage didn't just pick, it reshaped + +The winning model absorbs five owner-forced amendments: + +1. **No file-carried switch-memory** (#11). Flattening (b)'s retained + inactive values do **not** serialize; toggle-back restoration is editor + session state. The file holds only current truth. (M-3 re-scoped; + dormancy eliminated from the format entirely.) +2. **Strict states, lenient writes** (#8 + #18 — the apparent + contradiction, reconciled). Strictness lives in the _state model_: + invalid documents are unrepresentable. Leniency lives in the _write + layer_: agent/editor writes never hard-wall — they coerce or redirect to + the nearest meaningful intent, always reported, never silent + (editor.md law 4, extended from gestures to the API). H12 amendment: + "guaranteed-or-typed-rejection" → "effective-or-coerced-with-report" at + the op layer, over an unchanged strict document. +3. **The agent text IR is a first-class surface** (#20 + #17 + #9). Human + raw-readability barely matters, but an LLM must be able to _write a + file_ and _predict its geometry mentally_. The XML-ish projection stops + being documentation sugar and becomes a product deliverable: a textual + IR that round-trips with the binary format, tuned for model priors + (#19: familiar names) and mental simulation (#17: closed-form, + locally-decidable resolution — no arbitration combinatorics). H1 + re-scoped: legibility's audience is agents. +4. **Familiar vocabulary over novel vocabulary** (#7 + #19). New semantics, + old words: `x`, `width`, `rotation`, `flex`, `gap`, `padding` wherever + the semantics genuinely coincide. Invent meanings only where the point + _is_ the correction; never invent a term where a familiar one is honest. +5. **SVG-import ≈100% is a hard requirement** (#26); the exotic-transform + carrying mechanism stays open (quarantine default, declared degradation + acceptable as fallback — the owner is genuinely torn and the corpus + will decide, per #5's measure-don't-argue posture). + +## The one open fork, now with named criteria + +Rotation-in-flow (#3, punted honestly) is decided later **by prototype and +measurement** (#5), not by argument. The triage tilts it, though: + +- **toward AABB-participates**: #27 — "a card _turns_ and the list + reflows" was accepted as a core scenario, which is only expressible if + rotation is layout-visible; #1 — canvas-truth dislikes overlap-lies. +- **against**: #17 — visual-only rotation is the simpler mental model for + an agent (though `|w·cosθ|+|h·sinθ|` is well within LLM arithmetic). + +Recorded tilt: layout-visible, pending the measured prototype. It remains +`POL`-shaped in conformance (R-3/OP-ROT-2) until then. + +## Also opened / parked + +- #13/#15 unresolved by gut — resolved structurally by amendment 2 (writes) + and the existing types-core/rules-edge practice; revisit only if they + chafe. +- #28's curiosity: one exploratory sketch of a pluggable-layout core is + owed, explicitly non-binding, to see "what lands." +- #21's engineer-gut keeps fine-grained merge a design property — it costs + nothing under scalar intent fields. From e63d6c003bd5ecf1ea2c678a9137788f297381e9 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 7 Jul 2026 23:40:40 +0900 Subject: [PATCH 02/33] docs(model-v2): link tracking issue #957 in the branch note --- model-v2/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/model-v2/README.md b/model-v2/README.md index ad58388663..62fda89deb 100644 --- a/model-v2/README.md +++ b/model-v2/README.md @@ -7,10 +7,11 @@ represented — in the **Rust engine** (`crates/grida`) and the **format spec** after the model lands; they are out of scope here. > **Branch note.** This directory lives on the `model-v2-anchor` branch as a -> working snapshot. The plan: finish the feel pass on the spike, then propose -> an RFC with this branch as the textbook, then start the legacy -> migration/rebuild (weeks out). Nothing here ships; no production code is -> touched by this branch. +> working snapshot — tracking issue: +> [gridaco/grida#957](https://github.com/gridaco/grida/issues/957) (pinned). +> The plan: finish the feel pass on the spike, then propose an RFC with this +> branch as the textbook, then start the legacy migration/rebuild (weeks +> out). Nothing here ships; no production code is touched by this branch. ## Run From 860bfefe1e94148085fe0b4f9558c45a9b2d04c6 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 7 Jul 2026 23:54:16 +0900 Subject: [PATCH 03/33] docs(model-v2): sync a/README to DEC-0 second lock (visual-only, 114 tests, branch note); fix MODEL.md dec0 link --- model-v2/a/MODEL.md | 2 +- model-v2/a/README.md | 35 ++++++++++++++++++++--------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/model-v2/a/MODEL.md b/model-v2/a/MODEL.md index 7bbfa739b6..263474e8f8 100644 --- a/model-v2/a/MODEL.md +++ b/model-v2/a/MODEL.md @@ -76,7 +76,7 @@ Why this shape is right, per the evidence: contributions, hug, derived unions) never reads rotation or flips; the read tier (world AABBs, selection, hit) stays oriented. The normative rule set incl. the V-4 group-box fork: - [`a/dec0-visual-only.md`](./a/dec0-visual-only.md). The E1 arm + [`dec0-visual-only.md`](./dec0-visual-only.md). The E1 arm (oriented-AABB participation) remains implemented and tested as the documented alternative. diff --git a/model-v2/a/README.md b/model-v2/a/README.md index 37946c3207..dac76c24c0 100644 --- a/model-v2/a/README.md +++ b/model-v2/a/README.md @@ -6,7 +6,7 @@ This directory hosts the experiments, spikes, and spec drafts for **self-sufficient for session re-entry after context compaction** — read it top to bottom and you have working state. -> **2026-07-07 — the ledger has been RUN.** E1–E7 are complete with +> **2026-07-07 — the ledger has been RUN.** E1–E7 and E10 are complete with > verdicts; the lab implementation lives in [`lab/`](./lab) (**114** conformance-derived tests green). Start at **[`REPORT.md`](./REPORT.md)** > — win/lose/lessons-learnt and the fourteen spec deltas (E-A1…E-A14 + Taffy > guards). The consolidated model statement — the phase-3 seed — is @@ -14,12 +14,12 @@ top to bottom and you have working state. ## State of decisions (compressed) -| decision | state | where | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| Main model | **`anchor`** — intent-canonical anchored box model | [`../models/a.md`](../models/a.md) (the model spec draft) | -| How decided | 32-question model-blind owner triage; deciders: canvas-truth (#1) + "the CSS path already exists as the `htmlcss` engine; the new taxonomy exists to be a new editor-first standard" (#6) | [`../triage.md`](../triage.md), instrument: [`../survey.md`](../survey.md) | -| Retired | `sheet` (adopt-CSS — its domain is the existing htmlcss engine), `bake` (post-T1), `wire` (deferred; re-enters additively via `Pin.to`) | [`../finale.md`](../finale.md), [`../models/`](../models/) | -| ~~Open fork~~ | rotation-in-flow — **DECIDED: layout-visible** (E1 measured prototype, 2026-07-07); R-3/OP-ROT-2 → INV | [`e1-rotation-in-flow/verdict.md`](./e1-rotation-in-flow/verdict.md) | +| decision | state | where | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| Main model | **`anchor`** — intent-canonical anchored box model | [`../models/a.md`](../models/a.md) (the model spec draft) | +| How decided | 32-question model-blind owner triage; deciders: canvas-truth (#1) + "the CSS path already exists as the `htmlcss` engine; the new taxonomy exists to be a new editor-first standard" (#6) | [`../triage.md`](../triage.md), instrument: [`../survey.md`](../survey.md) | +| Retired | `sheet` (adopt-CSS — its domain is the existing htmlcss engine), `bake` (post-T1), `wire` (deferred; re-enters additively via `Pin.to`) | [`../finale.md`](../finale.md), [`../models/`](../models/) | +| ~~Open fork~~ | rotation-in-flow — **DECIDED: VISUAL-ONLY** (DEC-0 second lock, 2026-07-07; rules in [`dec0-visual-only.md`](./dec0-visual-only.md)); E1's layout-visible arm stays implemented + tested as the documented alternative | [`e1-rotation-in-flow/verdict.md`](./e1-rotation-in-flow/verdict.md) | ### The five triage amendments (binding on everything here) @@ -53,7 +53,8 @@ lens`. Shape descriptors are size-free, evaluated at the resolved box. derived (group, bool, lens — never store size). - Rotation pivot: box center (boxed/measured); own origin + gesture compensation (derived). `lens` = sole home for skew/matrix/3D ops. -- Resolution: measure → layout (flex over rotated AABBs — pending E1) → +- Resolution: measure → layout (flex over unrotated boxes — DEC-0 + visual-only; rotation applies in phase T, post-layout) → transforms (`from_box_center`) → bounds. One-way; resolved tier never serializes ("derivable ⇒ not encodable"). @@ -84,12 +85,13 @@ wired into the workspace; promote into `crates/`/`format/` only at phase 4. | **E10** | the feel spike: does the model drive a REAL editor? (owner: feel it + textbook) | **BUILT** — [`spike-canvas/`](./spike-canvas): native winit+Skia app on the lab (`cargo run --release`); resolve-per-frame thesis MEASURED (starter frame 0.17 ms; 10k nodes paint-bound at 9 ms); arena+SOA storage evolution in the lab (**up to 11.5× resolver speedup**, 100 tests); interaction FSM, HUD w/ E-A7 readout, cross-zero flip gesture, undo, live+editable IR panel, reports-as-badges; [`TEXTBOOK.md`](./spike-canvas/TEXTBOOK.md) + [`SPIKE.md`](./spike-canvas/SPIKE.md) | All verdicts + the spec deltas roll up in [`REPORT.md`](./REPORT.md). -Implementation: [`lab/`](./lab) — standalone crate, `cargo test` (92 -green), bins `e1`, `e3`, `e4`, `e5scan`, `edge`. +Implementation: [`lab/`](./lab) — standalone crate, `cargo test` (114 +green), bins `e1`, `e3`, `e4`, `e5scan`, `edge`, `fork`. ## Phase-3 definition of done -1. E1 verdict folded in → R-3/OP-ROT-2 become `INV`, not `POL`. +1. DEC-0 verdict folded in (visual-only, rules V-1…V-10) → R-3/OP-ROT-2 + become `INV`, not `POL`. 2. Normative spec: `../models/a.md` rewritten as spec (amendments folded, every `POL` in [`../conformance.md`](../conformance.md) answered and locked, applicability matrix final). @@ -110,7 +112,10 @@ Read in this order — ~10 minutes to full context: 5. Skim [`../problems.md`](../problems.md) / [`../harnesses.md`](../harnesses.md) only when a design argument needs its source. -Also standing: nothing in `model-v2/` is committed to git yet (untracked -working state); the session convention is grounding-first, problems before -solutions, and no scope beyond **Rust engine + format spec** — other seams -(TS editor, WASM bindings) follow after the model lands. +Also standing: this workbench lives on the **`model-v2-anchor`** branch +(tracking issue +[gridaco/grida#957](https://github.com/gridaco/grida/issues/957), pinned); +pushes require owner approval, per turn. The session convention is +grounding-first, problems before solutions, and no scope beyond +**Rust engine + format spec** — other seams (TS editor, WASM bindings) +follow after the model lands. From 8b5ffd81cd20f16b33826469cd95618778ffef65 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 8 Jul 2026 00:57:12 +0900 Subject: [PATCH 04/33] =?UTF-8?q?docs(model-v2):=20ENGINE.md=20=E2=80=94?= =?UTF-8?q?=20the=20phase-4=20layer=20programs,=20day-1=20major=20contract?= =?UTF-8?q?s=20(ENG-0=E2=80=A6ENG-5),=20validity-tagged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- model-v2/README.md | 6 +- model-v2/a/ENGINE.md | 374 +++++++++++++++++++++++++++++++++++++++++++ model-v2/a/README.md | 1 + 3 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 model-v2/a/ENGINE.md diff --git a/model-v2/README.md b/model-v2/README.md index 62fda89deb..2f2e4520fb 100644 --- a/model-v2/README.md +++ b/model-v2/README.md @@ -98,8 +98,10 @@ Ground rules: (each with verdicts and lab tests), the decision register ([`a/DECISIONS.md`](./a/DECISIONS.md)), the DEC-0 normative rules, the ship-readiness census ([`a/LIMITS.md`](./a/LIMITS.md)), the peer-compat - matrix, the 114-test Rust lab ([`a/lab/`](./a/lab/)), and the native - interactive spike ([`a/spike-canvas/`](./a/spike-canvas/)). + matrix, the phase-4 engine layer programs with day-1 contracts + ([`a/ENGINE.md`](./a/ENGINE.md)), the 114-test Rust lab + ([`a/lab/`](./a/lab/)), and the native interactive spike + ([`a/spike-canvas/`](./a/spike-canvas/)). - [`models/`](./models/) — concrete candidate models, one file each, harness-scored, best-faith. Files keep letter slots (`a.md`, `b.md`, …); the names are the working identifiers: diff --git a/model-v2/a/ENGINE.md b/model-v2/a/ENGINE.md new file mode 100644 index 0000000000..0c0625c1e0 --- /dev/null +++ b/model-v2/a/ENGINE.md @@ -0,0 +1,374 @@ +# ENGINE — the phase-4 layer programs (day-1 contracts) + +2026-07-08. The five engine layers the migration must shape from day 1 — +each with its **major contracts** (cheap to set now, brutal to retrofit), +its growth path (every stage keeps every contract), and its open studies +(named, instrumented, NOT decided). The model semantics are proven +([`MODEL.md`](./MODEL.md), 114 tests); this document is about the engine +that grows around them. Browser and game-engine technique is adopted +deliberately, not by vibe: the pipeline staging and damage discipline +are the browser's; the storage, spatial, and replay discipline are the +game engine's. + +**Validity rule (standing, H13-style).** A claim enters this document +only under one of five tags — anything untagged is refused: + +- `[MEASURED]` — a number from this workbench (lab, spike, E4 bench) or + a mechanism that exists in the lab today, cited. +- `[PRIOR]` — a finding from the legacy render-opt loop. Transfers as a + **hypothesis** — it was measured on the legacy engine and MUST + re-measure on this model before it binds. +- `[PEER]` — verified browser/game-engine precedent, stated coarsely + enough to be uncontroversial. +- `[CONTRACT]` — normative, testable, binds phase 4. The point of this + document. +- `[OPEN]` — a study question with a named instrument. Decides nothing. + +Decision hygiene: nothing here pre-empts the register — DEC-4/5/6/7/9 +stay owner calls; the contracts below are **decision-independent +sockets** any answer plugs into. + +## The layer map + +| layer | one line | precedent | day-1 contracts | grows into | +| ----- | --------------------------------------------- | ------------------------------------------------- | --------------- | ------------------------------------- | +| ENG-0 | stage purity + the oracle law | browser pipeline staging | 0.1–0.4 | everything below | +| ENG-1 | incremental resolve & damage | dirty bits / query memoization | 1.1–1.4 | dirty-scope → memoized resolve | +| ENG-2 | retained paint: drawlist → tiles → compositor | display lists, damage rects, layers | 2.1–2.4 | damage-driven partial paint | +| ENG-3 | spatial read tier | game broadphase (BVH/LBVH) | 3.1–3.4 | indexed pick/cull/snap at 100k | +| ENG-4 | deterministic content oracles | Figma's own text stack; lockstep float discipline | 4.1–4.5 | pinned shaper, pathops-in-measure | +| ENG-5 | time as data: journal, replay, CRDT seam | input-log replay; per-field LWW multiplayer | 5.1–5.5 | op history → replay rig → multiplayer | + +--- + +## ENG-0 · the meta-layer: stage purity and the oracle law + +The one asset every layer below trades on: **the pipeline is a chain of +pure stages**. `document → resolve → drawlist → raster/composite`, plus +the read tier (`pick`, spatial queries) off the resolved columns. The +lab already holds the first link (`resolve(document, fonts, resources, +viewport) → Resolved` is a pure function `[MEASURED]`); phase 4 extends +the chain, never breaks it. + +- **ENG-0.1 · stage purity** `[CONTRACT]` — every stage is a pure + function of the previous stage plus declared inputs. No stage reads + downstream state; no stage writes upstream. (Law 1's "derivable ⇒ not + encodable" is the document-side special case of this.) +- **ENG-0.2 · the oracle law** `[CONTRACT]` — every optimization + (incremental resolve, cache, spatial index, batching, tiling) ships + WITH a differential test against the unoptimized pure stage: + `optimized(input) ≡ reference(input)`, bit-for-bit, over the + conformance scenes and the replay corpus (ENG-5.3). An optimization + without its differential test does not merge. The reference paths are + never deleted — they are the permanent test oracles. +- **ENG-0.3 · order-determinism** `[CONTRACT]` — stages are + deterministic including iteration order (no hash-map-order-dependent + math; SOA columns indexed by NodeId already are `[MEASURED]`). + Determinism is what upgrades every differential test from ε-tolerance + to bit-equality — ε hides real bugs. +- **ENG-0.4 · this document's own gate** `[CONTRACT]` — additions to + ENGINE.md carry a validity tag or are rejected; `[PRIOR]` entries + must name their re-measurement before binding. + +**Never.** No layer may introduce hidden mutable state "for +performance" without routing it through ENG-0.2. A cache that cannot be +differential-tested is a bug factory with a speedup attached. + +--- + +## ENG-1 · incremental resolve & damage + +**Precedent.** Browsers keep style/layout/paint validity per phase with +dirty bits and scoped relayout `[PEER]`; rust-analyzer's salsa +demonstrates query-memoization as the alternative shape `[PEER]`; game +engines propagate dirty transforms through subtrees with change +detection `[PEER]`. + +**Evidence on file.** Resolve-per-frame is the proven baseline: starter +scene 0.008 ms resolve / 0.17 ms frame; 10k nodes 0.35–0.47 ms resolve +`[MEASURED]`. Locality is bounded — a leaf edit re-resolves to the +nearest fixed-extent ancestor, ~18 µs per card subtree under clean +parent boxes `[MEASURED]`. The lab's per-container hug chain is +2^depth — phase 4's single-tree layout removes it structurally +`[MEASURED]` (LIMITS cost note). The incremental engine is an +**optimization to add, not an architecture to build first** — that +ordering is itself the day-1 decision, already made and proven by the +spike. + +**Major contracts.** + +- **ENG-1.1 · the full resolver is the eternal oracle** `[CONTRACT]` — + incremental resolve must produce a resolved tier bit-identical to + from-scratch resolve, for every edit, forever. CI runs the + differential over the conformance scenes + replay corpus. (This is + ENG-0.2 applied; it is listed separately because it is the contract + most engines break first.) +- **ENG-1.2 · ops declare their dirty class** `[CONTRACT]` — the op + layer already enumerates typed write-sets (write counts are law + `[MEASURED]`); extend each op with a declared invalidation scope: + which phases (M/L/T/B) × which extent (self · subtree · + measure-chain-to-fixed-ancestor · bounds-only). Day 1 the enum merely + exists and the engine ignores it (full resolve); the incremental + engine consumes it later without re-auditing every op. CSS + containment is the browser's version of making damage scope explicit + `[PEER]`. +- **ENG-1.3 · viewport is root context only** `[CONTRACT]` — resolved + output for a node never depends on scroll/zoom/culling. Culling is a + paint/read concern (ENG-2, ENG-3). Violating this makes every pan a + full invalidation and poisons ENG-1.1. +- **ENG-1.4 · caches are a separate, keyed tier** `[CONTRACT]` — any + memoized value lives outside the document and the resolved tier, + keyed by generational identity (ENG-2.3). The document and Resolved + stay plain values. + +**Growth path.** full-per-frame (today, proven) → dirty-scope +re-resolve consuming ENG-1.2 (the 18 µs bound is the promised payoff) +→ memoized sub-queries where profiling justifies. + +**Open studies.** OS-1a: dirty-bit propagation vs salsa-style +memoization vs hybrid — instrument: the E4 bench harness + replay +corpus, measured at 10k/100k `[OPEN]`. OS-1b: does Taffy's tree API +admit subtree-scoped relayout cleanly, or does the flex phase stay +whole-tree longer than M/T/B? — instrument: prototype on the lab's flex +scenes `[OPEN]`. + +--- + +## ENG-2 · retained paint — display list, damage, tiles, compositor + +**Precedent.** The browser pipeline: paint produces a display list; +raster consumes it in tiles; a compositor owns layers and frame pacing; +damage rects flow forward `[PEER]`. Game render graphs contribute +batching/culling vocabulary `[PEER]`. + +**Evidence on file.** The spike is paint-bound, not resolve-bound: 10k +nodes = 0.35 ms resolve vs ~9 ms paint `[MEASURED]` — paint is where +the next order of magnitude lives, which is precisely why this layer +exists. The legacy loop's findings transfer as hypotheses only +`[PRIOR]`: unstable-full-draw suppression won big (−71%), analytic +mask-filter shadows halved stable full draws, and **naive layer +promotion/demotion was falsified twice** — re-measure all three on this +model before adopting. + +**Major contracts.** + +- **ENG-2.1 · drawlist is a pure stage** `[CONTRACT]` — + `resolved → drawlist` is deterministic and diffable, exactly like + resolve (ENG-0.1). Paint reads the resolved tier and the drawlist — + never the document. +- **ENG-2.2 · damage flows forward only** `[CONTRACT]` — op → dirty + class (ENG-1.2) → resolved-tier diff → drawlist diff → screen rects. + No stage invents damage; no stage widens it silently. Damage is data, + loggable and assertable per frame. +- **ENG-2.3 · generational cache keys** `[CONTRACT]` — cache identity + is `(slot, generation)`, never the bare NodeId. The lab arena + tombstones deleted slots but does not yet promise non-reuse + `[MEASURED]` — day 1: add the generation counter (or an explicit + append-only promise) so a reused slot can never alias another node's + cached raster/layout artifacts. +- **ENG-2.4 · the compositor owns pacing** `[CONTRACT]` — one frame + entry point schedules input → resolve → paint → present; hosts adapt + to it, not vice versa. The legacy `FrameLoop` unification is the + in-repo precedent `[PRIOR]` — its lesson (fragmented tick/redraw + paths rot) binds; its implementation does not. + +**Growth path.** immediate paint from SOA (today) → display list + +damage rects (partial repaint) → tiled raster (infinite canvas at deep +zoom) → composited layers, promotion by measured policy only. + +**Open studies.** OS-2a: tile scheme for an unbounded, deeply-zoomable +canvas — zoom-pyramid vs single-scale re-raster; instrument: `--bench` +scenes at extreme zoom `[OPEN]`. OS-2b: layer promotion heuristics — +re-run the legacy falsification on this engine before shipping any +promotion `[PRIOR→OPEN]`. OS-2c: text raster caching interaction with +ENG-4 versioning (glyph atlas keyed by oracle version) `[OPEN]`. + +**Never.** Paint must not become a second place where model semantics +live — a drawlist item renders what the resolved tier says, or the +resolved tier is wrong. + +--- + +## ENG-3 · the spatial read tier — one query API over a broadphase + +**Precedent.** Game engines split broadphase (index over AABBs) from +narrowphase (exact tests) and rebuild LBVHs per frame via morton sort +at large N `[PEER]`. Browsers hit-test by paint-order tree walk — the +editor case (marquee, snap, cull, minimap) outgrows that quickly +`[PEER]`. + +**Evidence on file.** The read tier is already SOA: `world_aabb` is a +flat column indexed by NodeId `[MEASURED]` — literally the input array +a broadphase wants. `pick` exists as a model concern (oriented +inverse-world test, lens post-ops, transparent-select promotion to the +outermost derived) with a linear walk `[MEASURED]` — correct, and the +permanent narrowphase + oracle. + +**Major contracts.** + +- **ENG-3.1 · one query API** `[CONTRACT]` — point-hit, rect + (marquee), viewport cull, and nearest-edge/center (snap candidates) + are one API family. Consumers (HUD, marquee, snapping, culling, + minimap, pick) never hand-roll tree traversals. Day 1 the API fronts + the linear walk; the index slots in behind it unchanged. +- **ENG-3.2 · the walk is the oracle** `[CONTRACT]` — every index + answer is differential-tested against the linear reference (ENG-0.2). + Broadphase may over-approximate (candidates), never under-approximate + (misses). +- **ENG-3.3 · model laws live in the query layer** `[CONTRACT]` — + paint-order topmost, transparent-select promotion, lens post-ops + hit-testing, hairline slop: these are semantics, and they stay in the + narrowphase (today's `pick.rs`), NOT in consumers and NOT in the + index. An index swap must never change what gets selected. +- **ENG-3.4 · spatial reads read the read tier only** `[CONTRACT]` — + the index is built from resolved columns (`world_aabb`), never from + intent. It rebuilds/refits from a resolve diff (ENG-2.2's damage + feed), so it can never disagree with what was resolved. + +**Growth path.** linear walk behind the API (today, correct) → +per-frame rebuilt BVH over the SOA column (morton/LBVH — the column +layout makes the sort nearly free `[PEER]`) → refit-vs-rebuild policy +at scale. + +**Open studies.** OS-3a: rebuild-vs-refit threshold at 10k/100k under +resolve-per-frame — instrument: bench scenes + the replay corpus +`[OPEN]`. OS-3b: snap-candidate query shape (k-nearest edges vs range +query + filter) measured against real gesture traces `[OPEN]`. + +--- + +## ENG-4 · deterministic content oracles — text, pathops, images + +**Precedent.** Browsers are deliberately NOT cross-platform +deterministic in text; Figma ships its own text stack precisely to be +`[PEER]` (already cited as evidence in DEC-4). Lockstep game +simulations pin float operation order and instruction choices to keep +replicas bit-identical `[PEER]`. + +**Evidence on file.** The seam already exists: oracles are explicit +resolve inputs (`fonts`, `resources` in the signature) `[MEASURED]`. +The lab text metric is a stub and the spike renders real glyphs against +stub measurement — the mismatch is deliberately visible `[MEASURED]`. +B-1 (wrap decisions are discrete; no ε absorbs a line-height jump) and +B-5 (bool needs pathops inside measure) are the two blockers this layer +services `[MEASURED]` (LIMITS). + +**Major contracts** (decision-independent: DEC-4 picks the shaper, +DEC-5 the numbers, DEC-6 the bool posture — these sockets hold under +any answer): + +- **ENG-4.1 · oracles are explicit inputs** `[CONTRACT]` — nothing + inside resolve reaches for ambient fonts/resources/platform metrics. + Already true; stays true. +- **ENG-4.2 · oracles are versioned** `[CONTRACT]` — the oracle version + is document-visible; an oracle upgrade is a format event, never a + silent drift. Golden corpora per version; CI pins each version's + outputs. +- **ENG-4.3 · bit-exact within a version** `[CONTRACT]` — same oracle + version + same inputs ⇒ same outputs on every platform (one Rust + codebase across native/wasm is the enabling asset). This is what + makes ENG-1.1 and ENG-5.2 testable as bit-equality. +- **ENG-4.4 · the numeric profile is declared** `[CONTRACT]` — there IS + a documented profile (precision, range, operation-order rules) the + resolver and oracles conform to; DEC-5 fills in the numbers. Bugs + against the profile are bugs; drift outside it is undefined, not + quietly tolerated. +- **ENG-4.5 · measure-phase oracles are budgeted and cacheable** + `[CONTRACT]` — phase-M oracle calls (shaping, pathops) are keyed by + `(content, constraints, oracle version)` for memoization under + ENG-1.4, and their cost is measured per frame — the phase-M budget is + a number, not a hope. + +**Growth path.** stub metric (today, declared) → pinned shaper behind +ENG-4.1–4.3 (unblocks B-1/T-3 as INV-per-version) → pathops in +phase M with an ENG-4.5 budget (unblocks B-5/bool) → image/decode +metrics under the same versioning. + +**Open studies.** OS-4a: shaper candidates under the ENG-4.3 constraint +— instrument: cross-platform golden corpus diff (feeds DEC-4, does not +make it) `[OPEN]`. OS-4b: pathops-in-measure cost at realistic bool +nesting — instrument: E9's corpus counts + bench `[OPEN]`. + +**Never.** Oracle output never enters the document (law 1 already +forbids it); an oracle upgrade never rewrites stored intent. + +--- + +## ENG-5 · time as data — transactions, journal, replay, CRDT seam + +**Precedent.** Game engines treat the input log as a first-class +artifact: deterministic replay for repro, regression, and benchmarks +`[PEER]`. Figma's published multiplayer write-ups describe per-property +last-writer-wins and client-local undo `[PEER]`. + +**Evidence on file.** The op layer is already journal-shaped: typed, +delta-form, enumerated write-sets, "a drag ends as if written once" is +law `[MEASURED]`. The spike ships snapshot undo (documents are values) +`[MEASURED]`. The C-matrix demonstrated field-level merges in tests but +has NEVER met a real replicated backend — named in the REPORT lose +column `[MEASURED]`. Stable identity is the named gap: NodeId is a +session-stable arena slot; format/IR stable ids are open (a.md §12) +`[MEASURED]`. + +**Major contracts.** + +- **ENG-5.1 · transaction = gesture** `[CONTRACT]` — the journal's unit + is the typed op; a gesture groups ops into one transaction with + all-or-nothing history semantics. The existing write-count law is the + enforcement instrument. +- **ENG-5.2 · replay determinism** `[CONTRACT]` — a replay file is + `(initial document, op log)`; playing it back yields a bit-identical + document AND resolved tier (stands on ENG-0.3 + ENG-4.3/4.4). This is + the property that turns "it crashed once" into a fixture. +- **ENG-5.3 · one corpus, four consumers** `[CONTRACT]` — the replay + format is THE shared artifact: bug repro, perf bench input, fuzz seed + (structure-aware op fuzzing), and conformance fixture. No parallel + bespoke trace formats. +- **ENG-5.4 · stable identity precedes distribution** `[CONTRACT]` — + ops address nodes by stable id. Session-local slot ids suffice for + in-process history; the format-level id story (a.md §12) must lock + BEFORE any cross-session replay, persistence-of-history, or + multiplayer claim. Until then those features are walled, not fudged. +- **ENG-5.5 · snapshots are the honest floor** `[CONTRACT]` — history = + invertible op log where proven, document snapshots where not (the + spike's posture). An op is only "invertible" once its inverse is + property-tested (`apply(op); apply(inverse(op)) ≡ identity` — the + E-A14 out-and-back test is the template `[MEASURED]`). + +**Growth path.** snapshot undo (today) → op journal + transactions → +replay rig wired into CI (ENG-0.2's corpus) → CRDT seam: run the +C-matrix against a real backend; only then multiplayer semantics +(undo-locality etc.) become design work. + +**Open studies.** OS-5a: journal-vs-snapshot memory/latency crossover +at real document sizes — instrument: replay corpus + bench `[OPEN]`. +OS-5b: C-matrix vs a real backend — which merge rows survive contact +`[OPEN]`. OS-5c: multiplayer undo semantics — study Figma's +client-local model against the op journal `[PEER→OPEN]`. + +--- + +## The cross-cutting instrument — the conformance rig + +Every layer above is only cheap because ENG-0 holds; the rig is how it +keeps holding: differential tests (incremental vs full, index vs walk, +optimized paint vs reference), structure-aware op fuzzing over the +replay format, golden corpora per oracle version, and WPT-style +external pinning where a peer is the oracle (flex conformance pins to +Chromium, never to Taffy — the E4 lesson `[MEASURED]`; `grida_wpt` +is the in-repo precedent for the discipline `[MEASURED]`). + +## Scope fence (named, not silent) + +- **Above Skia.** Raster stays Skia; this document pioneers the + architecture above it. No custom GPU path rendering, no rasterizer + replacement — the differentiation is in the five layers, not under + them. +- **Not ECS.** The arena/SOA is a storage layout, not a component + model. Nodes keep their typed header+payload shape (the model's + strict-states law); nothing here licenses arbitrary + component attachment. +- **Relation to the register.** ENG-4 services DEC-4/DEC-5/DEC-6 + without deciding them; ENG-5.4 depends on the a.md §12 stable-id + item; ENG-2's `[PRIOR]` entries cite the legacy render-opt loop and + bind only after re-measurement here. diff --git a/model-v2/a/README.md b/model-v2/a/README.md index dac76c24c0..044c777527 100644 --- a/model-v2/a/README.md +++ b/model-v2/a/README.md @@ -83,6 +83,7 @@ wired into the workspace; promote into `crates/`/`format/` only at phase 4. | **DEC-0 flip** | the second lock: visual-only default (CSS framing, CSS-pure sizing) | **DONE** — [`dec0-visual-only.md`](./dec0-visual-only.md) (rules V-1…V-10; the V-4 group-box fork was real UB, decided sizing-tier); lab default flipped, hug/union CSS-pure behind the flag; `tests/visual_only.rs` (+14, suite **114**); E-A4/7/8/11/12 retired; spike re-cut (ink-bounds chrome, shots regenerated) | | **free-editing demo** | the base case, hands on (owner question: "without layout, how does it work?") | **BUILT** — [`free-editing/`](./free-editing): live free-context mini-editor (JS mirror of the free rules — no Taffy, which is the point); live canonical IR, per-gesture write counts incl. typed errors, 0-write artboard responsiveness | | **E10** | the feel spike: does the model drive a REAL editor? (owner: feel it + textbook) | **BUILT** — [`spike-canvas/`](./spike-canvas): native winit+Skia app on the lab (`cargo run --release`); resolve-per-frame thesis MEASURED (starter frame 0.17 ms; 10k nodes paint-bound at 9 ms); arena+SOA storage evolution in the lab (**up to 11.5× resolver speedup**, 100 tests); interaction FSM, HUD w/ E-A7 readout, cross-zero flip gesture, undo, live+editable IR panel, reports-as-badges; [`TEXTBOOK.md`](./spike-canvas/TEXTBOOK.md) + [`SPIKE.md`](./spike-canvas/SPIKE.md) | +| **ENGINE** | the phase-4 layer programs (owner: browser/game-engine techniques from day 1) | **DRAFTED 2026-07-08** — [`ENGINE.md`](./ENGINE.md): major contracts ENG-0…ENG-5 (stage purity + the oracle law · incremental resolve & damage · retained paint · spatial read tier · deterministic content oracles · time-as-data), growth paths where every stage keeps every contract, open studies OS-\*; every claim validity-tagged (`[MEASURED]`/`[PRIOR]`/`[PEER]`/`[CONTRACT]`/`[OPEN]`) — decision-independent sockets, does not pre-empt DEC-4/5/6 | All verdicts + the spec deltas roll up in [`REPORT.md`](./REPORT.md). Implementation: [`lab/`](./lab) — standalone crate, `cargo test` (114 From 105d7156754dccdc7dbbd5a7d614ba6de512db4d Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 8 Jul 2026 17:50:39 +0900 Subject: [PATCH 05/33] feat(model-v2/lab): engine seams + visual-only exploration corpus (since the snapshot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lab's accumulated evolution on the anchor branch since the textbook snapshot, across two threads — the seams anchor-engine consumes and the DEC-0 visual-only feel study — plus a crate-wide rustfmt pass. 121-test suite green. Engine seams (additive; serde stays out of default deps): - ops.rs: typed `Op` + pure-dispatch `apply` (journal/replay/dirty land on it; free fns kept, `apply ≡ free_fn` differential per variant) + `DirtyClass` parameterized on rotation-in-flow [ENG-1.2]. - model.rs: per-slot `generations` column + `gen_of` (semantic PartialEq ignores it — a storage artifact) [ENG-2.3]. - resolve.rs: non-panicking `box_opt`/`local_opt`/`aabb_opt`/`slot_count`. - serde feature (default OFF) — the op-log wire; new tests accessors, generations, op_apply. Visual-only exploration: rotation scenes added to the edge/fork probes (grow × rotation, rotated text/frame/group) and expanded edge_census + visual_only corpora anchoring DEC-0. --- model-v2/a/lab/Cargo.lock | 1 + model-v2/a/lab/Cargo.toml | 7 + model-v2/a/lab/src/bin/e1.rs | 25 +++- model-v2/a/lab/src/bin/e3.rs | 2 +- model-v2/a/lab/src/bin/e4.rs | 6 +- model-v2/a/lab/src/bin/e5scan.rs | 26 +++- model-v2/a/lab/src/bin/edge.rs | 61 ++++++-- model-v2/a/lab/src/bin/fork.rs | 70 +++++++-- model-v2/a/lab/src/model.rs | 42 ++++-- model-v2/a/lab/src/ops.rs | 210 +++++++++++++++++++++++++-- model-v2/a/lab/src/pick.rs | 12 +- model-v2/a/lab/src/resolve.rs | 96 ++++++++---- model-v2/a/lab/src/svgout.rs | 5 +- model-v2/a/lab/src/textir.rs | 25 ++-- model-v2/a/lab/tests/accessors.rs | 73 ++++++++++ model-v2/a/lab/tests/common/mod.rs | 2 +- model-v2/a/lab/tests/derived.rs | 17 ++- model-v2/a/lab/tests/edge_census.rs | 87 ++++++++--- model-v2/a/lab/tests/escape.rs | 6 +- model-v2/a/lab/tests/flip.rs | 15 +- model-v2/a/lab/tests/generations.rs | 40 +++++ model-v2/a/lab/tests/geometry.rs | 9 +- model-v2/a/lab/tests/layout.rs | 8 +- model-v2/a/lab/tests/op_apply.rs | 137 +++++++++++++++++ model-v2/a/lab/tests/ops_suite.rs | 6 +- model-v2/a/lab/tests/rotation.rs | 3 +- model-v2/a/lab/tests/textir_suite.rs | 16 +- model-v2/a/lab/tests/visual_only.rs | 30 +++- 28 files changed, 887 insertions(+), 150 deletions(-) create mode 100644 model-v2/a/lab/tests/accessors.rs create mode 100644 model-v2/a/lab/tests/generations.rs create mode 100644 model-v2/a/lab/tests/op_apply.rs diff --git a/model-v2/a/lab/Cargo.lock b/model-v2/a/lab/Cargo.lock index 647861959a..24b84cc09f 100644 --- a/model-v2/a/lab/Cargo.lock +++ b/model-v2/a/lab/Cargo.lock @@ -7,6 +7,7 @@ name = "anchor-lab" version = "0.0.0" dependencies = [ "quick-xml", + "serde", "taffy", ] diff --git a/model-v2/a/lab/Cargo.toml b/model-v2/a/lab/Cargo.toml index 41e5f954b3..d8c82d065c 100644 --- a/model-v2/a/lab/Cargo.toml +++ b/model-v2/a/lab/Cargo.toml @@ -9,9 +9,16 @@ publish = false # experiment code is promoted into crates/ only at phase 4). [workspace] +[features] +# `serde` (default OFF) makes `Op` / `ResizeDrag` / `Axis` serializable — the +# op-log wire for engine replay. Kept optional so the default lab pulls only +# taffy + quick-xml (the model crate stays dependency-lean). +serde = ["dep:serde"] + [dependencies] taffy = "0.9.2" quick-xml = "0.37" +serde = { version = "1.0", optional = true, features = ["derive"] } [profile.release] debug = true diff --git a/model-v2/a/lab/src/bin/e1.rs b/model-v2/a/lab/src/bin/e1.rs index e4b6c816f6..be94414687 100644 --- a/model-v2/a/lab/src/bin/e1.rs +++ b/model-v2/a/lab/src/bin/e1.rs @@ -59,7 +59,13 @@ fn scene(fixed_w: Option) -> Scene { let mut cards = vec![]; for (i, color) in ["#4a90d9", "#e2574c", "#57b894"].iter().enumerate() { let h = Header::new(SizeIntent::Fixed(60.0), SizeIntent::Fixed(100.0)); - let c = b.add(f, h, Payload::Shape { desc: ShapeDesc::Rect }); + let c = b.add( + f, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); b.node_mut(c).fill = Some(color.to_string()); let _ = i; cards.push(c); @@ -110,7 +116,10 @@ fn sweep(mode: RotationInFlow, fixed_w: Option) -> Vec { } fn summarize(name: &str, frames: &[Frame]) -> String { - let wmin = frames.iter().map(|f| f.container_w).fold(f32::MAX, f32::min); + let wmin = frames + .iter() + .map(|f| f.container_w) + .fold(f32::MAX, f32::min); let wmax = frames.iter().map(|f| f.container_w).fold(0.0f32, f32::max); let peak_overlap = frames .iter() @@ -163,7 +172,10 @@ fn snapshot(theta: f32) -> String { r#""# ); for (i, (mode, label)) in [ - (RotationInFlow::AabbParticipates, "anchor: AABB participates"), + ( + RotationInFlow::AabbParticipates, + "anchor: AABB participates", + ), (RotationInFlow::VisualOnly, "control: visual-only (CSS)"), ] .iter() @@ -323,12 +335,15 @@ fn main() { // The envelope (and thus sibling displacement) peaks at θ* = atan(h/w). let bound = (60.0f32 * 60.0 + 100.0 * 100.0).sqrt() * STEP.to_radians(); let peak = (100.0f32 / 60.0).atan().to_degrees(); - println!(" analytic continuity bound: {bound:.2}px per {STEP}° step; envelope peak θ* = {peak:.1}°"); + println!( + " analytic continuity bound: {bound:.2}px per {STEP}° step; envelope peak θ* = {peak:.1}°" + ); write_csv(&out.join("metrics.csv"), &aabb, &visual, &fixed); for theta in [0.0f32, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0] { fs::write( - out.join("frames").join(format!("theta_{:03}.svg", theta as u32)), + out.join("frames") + .join(format!("theta_{:03}.svg", theta as u32)), snapshot(theta), ) .unwrap(); diff --git a/model-v2/a/lab/src/bin/e3.rs b/model-v2/a/lab/src/bin/e3.rs index 906a94b849..5bceb03dce 100644 --- a/model-v2/a/lab/src/bin/e3.rs +++ b/model-v2/a/lab/src/bin/e3.rs @@ -10,7 +10,7 @@ //! Tolerance: |Δ| ≤ 0.5 px per component. use anchor_lab::model::{Document, NodeId, Payload}; -use anchor_lab::resolve::{resolve, Resolved, ResolveOptions, RotationInFlow}; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved, RotationInFlow}; use anchor_lab::textir; use std::collections::BTreeMap; use std::fs; diff --git a/model-v2/a/lab/src/bin/e4.rs b/model-v2/a/lab/src/bin/e4.rs index c4abc43118..ca94a8385b 100644 --- a/model-v2/a/lab/src/bin/e4.rs +++ b/model-v2/a/lab/src/bin/e4.rs @@ -173,11 +173,11 @@ fn main() { println!("E4 resolver spike — full-resolve wall time (median of 11, release)"); for scale in [1_000, 10_000] { bench(&format!("flat canvas ({scale})"), &scene_flat(scale)); + bench(&format!("flex cards (~{scale})"), &scene_flex(scale / 7)); bench( - &format!("flex cards (~{scale})"), - &scene_flex(scale / 7), + &format!("mixed groups+flex (~{scale})"), + &scene_mixed(scale), ); - bench(&format!("mixed groups+flex (~{scale})"), &scene_mixed(scale)); } // locality proxy: cost of re-resolving one card subtree (what an // incremental engine pays for a leaf edit under clean parents) diff --git a/model-v2/a/lab/src/bin/e5scan.rs b/model-v2/a/lab/src/bin/e5scan.rs index 8fdd2c6217..06d6504b6d 100644 --- a/model-v2/a/lab/src/bin/e5scan.rs +++ b/model-v2/a/lab/src/bin/e5scan.rs @@ -92,9 +92,17 @@ fn parse_transform(s: &str) -> Option { ("scale", [s]) => M { a: *s, d: *s, ..ID }, ("scale", [x, y]) => M { a: *x, d: *y, ..ID }, ("rotate", [deg]) => rot(*deg), - ("rotate", [deg, cx, cy]) => M { e: *cx, f: *cy, ..ID } - .mul(&rot(*deg)) - .mul(&M { e: -*cx, f: -*cy, ..ID }), + ("rotate", [deg, cx, cy]) => M { + e: *cx, + f: *cy, + ..ID + } + .mul(&rot(*deg)) + .mul(&M { + e: -*cx, + f: -*cy, + ..ID + }), ("skewX", [deg]) => M { c: deg.to_radians().tan(), ..ID @@ -178,7 +186,11 @@ fn scan_file(path: &Path, c: &mut Counts) { let mut found = 0usize; let mut needs_lens = false; let mut flip = false; - for key in ["transform=\"", "gradientTransform=\"", "patternTransform=\""] { + for key in [ + "transform=\"", + "gradientTransform=\"", + "patternTransform=\"", + ] { let is_paint = key != "transform=\""; let mut at = 0usize; while let Some(pos) = src[at..].find(key) { @@ -251,7 +263,11 @@ fn walk(dir: &Path, out: &mut Vec) { for e in entries.flatten() { let p = e.path(); if p.is_dir() { - let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + let name = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); if name == "node_modules" || name == "target" || name == ".git" { continue; } diff --git a/model-v2/a/lab/src/bin/edge.rs b/model-v2/a/lab/src/bin/edge.rs index 6cd2b1b3af..4aafb3e40c 100644 --- a/model-v2/a/lab/src/bin/edge.rs +++ b/model-v2/a/lab/src/bin/edge.rs @@ -345,14 +345,54 @@ fn s_flipzero(t: f32) -> (Document, Vec<(NodeId, &'static str)>) { fn main() { let scenes: Vec = vec![ - Scene { id: "grow", name: "grow × rotation (E-A4)", build: s_grow, extra: "" }, - Scene { id: "stretch", name: "stretch × rotation", build: s_stretch, extra: "" }, - Scene { id: "text", name: "rotated text in flow", build: s_text, extra: "" }, - Scene { id: "rotframe", name: "rotated flex frame (rigid)", build: s_rotated_frame, extra: "" }, - Scene { id: "group", name: "group rotating in flow", build: s_group, extra: "" }, - Scene { id: "two", name: "two rotating siblings", build: s_two, extra: "" }, - Scene { id: "between", name: "space-between × rotation", build: s_between, extra: "" }, - Scene { id: "wrap", name: "wrap reflow mid-rotation", build: s_wrap, extra: "" }, + Scene { + id: "grow", + name: "grow × rotation (E-A4)", + build: s_grow, + extra: "", + }, + Scene { + id: "stretch", + name: "stretch × rotation", + build: s_stretch, + extra: "", + }, + Scene { + id: "text", + name: "rotated text in flow", + build: s_text, + extra: "", + }, + Scene { + id: "rotframe", + name: "rotated flex frame (rigid)", + build: s_rotated_frame, + extra: "", + }, + Scene { + id: "group", + name: "group rotating in flow", + build: s_group, + extra: "", + }, + Scene { + id: "two", + name: "two rotating siblings", + build: s_two, + extra: "", + }, + Scene { + id: "between", + name: "space-between × rotation", + build: s_between, + extra: "", + }, + Scene { + id: "wrap", + name: "wrap reflow mid-rotation", + build: s_wrap, + extra: "", + }, Scene { id: "flipzero", name: "resize across zero (flip)", @@ -368,10 +408,7 @@ fn main() { } // node labels from θ=0 build let (_, nodes0) = (sc.build)(0.0); - let labels: Vec = nodes0 - .iter() - .map(|(_, l)| format!("\"{}\"", l)) - .collect(); + let labels: Vec = nodes0.iter().map(|(_, l)| format!("\"{}\"", l)).collect(); let _ = write!( json, "{{\"id\":\"{}\",\"name\":\"{}\",\"labels\":[{}]{},\"frames\":[", diff --git a/model-v2/a/lab/src/bin/fork.rs b/model-v2/a/lab/src/bin/fork.rs index a168903aea..b27676cad0 100644 --- a/model-v2/a/lab/src/bin/fork.rs +++ b/model-v2/a/lab/src/bin/fork.rs @@ -67,7 +67,12 @@ fn at(mut h: Header, x: f32, y: f32) -> Header { /// grow: fixed row, middle card grow=1 rotating — DEC-1's home turf. fn s_grow(theta: f32) -> Built { let mut b = DocBuilder::new(); - let (fh, fp) = frame_flex(SizeIntent::Fixed(460.0), SizeIntent::Fixed(170.0), 10.0, 10.0); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + 10.0, + 10.0, + ); let f = b.add(0, at(fh, 20.0, 20.0), fp); let mut out = vec![(f, "container")]; for i in 0..3 { @@ -84,7 +89,12 @@ fn s_grow(theta: f32) -> Built { /// stretch: cross-axis fill rotating. fn s_stretch(theta: f32) -> Built { let mut b = DocBuilder::new(); - let (fh, fp) = frame_flex(SizeIntent::Fixed(460.0), SizeIntent::Fixed(190.0), 10.0, 10.0); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(190.0), + 10.0, + 10.0, + ); let f = b.add(0, at(fh, 20.0, 20.0), fp); let mut out = vec![(f, "container")]; for i in 0..3 { @@ -162,7 +172,12 @@ fn s_wrap(theta: f32) -> Built { /// between: pinned ends, envelope eats the free space — or overlaps. fn s_between(theta: f32) -> Built { let mut b = DocBuilder::new(); - let (fh, fp) = frame_flex(SizeIntent::Fixed(460.0), SizeIntent::Fixed(170.0), 0.0, 10.0); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(460.0), + SizeIntent::Fixed(170.0), + 0.0, + 10.0, + ); let f = b.add(0, at(fh, 20.0, 20.0), fp); if let Payload::Frame { layout, .. } = &mut b.node_mut(f).payload { layout.main_align = MainAlign::SpaceBetween; @@ -183,7 +198,12 @@ fn s_between(theta: f32) -> Built { /// keeps CSS semantics; in the CSS arm both behave the same (overlap). fn s_lens(theta: f32) -> Built { let mut b = DocBuilder::new(); - let (fh, fp) = frame_flex(SizeIntent::Fixed(520.0), SizeIntent::Fixed(190.0), 10.0, 10.0); + let (fh, fp) = frame_flex( + SizeIntent::Fixed(520.0), + SizeIntent::Fixed(190.0), + 10.0, + 10.0, + ); let f = b.add(0, at(fh, 20.0, 20.0), fp); let mut out = vec![(f, "container")]; let (h1, p1) = card(60.0, 100.0); @@ -298,13 +318,41 @@ fn emit_arm(json: &mut String, arm: RotationInFlow, sc: &Scene) { fn main() { let scenes: Vec = vec![ - Scene { id: "grow", name: "grow x rotation", build: s_grow }, - Scene { id: "stretch", name: "stretch x rotation", build: s_stretch }, - Scene { id: "text", name: "rotated text in a row", build: s_text }, - Scene { id: "hug", name: "hug containment", build: s_hug }, - Scene { id: "wrap", name: "wrap reflow", build: s_wrap }, - Scene { id: "between", name: "space-between", build: s_between }, - Scene { id: "lens", name: "two lanes in one row", build: s_lens }, + Scene { + id: "grow", + name: "grow x rotation", + build: s_grow, + }, + Scene { + id: "stretch", + name: "stretch x rotation", + build: s_stretch, + }, + Scene { + id: "text", + name: "rotated text in a row", + build: s_text, + }, + Scene { + id: "hug", + name: "hug containment", + build: s_hug, + }, + Scene { + id: "wrap", + name: "wrap reflow", + build: s_wrap, + }, + Scene { + id: "between", + name: "space-between", + build: s_between, + }, + Scene { + id: "lens", + name: "two lanes in one row", + build: s_lens, + }, ]; let mut json = String::from("{\"step\":3,\"scenes\":["); diff --git a/model-v2/a/lab/src/model.rs b/model-v2/a/lab/src/model.rs index ac5c6db3c5..7c49b95b81 100644 --- a/model-v2/a/lab/src/model.rs +++ b/model-v2/a/lab/src/model.rs @@ -274,6 +274,14 @@ pub struct Document { /// Parent link column, index-aligned with `slots`. Maintained by the /// structural APIs below — mutate children through them, not by hand. parents: Vec>, + /// Generation per slot (ENG-2.3), index-aligned with `slots`: + /// incremented when a slot is tombstoned, so a future reused slot + /// cannot alias a prior node's cache identity (`engine::ident::Key`). + /// The arena is append-only today (`add_child` asserts a fresh slot), + /// so every live node sits at generation 0 and the column is the + /// dormant guard the cache tier will key on. A storage artifact — + /// ignored by the semantic `PartialEq`, like tombstones. + generations: Vec, /// Scene root: a frame whose bindings span the viewport (a.md §3 — the /// InitialContainer regularized). pub root: NodeId, @@ -288,15 +296,8 @@ impl PartialEq for Document { return false; } self.slots.iter().enumerate().all(|(i, s)| match s { - None => other - .slots - .get(i) - .map(|o| o.is_none()) - .unwrap_or(true), - Some(n) => { - other.get_opt(i as NodeId) == Some(n) - && self.parents[i] == other.parents[i] - } + None => other.slots.get(i).map(|o| o.is_none()).unwrap_or(true), + Some(n) => other.get_opt(i as NodeId) == Some(n) && self.parents[i] == other.parents[i], }) } } @@ -327,14 +328,30 @@ impl Document { self.slots.len() } + /// The slot's generation (ENG-2.3) — pair with the [`NodeId`] to form + /// an `engine::ident::Key`. Out-of-range ids read 0. + pub fn gen_of(&self, id: NodeId) -> u32 { + self.generations.get(id as usize).copied().unwrap_or(0) + } + fn ensure_slot(&mut self, id: NodeId) { let need = id as usize + 1; if self.slots.len() < need { self.slots.resize_with(need, || None); self.parents.resize(need, None); + self.generations.resize(need, 0); } } + /// Tombstone slot `id` and bump its generation (ENG-2.3). The single + /// place a slot dies — both remove paths funnel through it, so the + /// generation guard can never be bypassed. + fn vacate(&mut self, id: NodeId) { + self.slots[id as usize] = None; + self.parents[id as usize] = None; + self.generations[id as usize] += 1; + } + /// Structural insert: registers the node at `node.id` and attaches it /// as the last child of `parent`. pub fn add_child(&mut self, parent: NodeId, node: Node) -> NodeId { @@ -364,16 +381,14 @@ impl Document { for c in children { n += self.tombstone_rec(c); } - self.slots[id as usize] = None; - self.parents[id as usize] = None; + self.vacate(id); n } /// Tombstone a single slot without touching its (already re-homed) /// children — the ungroup bake's final step. pub fn remove_slot(&mut self, id: NodeId) { - self.slots[id as usize] = None; - self.parents[id as usize] = None; + self.vacate(id); } /// Replace `remove` children of `parent` at `idx` with `insert`, @@ -411,6 +426,7 @@ impl Document { Document { slots, parents, + generations: vec![0; cap], root, } } diff --git a/model-v2/a/lab/src/ops.rs b/model-v2/a/lab/src/ops.rs index e568d1e096..4ffa50ec69 100644 --- a/model-v2/a/lab/src/ops.rs +++ b/model-v2/a/lab/src/ops.rs @@ -10,7 +10,7 @@ use crate::math::Affine; use crate::model::*; -use crate::resolve::Resolved; +use crate::resolve::{Resolved, RotationInFlow}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum OpError { @@ -97,13 +97,7 @@ pub fn set_y(doc: &mut Document, resolved: &Resolved, id: NodeId, value: f32) -> } /// OP-MOVE: drag by (dx,dy) → exactly two offset writes. -pub fn move_by( - doc: &mut Document, - resolved: &Resolved, - id: NodeId, - dx: f32, - dy: f32, -) -> OpResult { +pub fn move_by(doc: &mut Document, resolved: &Resolved, id: NodeId, dx: f32, dy: f32) -> OpResult { let (x, y, _, _) = resolved.xywh(id); let a = set_x(doc, resolved, id, x + dx)?; let b = set_y(doc, resolved, id, y + dy)?; @@ -206,6 +200,7 @@ pub fn rotate_derived_center_feel( } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum Axis { X, Y, @@ -217,7 +212,8 @@ pub enum Axis { /// becomes |target − anchor| and the axis flip toggles relative to the /// gesture baseline (Figma parity: fill/flip survive as intent; the typed /// `set_width(−50)` stays a wall — `NegativeExtent`). -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ResizeDrag { pub axis: Axis, /// Parent-space coordinate of the FIXED edge, captured at gesture start. @@ -349,7 +345,10 @@ pub fn ungroup(doc: &mut Document, resolved: &Resolved, group_id: NodeId) -> OpR let parent_id = doc.parent_of(group_id).ok_or(OpError::WrongKind)?; let group_local = resolved.local_of(group_id); let group_theta = doc.get(group_id).header.rotation; - let (gfx, gfy) = (doc.get(group_id).header.flip_x, doc.get(group_id).header.flip_y); + let (gfx, gfy) = ( + doc.get(group_id).header.flip_x, + doc.get(group_id).header.flip_y, + ); // Mirror conjugation: F·R(θ) = R(σθ)·F with σ = −1 under a single-axis // mirror (det −1) and σ = +1 under none/both (F_xy = R(180), det +1). // So R(θg)·F_g·R(θc)·F_c = R(θg + σ·θc)·F_{g⊕c} — the bake stays three @@ -408,3 +407,194 @@ pub fn ungroup(doc: &mut Document, resolved: &Resolved, group_id: NodeId) -> OpR Ok(writes) } +// --------------------------------------------------------------------------- +// The op as data (ENG-5.1) + its dispatcher + its dirty class (ENG-1.2). +// +// The free functions above are the implementations; `Op` names each one as a +// value and `apply` is the sole dispatcher, so the journal, replay, and the +// dirty-class map all describe exactly the writes the free functions perform. +// The free functions stay the primary API (every existing test calls them); +// `apply` is pure dispatch, guarded equal to them per-variant by +// `tests/op_apply.rs`. +// --------------------------------------------------------------------------- + +/// One editor write as data. `ResizeDrag` carries its begin-state inline, so +/// replaying the op never has to re-derive gesture context. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Op { + SetX { + id: NodeId, + value: f32, + }, + SetY { + id: NodeId, + value: f32, + }, + MoveBy { + id: NodeId, + dx: f32, + dy: f32, + }, + SetWidth { + id: NodeId, + value: f32, + }, + SetHeight { + id: NodeId, + value: f32, + }, + SetRotation { + id: NodeId, + deg: f32, + }, + RotateDerivedCenterFeel { + id: NodeId, + deg: f32, + }, + ResizeDrag { + id: NodeId, + drag: ResizeDrag, + target: f32, + }, + ResizeTopLeft { + id: NodeId, + x: f32, + y: f32, + w: f32, + h: f32, + }, + Delete { + id: NodeId, + }, + Ungroup { + id: NodeId, + }, +} + +impl Op { + /// The node this op addresses (logging, dirty scoping). + pub fn target(&self) -> NodeId { + match *self { + Op::SetX { id, .. } + | Op::SetY { id, .. } + | Op::MoveBy { id, .. } + | Op::SetWidth { id, .. } + | Op::SetHeight { id, .. } + | Op::SetRotation { id, .. } + | Op::RotateDerivedCenterFeel { id, .. } + | Op::ResizeDrag { id, .. } + | Op::ResizeTopLeft { id, .. } + | Op::Delete { id } + | Op::Ungroup { id } => id, + } + } +} + +/// Apply one typed op — pure dispatch to the free functions above. `resolved` +/// must be a FRESH resolve of `doc`; variants that don't consult it +/// (size/rotation/delete) ignore it. Never diverges from the free functions +/// (guarded by `tests/op_apply.rs`). +pub fn apply(doc: &mut Document, resolved: &Resolved, op: &Op) -> OpResult { + match op { + Op::SetX { id, value } => set_x(doc, resolved, *id, *value), + Op::SetY { id, value } => set_y(doc, resolved, *id, *value), + Op::MoveBy { id, dx, dy } => move_by(doc, resolved, *id, *dx, *dy), + Op::SetWidth { id, value } => set_width(doc, *id, *value), + Op::SetHeight { id, value } => set_height(doc, *id, *value), + Op::SetRotation { id, deg } => set_rotation(doc, *id, *deg), + Op::RotateDerivedCenterFeel { id, deg } => { + rotate_derived_center_feel(doc, resolved, *id, *deg) + } + Op::ResizeDrag { id, drag, target } => resize_drag(doc, resolved, *id, drag, *target), + Op::ResizeTopLeft { id, x, y, w, h } => resize_top_left(doc, resolved, *id, *x, *y, *w, *h), + Op::Delete { id } => delete(doc, *id), + Op::Ungroup { id } => ungroup(doc, resolved, *id), + } +} + +/// Which resolve phases a write can dirty (ENG-1.2): M measure, L layout, +/// T transform, B bounds. A bitmask, so one op can name several. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PhaseMask(pub u8); + +impl PhaseMask { + pub const M: PhaseMask = PhaseMask(1); + pub const L: PhaseMask = PhaseMask(2); + pub const T: PhaseMask = PhaseMask(4); + pub const B: PhaseMask = PhaseMask(8); + pub const fn or(self, o: PhaseMask) -> PhaseMask { + PhaseMask(self.0 | o.0) + } + pub fn has(self, o: PhaseMask) -> bool { + self.0 & o.0 == o.0 + } +} + +/// How far a write's damage reaches (ENG-1.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirtyExtent { + SelfOnly, + Subtree, + MeasureChainToFixedAncestor, + BoundsOnly, +} + +/// The invalidation scope an op declares. Consumed by the incremental +/// resolver (OS-1a) — **today the engine ignores it and does a full resolve**, +/// so the full resolver stays the oracle (ENG-1.1). The assignments in +/// [`dirty_class`] are deliberately conservative (wide): an over-approximation +/// only costs extra work, never a missed update; they tighten with +/// measurement. The exhaustive match guarantees every op has one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DirtyClass { + pub phases: PhaseMask, + pub extent: DirtyExtent, +} + +/// The declared dirty class per op. Parameterized on [`RotationInFlow`] +/// because rotation is paint-only under `VisualOnly` (DEC-0) but feeds the +/// parent's sizing under `AabbParticipates` — hardcoding the default would be +/// a landmine when the incremental engine finally reads this. +pub fn dirty_class(op: &Op, rot: RotationInFlow) -> DirtyClass { + let all = PhaseMask::M + .or(PhaseMask::L) + .or(PhaseMask::T) + .or(PhaseMask::B); + let layout = PhaseMask::L.or(PhaseMask::T).or(PhaseMask::B); // reflow, no re-measure + let paint = PhaseMask::T.or(PhaseMask::B); // transform + bounds only + match op { + // Position: never re-measures. Free move is self+subtree; an in-flow + // attempt reflows up-chain (coerced) — declare the wider reach. + Op::SetX { .. } | Op::SetY { .. } | Op::MoveBy { .. } => DirtyClass { + phases: layout, + extent: DirtyExtent::MeasureChainToFixedAncestor, + }, + // Size: changes measure -> reflow -> transform -> bounds. + Op::SetWidth { .. } + | Op::SetHeight { .. } + | Op::ResizeTopLeft { .. } + | Op::ResizeDrag { .. } => DirtyClass { + phases: all, + extent: DirtyExtent::MeasureChainToFixedAncestor, + }, + // Rotation: arm-dependent. Paint-only under VisualOnly; feeds the + // parent's sizing under AabbParticipates. + Op::SetRotation { .. } | Op::RotateDerivedCenterFeel { .. } => match rot { + RotationInFlow::VisualOnly => DirtyClass { + phases: paint, + extent: DirtyExtent::Subtree, + }, + RotationInFlow::AabbParticipates => DirtyClass { + phases: layout, + extent: DirtyExtent::MeasureChainToFixedAncestor, + }, + }, + // Structural: a subtree leaves/changes; the parent's measure chain + // reflows (hug/flex). + Op::Delete { .. } | Op::Ungroup { .. } => DirtyClass { + phases: all, + extent: DirtyExtent::MeasureChainToFixedAncestor, + }, + } +} diff --git a/model-v2/a/lab/src/pick.rs b/model-v2/a/lab/src/pick.rs index 4998919fa0..aba3a1fcc8 100644 --- a/model-v2/a/lab/src/pick.rs +++ b/model-v2/a/lab/src/pick.rs @@ -36,8 +36,16 @@ fn hit_subtree(doc: &Document, r: &Resolved, id: NodeId, p: (f32, f32)) -> Optio let inv = r.world_of(id).invert()?; let (lx, ly) = inv.apply(p); let b = r.box_of(id); - let sx = if b.w < 2.0 * HAIRLINE_SLOP { HAIRLINE_SLOP } else { 0.0 }; - let sy = if b.h < 2.0 * HAIRLINE_SLOP { HAIRLINE_SLOP } else { 0.0 }; + let sx = if b.w < 2.0 * HAIRLINE_SLOP { + HAIRLINE_SLOP + } else { + 0.0 + }; + let sy = if b.h < 2.0 * HAIRLINE_SLOP { + HAIRLINE_SLOP + } else { + 0.0 + }; if lx >= -sx && lx <= b.w + sx && ly >= -sy && ly <= b.h + sy { Some(id) } else { diff --git a/model-v2/a/lab/src/resolve.rs b/model-v2/a/lab/src/resolve.rs index 21dce35438..5d4a2dcd53 100644 --- a/model-v2/a/lab/src/resolve.rs +++ b/model-v2/a/lab/src/resolve.rs @@ -110,6 +110,25 @@ impl Resolved { pub fn world_opt(&self, id: NodeId) -> Option { self.world.get(id as usize).copied().flatten() } + /// Non-panicking column reads. Any consumer that walks all slots + /// (some absent — hidden subtrees, tombstones) uses these, never the + /// `*_of` forms that assert resolution. The damage differ + /// (`engine::damage`) is the first caller. + pub fn box_opt(&self, id: NodeId) -> Option { + self.box_in_parent.get(id as usize).copied().flatten() + } + pub fn local_opt(&self, id: NodeId) -> Option { + self.local.get(id as usize).copied().flatten() + } + pub fn aabb_opt(&self, id: NodeId) -> Option { + self.world_aabb.get(id as usize).copied().flatten() + } + /// Number of slots in the resolved columns (matches the document's + /// arena capacity) — the upper bound for a full-column walk. Distinct + /// from [`Self::resolved_count`], which counts only the `Some` entries. + pub fn slot_count(&self) -> usize { + self.world.len() + } /// Number of nodes that resolved (hidden subtrees are absent). pub fn resolved_count(&self) -> usize { self.world.iter().filter(|w| w.is_some()).count() @@ -231,7 +250,14 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 }); // min/max clamp last; min beats max (G-4 declared rule). - wv = clamp_axis(id, "width", wv, node.header.min_width, node.header.max_width, cx); + wv = clamp_axis( + id, + "width", + wv, + node.header.min_width, + node.header.max_width, + cx, + ); hv = clamp_axis( id, "height", @@ -310,9 +336,7 @@ fn intent_extent_x(id: NodeId, cx: &mut Ctx) -> Option { match node.header.width { SizeIntent::Fixed(v) => Some(v), SizeIntent::Auto => match &node.payload { - Payload::Text { content, font_size } => { - Some(measure_text(content, *font_size, None).0) - } + Payload::Text { content, font_size } => Some(measure_text(content, *font_size, None).0), Payload::Frame { .. } => Some(hug_size(id, cx).0), _ => None, }, @@ -376,9 +400,17 @@ fn hug_size(id: NodeId, cx: &mut Ctx) -> (f32, f32) { let (cw, ch) = extent_of(child_id, None, cx); let is_derived = cx.doc.get(child_id).payload.box_is_derived(); let u = if is_derived { - cx.union_cache.get(&child_id).copied().unwrap_or(RectF::EMPTY) + cx.union_cache + .get(&child_id) + .copied() + .unwrap_or(RectF::EMPTY) } else { - RectF { x: 0.0, y: 0.0, w: cw, h: ch } + RectF { + x: 0.0, + y: 0.0, + w: cw, + h: ch, + } }; let child = cx.doc.get(child_id); let ox = start_offset_or_report(child_id, child.header.x, "x", cx); @@ -566,7 +598,11 @@ fn union_of_derived(id: NodeId, cx: &mut Ctx) -> RectF { // Derived children: pins place the ORIGIN; the box = origin + own // union offset (census fix: nested unions must not swallow it). let derived_off = if cx.doc.get(child_id).payload.box_is_derived() { - let u = cx.union_cache.get(&child_id).copied().unwrap_or(RectF::EMPTY); + let u = cx + .union_cache + .get(&child_id) + .copied() + .unwrap_or(RectF::EMPTY); (u.x, u.y) } else { (0.0, 0.0) @@ -708,7 +744,11 @@ fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), // Derived kinds keep their union dims; under AABB mode // the *post-rotation* union center lands on the slot // center (pivot is the origin, so the center swings). - let u = cx.union_cache.get(&child_id).copied().unwrap_or(RectF::EMPTY); + let u = cx + .union_cache + .get(&child_id) + .copied() + .unwrap_or(RectF::EMPTY); if cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates { let (scx, scy) = slot.center(); // Post-transform union center (R·F — same composition @@ -730,9 +770,7 @@ fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), h: u.h, } } - } else if rotated - && cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates - { + } else if rotated && cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates { // Box center := slot center; box keeps its basis dims. let (bw, bh) = basis; let (cx_, cy_) = slot.center(); @@ -990,25 +1028,31 @@ fn flex_layout( avail, |known, avail_space, _node, ctx, _style| { if std::env::var("ANCHOR_DBG").is_ok() { - eprintln!("measure ctx={} known={:?} avail={:?}", ctx.is_some(), known, avail_space); + eprintln!( + "measure ctx={} known={:?} avail={:?}", + ctx.is_some(), + known, + avail_space + ); } match ctx { - Some(t) => { - let constraint = known.width.or(match avail_space.width { - AvailableSpace::Definite(w) => Some(w), - _ => None, - }); - let (w, h) = measure_text(&t.content, t.font_size, constraint); - taffy::geometry::Size { - width: known.width.unwrap_or(w), - height: known.height.unwrap_or(h), + Some(t) => { + let constraint = known.width.or(match avail_space.width { + AvailableSpace::Definite(w) => Some(w), + _ => None, + }); + let (w, h) = measure_text(&t.content, t.font_size, constraint); + taffy::geometry::Size { + width: known.width.unwrap_or(w), + height: known.height.unwrap_or(h), + } } + None => taffy::geometry::Size { + width: known.width.unwrap_or(0.0), + height: known.height.unwrap_or(0.0), + }, } - None => taffy::geometry::Size { - width: known.width.unwrap_or(0.0), - height: known.height.unwrap_or(0.0), - }, - }}, + }, ) .unwrap(); diff --git a/model-v2/a/lab/src/svgout.rs b/model-v2/a/lab/src/svgout.rs index f15f6cffe1..7ed9f7c118 100644 --- a/model-v2/a/lab/src/svgout.rs +++ b/model-v2/a/lab/src/svgout.rs @@ -7,10 +7,7 @@ use crate::resolve::Resolved; use std::fmt::Write as _; fn mat(t: &Affine) -> String { - format!( - "matrix({} {} {} {} {} {})", - t.a, t.b, t.c, t.d, t.e, t.f - ) + format!("matrix({} {} {} {} {} {})", t.a, t.b, t.c, t.d, t.e, t.f) } pub struct SvgOptions { diff --git a/model-v2/a/lab/src/textir.rs b/model-v2/a/lab/src/textir.rs index 1199d6512f..6ac7a6da06 100644 --- a/model-v2/a/lab/src/textir.rs +++ b/model-v2/a/lab/src/textir.rs @@ -40,7 +40,12 @@ fn parse_num(s: &str, what: &str) -> Result { fn parse_binding(s: &str, what: &str) -> Result { let parts: Vec<&str> = s.split_whitespace().collect(); match parts.as_slice() { - [n] if n.chars().next().map(|c| c.is_ascii_digit() || c == '-' || c == '.') == Some(true) => { + [n] if n + .chars() + .next() + .map(|c| c.is_ascii_digit() || c == '-' || c == '.') + == Some(true) => + { Ok(AxisBinding::start(parse_num(n, what)?)) } ["start", n] => Ok(AxisBinding::start(parse_num(n, what)?)), @@ -277,8 +282,8 @@ pub fn parse(input: &str) -> Result { } } "shape" => { - let desc = shape_kind - .ok_or_else(|| ParseError(" requires kind".into()))?; + let desc = + shape_kind.ok_or_else(|| ParseError(" requires kind".into()))?; if matches!(desc, ShapeDesc::Line) { header.height = SizeIntent::Fixed(0.0); // §3.2 locked } @@ -325,9 +330,7 @@ pub fn parse(input: &str) -> Result { } Ok(Event::Text(t)) => { if let Some(p) = stack.last_mut() { - let txt = t - .unescape() - .map_err(|e| ParseError(format!("text: {e}")))?; + let txt = t.unescape().map_err(|e| ParseError(format!("text: {e}")))?; p.text_content.push_str(&txt); } } @@ -347,7 +350,9 @@ pub fn parse(input: &str) -> Result { } fn finish(stack: &mut Vec, nodes: &mut BTreeMap) -> Result<(), ParseError> { - let p = stack.pop().ok_or_else(|| ParseError("unbalanced end".into()))?; + let p = stack + .pop() + .ok_or_else(|| ParseError("unbalanced end".into()))?; if p.is_text { if let Payload::Text { content, .. } = &mut nodes.get_mut(&p.id).unwrap().payload { *content = p.text_content; @@ -530,11 +535,7 @@ fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { push_attr( out, "gap", - &format!( - "{} {}", - fmt_num(layout.gap_main), - fmt_num(layout.gap_cross) - ), + &format!("{} {}", fmt_num(layout.gap_main), fmt_num(layout.gap_cross)), ); } } diff --git a/model-v2/a/lab/tests/accessors.rs b/model-v2/a/lab/tests/accessors.rs new file mode 100644 index 0000000000..cf8fa1ee86 --- /dev/null +++ b/model-v2/a/lab/tests/accessors.rs @@ -0,0 +1,73 @@ +//! The non-panicking `Resolved` column reads (engine setup, step 2). The +//! damage differ and any consumer that walks all arena slots use these +//! instead of the `*_of` forms that assert resolution. Guards: they agree +//! with the panicking forms where resolved, and return `None` off the +//! resolved set (hidden subtree, out-of-range id). + +mod common; + +use anchor_lab::model::*; +use anchor_lab::resolve::resolve; +use common::opts_visual; + +#[test] +fn opt_accessors_agree_with_panicking_forms() { + let mut b = DocBuilder::new(); + let s = b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(28.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let doc = b.build(); + let r = resolve(&doc, &opts_visual()); + + assert_eq!(r.box_opt(s), Some(r.box_of(s))); + assert_eq!(r.local_opt(s), Some(r.local_of(s))); + assert_eq!(r.aabb_opt(s), Some(r.aabb_of(s))); + assert_eq!(r.world_opt(s), Some(r.world_of(s))); +} + +#[test] +fn slot_count_matches_arena_capacity() { + let mut b = DocBuilder::new(); + b.add( + 0, + Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let doc = b.build(); + let r = resolve(&doc, &opts_visual()); + assert_eq!(r.slot_count(), doc.capacity()); +} + +#[test] +fn hidden_and_out_of_range_read_none() { + let mut b = DocBuilder::new(); + let mut h = Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)); + h.active = false; // display:none — the resolver skips it, column stays None + let hidden = b.add( + 0, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let doc = b.build(); + let r = resolve(&doc, &opts_visual()); + + assert_eq!(r.box_opt(hidden), None); + assert_eq!(r.local_opt(hidden), None); + assert_eq!(r.aabb_opt(hidden), None); + assert_eq!(r.world_opt(hidden), None); + + // Never-allocated id: `.get()` guards the index rather than panicking. + let ghost: NodeId = 9999; + assert_eq!(r.box_opt(ghost), None); + assert_eq!(r.local_opt(ghost), None); + assert_eq!(r.aabb_opt(ghost), None); + assert_eq!(r.world_opt(ghost), None); +} diff --git a/model-v2/a/lab/tests/common/mod.rs b/model-v2/a/lab/tests/common/mod.rs index a3a4ead377..b18d184d5f 100644 --- a/model-v2/a/lab/tests/common/mod.rs +++ b/model-v2/a/lab/tests/common/mod.rs @@ -2,7 +2,7 @@ use anchor_lab::math::RectF; use anchor_lab::model::*; -use anchor_lab::resolve::{resolve, Resolved, ResolveOptions, RotationInFlow}; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved, RotationInFlow}; pub const EPS: f32 = 1e-3; // N-3 within-platform tolerance for the lab diff --git a/model-v2/a/lab/tests/derived.rs b/model-v2/a/lab/tests/derived.rs index 67a5f38129..b0d7a02b19 100644 --- a/model-v2/a/lab/tests/derived.rs +++ b/model-v2/a/lab/tests/derived.rs @@ -25,7 +25,11 @@ fn rotated_group_doc() -> (Document, NodeId, NodeId, NodeId) { #[test] fn d1_union_of_oriented_corners() { let mut b = DocBuilder::new(); - let g = b.add(0, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let g = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (mut s1h, s1p) = shape(100.0, 20.0); s1h.rotation = 90.0; // oriented AABB: 20×100 about center (50,10) let s1 = b.add(g, s1h, s1p); @@ -83,7 +87,11 @@ fn d6_group_in_flex_participates_via_derived_aabb() { let f = b.add(0, h, p); let (s0h, s0p) = shape(50.0, 50.0); let s0 = b.add(f, s0h, s0p); - let g = b.add(f, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let g = b.add( + f, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (g1h, g1p) = shape(30.0, 30.0); b.add(g, g1h, g1p); let (mut g2h, g2p) = shape(30.0, 30.0); @@ -148,7 +156,10 @@ fn lens_ops_are_layout_transparent() { assert_close(r.box_of(s3).x, 50.0 + 10.0 + 240.0 + 10.0, "sibling slot"); // …but the child's world transform carries the skew let w = r.world_of(c); - assert!((w.c - (20.0f32).to_radians().tan()).abs() < 1e-4, "skew in world"); + assert!( + (w.c - (20.0f32).to_radians().tan()).abs() < 1e-4, + "skew in world" + ); // and the render bounds (world AABB) are wider than the box assert!(r.aabb_of(lens).w > 240.0, "post-ops render bounds"); } diff --git a/model-v2/a/lab/tests/edge_census.rs b/model-v2/a/lab/tests/edge_census.rs index ee512c8dab..57e4b0bf53 100644 --- a/model-v2/a/lab/tests/edge_census.rs +++ b/model-v2/a/lab/tests/edge_census.rs @@ -72,13 +72,7 @@ fn text_hug_chain_3_deep() { let mut parent = 0; let mut frames = vec![]; for _ in 0..3 { - let (h, p) = frame_flex( - SizeIntent::Auto, - SizeIntent::Auto, - Direction::Row, - 0.0, - 5.0, - ); + let (h, p) = frame_flex(SizeIntent::Auto, SizeIntent::Auto, Direction::Row, 0.0, 5.0); parent = b.add(parent, h, p); frames.push(parent); } @@ -132,7 +126,11 @@ fn empty_group_in_flex() { let f = b.add(0, h, p); let (h1, p1) = shape(50.0, 50.0); b.add(f, h1, p1); - b.add(f, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + b.add( + f, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (h2, p2) = shape(50.0, 50.0); let c = b.add(f, h2, p2); let r = run(&b.build()); @@ -197,7 +195,11 @@ fn nested_groups_3_deep_rotated() { #[test] fn group_union_of_rotated_members() { let mut b = DocBuilder::new(); - let g = b.add(0, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let g = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (mut h1, p1) = shape(40.0, 40.0); h1.rotation = 45.0; // envelope 56.57 about center (20,20) b.add(g, h1, p1); @@ -314,7 +316,10 @@ fn lens_scale_zero_degenerate() { let r = run(&b.build()); assert_close(r.box_of(c).x, 70.0, "layout uses pre-ops box (10+50+10)"); let aabb = r.aabb_of(l); - assert!(aabb.w.is_finite() && aabb.w < 1e-3, "paint collapsed, finite"); + assert!( + aabb.w.is_finite() && aabb.w < 1e-3, + "paint collapsed, finite" + ); } // ---------- rotation extremes ---------- @@ -345,8 +350,14 @@ fn span_full_bleed_rotated_overflows() { fh.x = AxisBinding::start(0.0); let f = b.add(0, fh, fp); let mut h = Header::new(SizeIntent::Auto, SizeIntent::Auto); - h.x = AxisBinding::Span { start: 0.0, end: 0.0 }; - h.y = AxisBinding::Span { start: 0.0, end: 0.0 }; + h.x = AxisBinding::Span { + start: 0.0, + end: 0.0, + }; + h.y = AxisBinding::Span { + start: 0.0, + end: 0.0, + }; h.rotation = 30.0; let s = b.add( f, @@ -356,7 +367,14 @@ fn span_full_bleed_rotated_overflows() { }, ); let r = run(&b.build()); - assert_rect(r.box_of(s), 0.0, 0.0, 400.0, 300.0, "span box unbent by rotation"); + assert_rect( + r.box_of(s), + 0.0, + 0.0, + 400.0, + 300.0, + "span box unbent by rotation", + ); let aabb = r.aabb_of(s); assert!(aabb.w > 400.0 && aabb.h > 300.0, "corners swing outside"); } @@ -418,8 +436,16 @@ fn far_canvas_rotation_center() { let r = run(&b.build()); let c = r.world_of(s).apply((60.0, 40.0)); // f32 ULP at 1e6 is 0.0625 — allow a scaled tolerance - assert!((c.0 - 1_000_060.0).abs() < 0.25, "center x at far canvas: {}", c.0); - assert!((c.1 - 1_000_040.0).abs() < 0.25, "center y at far canvas: {}", c.1); + assert!( + (c.0 - 1_000_060.0).abs() < 0.25, + "center x at far canvas: {}", + c.0 + ); + assert!( + (c.1 - 1_000_040.0).abs() < 0.25, + "center y at far canvas: {}", + c.1 + ); } /// Census blocker fix: Span{0,0} (the canonical free-context fill) is a @@ -431,7 +457,10 @@ fn span_fill_text_rewraps() { fh.x = AxisBinding::start(0.0); let f = b.add(0, fh, fp); let mut th = Header::new(SizeIntent::Auto, SizeIntent::Auto); - th.x = AxisBinding::Span { start: 0.0, end: 0.0 }; + th.x = AxisBinding::Span { + start: 0.0, + end: 0.0, + }; let t = b.add( f, th, @@ -456,7 +485,11 @@ fn nested_group_union_offset_exact() { gh.x = AxisBinding::start(100.0); gh.y = AxisBinding::start(50.0); let g = b.add(0, gh, Payload::Group); - let h = b.add(g, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let h = b.add( + g, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (mut sh, sp) = shape(40.0, 40.0); sh.x = AxisBinding::start(20.0); let s = b.add(h, sh, sp); @@ -476,7 +509,11 @@ fn ungroup_nested_group_preserves_world() { gh.y = AxisBinding::start(50.0); gh.rotation = 30.0; let g = b.add(0, gh, Payload::Group); - let h = b.add(g, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let h = b.add( + g, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (mut s1h, s1p) = shape(40.0, 40.0); s1h.x = AxisBinding::start(20.0); let s1 = b.add(h, s1h, s1p); @@ -491,7 +528,14 @@ fn ungroup_nested_group_preserves_world() { let after = run(&doc); for (id, w) in [(s1, w1), (s2, w2)] { let v = after.world_of(id); - for (a, e) in [(v.a,w.a),(v.b,w.b),(v.c,w.c),(v.d,w.d),(v.e,w.e),(v.f,w.f)] { + for (a, e) in [ + (v.a, w.a), + (v.b, w.b), + (v.c, w.c), + (v.d, w.d), + (v.e, w.e), + (v.f, w.f), + ] { assert!((a - e).abs() < 1e-3, "nested bake exact: {a} vs {e}"); } } @@ -527,5 +571,8 @@ fn negative_zero_rotation_canonicalized() { let s = b.add(0, h, p); let mut doc = b.build(); anchor_lab::ops::set_rotation(&mut doc, s, -0.0).unwrap(); - assert!(doc.get(s).header.rotation.is_sign_positive(), "−0.0 never stored"); + assert!( + doc.get(s).header.rotation.is_sign_positive(), + "−0.0 never stored" + ); } diff --git a/model-v2/a/lab/tests/escape.rs b/model-v2/a/lab/tests/escape.rs index bb50279960..676968c406 100644 --- a/model-v2/a/lab/tests/escape.rs +++ b/model-v2/a/lab/tests/escape.rs @@ -120,5 +120,9 @@ fn diagonal_wrapper_gives_theta_stable_slot() { for w in &widths { assert_close(*w, widths[0], "θ-stable container"); } - assert_close(widths[0], 10.0 + 60.0 + 10.0 + 116.61903 + 10.0 + 60.0 + 10.0, "diagonal slot"); + assert_close( + widths[0], + 10.0 + 60.0 + 10.0 + 116.61903 + 10.0 + 60.0 + 10.0, + "diagonal slot", + ); } diff --git a/model-v2/a/lab/tests/flip.rs b/model-v2/a/lab/tests/flip.rs index 2a8ee31d6f..f1c6f785a3 100644 --- a/model-v2/a/lab/tests/flip.rs +++ b/model-v2/a/lab/tests/flip.rs @@ -287,9 +287,18 @@ fn f4_in_flow_drag_writes_extent_and_flip_only() { fn f5_typed_negative_size_is_rejected_untouched() { let (mut doc, id) = free_rect(); let before = doc.clone(); - assert_eq!(ops::set_width(&mut doc, id, -50.0), Err(OpError::NegativeExtent)); - assert_eq!(ops::set_height(&mut doc, id, -0.5), Err(OpError::NegativeExtent)); - assert_eq!(doc, before, "rejected op must leave the document byte-identical"); + assert_eq!( + ops::set_width(&mut doc, id, -50.0), + Err(OpError::NegativeExtent) + ); + assert_eq!( + ops::set_height(&mut doc, id, -0.5), + Err(OpError::NegativeExtent) + ); + assert_eq!( + doc, before, + "rejected op must leave the document byte-identical" + ); } // --- F-6: ungroup bakes flips exactly ------------------------------------ diff --git a/model-v2/a/lab/tests/generations.rs b/model-v2/a/lab/tests/generations.rs new file mode 100644 index 0000000000..3ba6b95cc1 --- /dev/null +++ b/model-v2/a/lab/tests/generations.rs @@ -0,0 +1,40 @@ +//! The per-slot generation column (engine setup, step 3; ENG-2.3). Live +//! nodes sit at generation 0; tombstoning a slot bumps it, so a future +//! reused slot cannot alias a prior node's cache key. That generations do +//! NOT affect document equality is proven by the existing MM-7 test +//! (`mm_laws.rs`) still passing — the semantic PartialEq never reads them. + +mod common; + +use anchor_lab::model::*; + +fn one_child() -> (Document, NodeId) { + let mut b = DocBuilder::new(); + let c = b.add( + 0, + Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + (b.build(), c) +} + +#[test] +fn live_nodes_are_generation_zero() { + let (doc, c) = one_child(); + assert_eq!(doc.gen_of(doc.root), 0); + assert_eq!(doc.gen_of(c), 0); + assert_eq!(doc.gen_of(9999), 0); // out of range reads 0 +} + +#[test] +fn tombstone_bumps_generation() { + let (mut doc, c) = one_child(); + assert_eq!(doc.gen_of(c), 0); + let removed = doc.remove_subtree(c); + assert_eq!(removed, 1); + // The vacated slot's generation advanced; the root was untouched. + assert_eq!(doc.gen_of(c), 1); + assert_eq!(doc.gen_of(doc.root), 0); +} diff --git a/model-v2/a/lab/tests/geometry.rs b/model-v2/a/lab/tests/geometry.rs index 25d0a7b768..91be0b8de1 100644 --- a/model-v2/a/lab/tests/geometry.rs +++ b/model-v2/a/lab/tests/geometry.rs @@ -164,5 +164,12 @@ fn root_is_viewport_bound_frame() { let b = DocBuilder::new(); let doc = b.build(); let r = resolve(&doc, &opts()); - assert_rect(r.box_of(doc.root), 0.0, 0.0, 1000.0, 1000.0, "root=viewport"); + assert_rect( + r.box_of(doc.root), + 0.0, + 0.0, + 1000.0, + 1000.0, + "root=viewport", + ); } diff --git a/model-v2/a/lab/tests/layout.rs b/model-v2/a/lab/tests/layout.rs index c38efe7ccd..26fc102492 100644 --- a/model-v2/a/lab/tests/layout.rs +++ b/model-v2/a/lab/tests/layout.rs @@ -255,13 +255,7 @@ fn nested_flex_exact() { 0.0, ); let outer = b.add(0, h, p); - let (h2, p2) = frame_flex( - SizeIntent::Auto, - SizeIntent::Auto, - Direction::Row, - 5.0, - 0.0, - ); + let (h2, p2) = frame_flex(SizeIntent::Auto, SizeIntent::Auto, Direction::Row, 5.0, 0.0); let inner = b.add(outer, h2, p2); let (s1h, s1p) = shape(40.0, 30.0); let s1 = b.add(inner, s1h, s1p); diff --git a/model-v2/a/lab/tests/op_apply.rs b/model-v2/a/lab/tests/op_apply.rs new file mode 100644 index 0000000000..1563f22cd3 --- /dev/null +++ b/model-v2/a/lab/tests/op_apply.rs @@ -0,0 +1,137 @@ +//! ENG-5.1 · the typed `Op` + `apply` dispatcher must never diverge from +//! the free functions they wrap — every variant applied both ways from the +//! same start must leave the same result AND the same document. This is the +//! guard that lets the journal/replay describe writes by `Op` alone. + +mod common; +use common::{opts_visual, shape}; + +use anchor_lab::model::*; +use anchor_lab::ops::{self, Axis, Op, OpError, ResizeDrag}; +use anchor_lab::resolve::{resolve, Resolved}; + +/// root (viewport frame) with a free shape `s` and a group `g` of two shapes. +/// Deterministic ids: s = 1, g = 2. +fn fresh() -> (Document, NodeId, NodeId) { + let mut b = DocBuilder::new(); + let (mut sh, sp) = shape(80.0, 40.0); + sh.x = AxisBinding::start(20.0); + sh.y = AxisBinding::start(20.0); + let s = b.add(0, sh, sp); + + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(200.0); + gh.y = AxisBinding::start(60.0); + let g = b.add(0, gh, Payload::Group); + let (c1h, c1p) = shape(40.0, 40.0); + b.add(g, c1h, c1p); + let (mut c2h, c2p) = shape(40.0, 40.0); + c2h.x = AxisBinding::start(56.0); + b.add(g, c2h, c2p); + + (b.build(), s, g) +} + +/// Apply `op` and the equivalent free call from the SAME start + SAME resolve; +/// assert identical result and identical resulting document. +fn check(label: &str, op: Op, free: impl Fn(&mut Document, &Resolved) -> ops::OpResult) { + let (base, _, _) = fresh(); + let r = resolve(&base, &opts_visual()); + + let mut d_apply = base.clone(); + let res_apply = ops::apply(&mut d_apply, &r, &op); + + let mut d_free = base.clone(); + let res_free = free(&mut d_free, &r); + + assert_eq!(res_apply, res_free, "{label}: result mismatch"); + assert!(d_apply == d_free, "{label}: document mismatch"); +} + +#[test] +fn apply_matches_free_fns() { + let (base, s, g) = fresh(); + let r = resolve(&base, &opts_visual()); + + check("set_x", Op::SetX { id: s, value: 44.0 }, |d, rr| { + ops::set_x(d, rr, s, 44.0) + }); + check("set_y", Op::SetY { id: s, value: 33.0 }, |d, rr| { + ops::set_y(d, rr, s, 33.0) + }); + check( + "move_by", + Op::MoveBy { + id: s, + dx: 10.0, + dy: -7.0, + }, + |d, rr| ops::move_by(d, rr, s, 10.0, -7.0), + ); + check( + "set_width", + Op::SetWidth { + id: s, + value: 111.0, + }, + |d, _| ops::set_width(d, s, 111.0), + ); + check( + "set_height", + Op::SetHeight { id: s, value: 22.0 }, + |d, _| ops::set_height(d, s, 22.0), + ); + check( + "set_rotation", + Op::SetRotation { id: s, deg: 17.0 }, + |d, _| ops::set_rotation(d, s, 17.0), + ); + check( + "rotate_derived_center_feel", + Op::RotateDerivedCenterFeel { id: g, deg: 25.0 }, + |d, rr| ops::rotate_derived_center_feel(d, rr, g, 25.0), + ); + check( + "resize_top_left", + Op::ResizeTopLeft { + id: s, + x: 5.0, + y: 5.0, + w: 60.0, + h: 30.0, + }, + |d, rr| ops::resize_top_left(d, rr, s, 5.0, 5.0, 60.0, 30.0), + ); + check("ungroup", Op::Ungroup { id: g }, |d, rr| { + ops::ungroup(d, rr, g) + }); + check("delete", Op::Delete { id: s }, |d, _| ops::delete(d, s)); + + // resize_drag past the fixed (right) edge — the cross-zero flip path. + let drag = ResizeDrag::begin(&base, &r, s, Axis::X, AnchorEdge::End).unwrap(); + let b = r.box_of(s); + let target = b.x + b.w + 30.0; + check( + "resize_drag", + Op::ResizeDrag { + id: s, + drag, + target, + }, + move |d, rr| ops::resize_drag(d, rr, s, &drag, target), + ); +} + +/// A rejected op must surface the SAME typed error both ways and leave the +/// document byte-untouched (M-6) — errors are deterministic no-ops. +#[test] +fn apply_surfaces_errors_like_free_fns() { + let (base, _s, g) = fresh(); + let r = resolve(&base, &opts_visual()); + + // Sizing a derived (group) box: BoxDerived, doc untouched. + let mut d_apply = base.clone(); + let res_apply = ops::apply(&mut d_apply, &r, &Op::SetWidth { id: g, value: 50.0 }); + assert_eq!(res_apply, Err(OpError::BoxDerived)); + assert!(d_apply == base, "rejected op must not mutate the document"); +} diff --git a/model-v2/a/lab/tests/ops_suite.rs b/model-v2/a/lab/tests/ops_suite.rs index 34aff3230f..50eedc97a2 100644 --- a/model-v2/a/lab/tests/ops_suite.rs +++ b/model-v2/a/lab/tests/ops_suite.rs @@ -136,7 +136,11 @@ fn nan_rejected_at_write_boundary() { #[test] fn set_width_on_group_typed_error() { let mut b = DocBuilder::new(); - let g = b.add(0, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let g = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (h1, p1) = shape(40.0, 40.0); b.add(g, h1, p1); let mut doc = b.build(); diff --git a/model-v2/a/lab/tests/rotation.rs b/model-v2/a/lab/tests/rotation.rs index e5990abefa..ae092a3fef 100644 --- a/model-v2/a/lab/tests/rotation.rs +++ b/model-v2/a/lab/tests/rotation.rs @@ -200,8 +200,7 @@ fn group_origin_pivot_and_center_feel_gesture() { doc.get_mut(g).header.rotation = 0.0; let r2 = run(&doc); let center_before = (b0.x + b0.w / 2.0, b0.y + b0.h / 2.0); - let writes = - anchor_lab::ops::rotate_derived_center_feel(&mut doc, &r2, g, 30.0).unwrap(); + let writes = anchor_lab::ops::rotate_derived_center_feel(&mut doc, &r2, g, 30.0).unwrap(); assert_eq!(writes, 3, "rotation + x + y — the Figma trick over scalars"); let r3 = run(&doc); let bb = r3.box_of(g); diff --git a/model-v2/a/lab/tests/textir_suite.rs b/model-v2/a/lab/tests/textir_suite.rs index c3cf85e95e..475c0eacfa 100644 --- a/model-v2/a/lab/tests/textir_suite.rs +++ b/model-v2/a/lab/tests/textir_suite.rs @@ -59,7 +59,10 @@ fn quartet_parses_and_resolves() { assert_close(r.box_of(lens).w, 240.0, "(e) pre-ops box"); let child = 10; let w = r.world_of(child); - assert!((w.c - (20.0f32).to_radians().tan()).abs() < 1e-4, "(e) skew"); + assert!( + (w.c - (20.0f32).to_radians().tan()).abs() < 1e-4, + "(e) skew" + ); } /// S-1 analogue: parse → print → parse is a fixpoint on the model. @@ -78,7 +81,10 @@ fn roundtrip_fixpoint() { #[test] fn parse_errors_are_typed() { assert!(parse(r#""#).is_err(), "NaN rejected (N-2)"); - assert!(parse(r#""#).is_err(), "unknown attr rejected"); + assert!( + parse(r#""#).is_err(), + "unknown attr rejected" + ); assert!(parse(r#""#).is_err(), "shape without kind rejected"); assert!(parse(r#""#).is_err(), "bad anchor word"); } @@ -91,5 +97,9 @@ fn kind_defaults_in_ir() { let t = 1; assert_eq!(doc.get(t).header.width, SizeIntent::Auto); let l = 2; - assert_eq!(doc.get(l).header.height, SizeIntent::Fixed(0.0), "line h locked"); + assert_eq!( + doc.get(l).header.height, + SizeIntent::Fixed(0.0), + "line h locked" + ); } diff --git a/model-v2/a/lab/tests/visual_only.rs b/model-v2/a/lab/tests/visual_only.rs index 31dc6d652b..bda3485086 100644 --- a/model-v2/a/lab/tests/visual_only.rs +++ b/model-v2/a/lab/tests/visual_only.rs @@ -122,7 +122,11 @@ fn v2_no_inert_reports() { matches!(rep, Report::IgnoredByRule { field, .. } if *field == "grow" || *field == "self_align") }); - assert!(!inert, "nothing is ignored-for-rotation anymore: {:?}", r.reports); + assert!( + !inert, + "nothing is ignored-for-rotation anymore: {:?}", + r.reports + ); } // ── V-3: hug ignores transforms (flex AND free) ────────────────────── @@ -194,7 +198,14 @@ fn v4_group_box_is_unrotated_union() { let doc = b.build(); let r = run_default(&doc); // Sizing tier: the member's UNROTATED box. - assert_rect(r.box_of(g), 200.0, 100.0, 100.0, 20.0, "group box unrotated"); + assert_rect( + r.box_of(g), + 200.0, + 100.0, + 100.0, + 20.0, + "group box unrotated", + ); // Read tier: the ink is oriented and larger. let ink = r.aabb_of(c); let (ew, eh) = rotated_aabb_size(100.0, 20.0, 45.0); @@ -219,7 +230,11 @@ fn v4_group_flex_contribution_is_unrotated() { 10.0, ); let f = b.add(0, at(fh, 20.0, 20.0), fp); - let g = b.add(f, Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group); + let g = b.add( + f, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); let (mut m, mp) = shape(100.0, 20.0); m.rotation = 45.0; b.add(g, m, mp); @@ -356,7 +371,14 @@ fn v3v4_hug_of_group_of_rotated() { let r = run_default(&b.build()); // Hug wraps the group's SIZING box (unrotated union at its pins): // 15 + 100 wide, 25 + 20 tall — no envelope anywhere in sizing. - assert_rect(r.box_of(f), 10.0, 10.0, 115.0, 45.0, "hug of group of rotated"); + assert_rect( + r.box_of(f), + 10.0, + 10.0, + 115.0, + 45.0, + "hug of group of rotated", + ); // The ink still escapes, visibly, in the read tier. assert!(r.aabb_of(f).w > r.box_of(f).w - 1.0); } From d71693187b2335dda3f9894798e79a3850192eec Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 8 Jul 2026 17:51:14 +0900 Subject: [PATCH 06/33] =?UTF-8?q?feat(model-v2/engine):=20phase-4=20engine?= =?UTF-8?q?=20skeleton=20=E2=80=94=20pipeline=20+=20sockets,=20spike=20re-?= =?UTF-8?q?hosted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anchor-engine: document → resolve → drawlist → paint, plus the read tier (query), time-as-data (journal/replay), and the sockets every future optimization plugs into (damage, ident, oracle). It consumes anchor-lab as a library — the relationship the crates/grida migration will have with the model crate. Every ENG-0…ENG-5 contract has a code socket and a guarding test; skia is confined to paint.rs so a later core/paint split is mechanical. The spike is re-hosted onto it (proof, not just scaffolding): the scene painter is deleted → drawlist::build + paint::execute (4 goldens byte-identical, incl. the rotated and cross-zero-flip scenes); pick/hover via query; gesture ops via apply, recorded in the journal (undo stays document snapshots — ENG-5.5); `--record` writes .replay corpus. The scene-cache compositor (ENG-2) rasters once into a backend-matched offscreen and blits camera pans O(1), re-rastering only on doc-change / zoom / pan-beyond-margin. Live per-stage frame instrumentation (a glFinish GPU-vs-vsync split) plus render-on-demand (draw only on real input) land the felt-latency fix. gate (ENG-0.2 oracle law): shots 4/4 IDENTICAL · replays deterministic · scene-cache differential MATCHES fresh (integer pan) · bench within budgets. Docs: ENGINE.md SETUP ground contracts (S-1…S-7), engine README (contract → module → guarding-test map), model-v2 + a/README phase-4 status. --- model-v2/README.md | 2 +- model-v2/a/ENGINE.md | 69 ++ model-v2/a/README.md | 46 +- model-v2/a/spike-canvas/Cargo.lock | 11 + model-v2/a/spike-canvas/Cargo.toml | 3 + model-v2/a/spike-canvas/src/interaction.rs | 12 +- model-v2/a/spike-canvas/src/main.rs | 195 +++-- model-v2/a/spike-canvas/src/paint.rs | 140 +-- model-v2/a/spike-canvas/src/scene.rs | 23 +- model-v2/a/spike-canvas/src/shell/app.rs | 319 +++++-- model-v2/a/spike-canvas/src/shell/hud.rs | 29 +- model-v2/engine/.cargo/config.toml | 5 + model-v2/engine/Cargo.lock | 895 ++++++++++++++++++++ model-v2/engine/Cargo.toml | 46 + model-v2/engine/README.md | 78 ++ model-v2/engine/rig/baselines.json | 14 + model-v2/engine/rig/corpus/crosszero.replay | 31 + model-v2/engine/rig/corpus/rot45.replay | 31 + model-v2/engine/rig/corpus/ungroup.replay | 31 + model-v2/engine/src/bin/gate.rs | 349 ++++++++ model-v2/engine/src/cache.rs | 148 ++++ model-v2/engine/src/damage.rs | 78 ++ model-v2/engine/src/drawlist.rs | 188 ++++ model-v2/engine/src/frame.rs | 52 ++ model-v2/engine/src/ident.rs | 28 + model-v2/engine/src/journal.rs | 59 ++ model-v2/engine/src/lib.rs | 26 + model-v2/engine/src/oracle.rs | 25 + model-v2/engine/src/paint.rs | 127 +++ model-v2/engine/src/query.rs | 68 ++ model-v2/engine/src/replay.rs | 220 +++++ model-v2/engine/src/trace.rs | 60 ++ model-v2/engine/tests/cache.rs | 132 +++ model-v2/engine/tests/damage.rs | 78 ++ model-v2/engine/tests/drawlist.rs | 158 ++++ model-v2/engine/tests/ident.rs | 23 + model-v2/engine/tests/journal.rs | 34 + model-v2/engine/tests/query.rs | 99 +++ model-v2/engine/tests/replay.rs | 131 +++ 39 files changed, 3779 insertions(+), 284 deletions(-) create mode 100644 model-v2/engine/.cargo/config.toml create mode 100644 model-v2/engine/Cargo.lock create mode 100644 model-v2/engine/Cargo.toml create mode 100644 model-v2/engine/README.md create mode 100644 model-v2/engine/rig/baselines.json create mode 100644 model-v2/engine/rig/corpus/crosszero.replay create mode 100644 model-v2/engine/rig/corpus/rot45.replay create mode 100644 model-v2/engine/rig/corpus/ungroup.replay create mode 100644 model-v2/engine/src/bin/gate.rs create mode 100644 model-v2/engine/src/cache.rs create mode 100644 model-v2/engine/src/damage.rs create mode 100644 model-v2/engine/src/drawlist.rs create mode 100644 model-v2/engine/src/frame.rs create mode 100644 model-v2/engine/src/ident.rs create mode 100644 model-v2/engine/src/journal.rs create mode 100644 model-v2/engine/src/lib.rs create mode 100644 model-v2/engine/src/oracle.rs create mode 100644 model-v2/engine/src/paint.rs create mode 100644 model-v2/engine/src/query.rs create mode 100644 model-v2/engine/src/replay.rs create mode 100644 model-v2/engine/src/trace.rs create mode 100644 model-v2/engine/tests/cache.rs create mode 100644 model-v2/engine/tests/damage.rs create mode 100644 model-v2/engine/tests/drawlist.rs create mode 100644 model-v2/engine/tests/ident.rs create mode 100644 model-v2/engine/tests/journal.rs create mode 100644 model-v2/engine/tests/query.rs create mode 100644 model-v2/engine/tests/replay.rs diff --git a/model-v2/README.md b/model-v2/README.md index 2f2e4520fb..d8a20ab9c2 100644 --- a/model-v2/README.md +++ b/model-v2/README.md @@ -45,7 +45,7 @@ them — problems first, then candidates, then spec. | 1. Problems & harnesses | `problems.md`, `harnesses.md`, `study.md` | stable draft | | 2. Candidate models | `paradigm.md`, `axes.md`, `models/*`, `finale.md`, `triage.md` | **DECIDED — `anchor`** (+5 triage amendments) | | 3. Spec | normative doc + `grida.fbs` draft | **experiments RUN, model PROVEN** — E1–E10 complete with verdicts; **DEC-0 decided: VISUAL-ONLY rotation (the CSS framing), CSS-pure sizing** ([`a/dec0-visual-only.md`](./a/dec0-visual-only.md)); flips built (E-A14, cross-zero resize); 114-test lab; native interactive spike ([`a/spike-canvas/`](./a/spike-canvas/)); open calls parked in [`a/DECISIONS.md`](./a/DECISIONS.md). Remaining: fold deltas into a normative rewrite of `models/a.md` + WG graduation | -| 4. Runtime | `crates/grida` implementation | not started (the spike's [`TEXTBOOK.md`](./a/spike-canvas/TEXTBOOK.md) is the migration's reference) | +| 4. Runtime | `crates/grida` implementation | **day-1 engine skeleton BUILT** — [`engine/`](./engine) (`anchor-engine`): the `resolve → drawlist → paint` pipeline + query/journal/replay/damage sockets, spike re-hosted onto it, gate green (shots byte-identical, replay deterministic, budgets baselined); contracts in [`a/ENGINE.md`](./a/ENGINE.md). Migration into `crates/grida` follows; the spike's [`TEXTBOOK.md`](./a/spike-canvas/TEXTBOOK.md) is the reference | Ground rules: diff --git a/model-v2/a/ENGINE.md b/model-v2/a/ENGINE.md index 0c0625c1e0..19ca91098d 100644 --- a/model-v2/a/ENGINE.md +++ b/model-v2/a/ENGINE.md @@ -358,6 +358,75 @@ external pinning where a peer is the oracle (flex conformance pins to Chromium, never to Taffy — the E4 lesson `[MEASURED]`; `grida_wpt` is the in-repo precedent for the discipline `[MEASURED]`). +## SETUP — the ground contracts (S-1…S-7) + +The five layers above are the runtime; these are the ground they sit on +— the workspace shape, the boundaries, and the rig. A **day-1 skeleton +now implements them** ([`../engine/`](../engine) — `anchor-engine`, +consuming `anchor-lab`; the spike is re-hosted onto it and the gate is +green), so several of these are `[MEASURED]` rather than aspirational. + +- **S-1 · crate topology & the Stylo landing** `[CONTRACT]` — the model + crate (`anchor-lab` today) carries no host deps and compiles for every + target incl. server; the engine embeds into the host component-wise. + The one industry rewrite that shipped did it this way — Stylo and + WebRender landed _into_ Gecko; greenfield "new browser" rewrites did + not `[PEER]`. So the lab stays the greenfield core, kept + dependency-clean, and promotion to `crates/` is a `git mv`, not a + rewrite `[MEASURED]`. Phase-4 landing order: resolve+drawlist behind + the pipeline seam first, then pick/query, then interaction/journal — + each gated by shot+replay parity. The spike re-host **is** this + landing, rehearsed `[MEASURED]`. +- **S-2 · dependency-direction law** `[CONTRACT]` — arrows never + reverse: model (no skia/winit/wasm-bindgen) ← engine (model + skia; + `use skia_safe` confined to `paint.rs` + `bin/gate.rs`) ← hosts + (engine + winit/egui/GL). Compiler-enforced, not reviewer-enforced. + Proven: `anchor-lab` builds feature-less, `anchor-engine` adds only + skia, the spike holds all chrome `[MEASURED]`. +- **S-3 · one write door, one read door** `[CONTRACT]` — every mutation + is a typed `Op` through `apply` (the journal's unit); every read is + the resolved tier (via `query`) or the text IR. No tool reaches into + node fields. The legacy's three-answers-to-one-question split-brain is + what reach-ins grow into. Proven: the spike's interaction sites all + route through `apply`; pick/hover through `query` `[MEASURED]`. +- **S-4 · wasm-first mechanics** `[CONTRACT]` — the core is + single-thread-first (wasm threads need cross-origin isolation; + parallelism is a native bonus behind ENG-0.3, never load-bearing) + `[PEER]`; no interior mutability in the model/resolved tiers (value + semantics also make snapshot undo and replay cheap — the document is + one value `[MEASURED]`); the arena/SOA layout suits linear memory that + only grows `[PEER]`; the boundary is ops in, damage/query results out + (the C-ABI seam), never "JS reaches into engine memory." +- **S-5 · rig before engine** `[CONTRACT]` — the gate exists _before_ + the engine grows: shots byte-compare, replay determinism, resolve+build + budgets against a machine-tagged baseline. Pixel goldens raster on CPU + (GPU AA varies by machine — browsers golden on software raster + `[PEER]`). The first untested "temporary" optimization fails here. + Built with the skeleton (`bin/gate.rs`, green) `[MEASURED]`. +- **S-6 · gated observability** `[CONTRACT]` — per-stage spans/counters + compile to zero when off (the `trace` feature); the profiler must + never distort the profile (the legacy loop measured exactly this — + devtools cost-prediction polluted plan build until gated `[PRIOR]`). + Proven: `t_span!`/`t_count!` expand to the bare block with `trace` off + `[MEASURED]`. +- **S-7 · one shipping profile** `[CONTRACT]` — both DEC-0 arms stay + implemented and tested, but the engine ships ONE profile; arms and + flags exist for tests/studies only, and CI enumerates the supported + configuration set. The guard against the legacy's 26-arm branch forest + regrowing as feature flags — the lab pins both arms in tests while the + default resolves one `[MEASURED]`. + +Two one-liners that also belong on day 1: + +- **Format carries a version field from byte one** `[CONTRACT]` — the + refuse-newer posture is DEC-7's call; the field itself is not optional. + The `.replay` header already stamps magic + version, the pattern + rehearsed `[MEASURED]`. +- **DPR / pixel-snapping is a declared paint policy** `[CONTRACT]`, not + rounding scattered through code — the executor takes the view + transform and the host owns DPR (the spike passes `dpr` into the HUD; + the scene view composes it) `[MEASURED]`. + ## Scope fence (named, not silent) - **Above Skia.** Raster stays Skia; this document pioneers the diff --git a/model-v2/a/README.md b/model-v2/a/README.md index 044c777527..4af4cc9697 100644 --- a/model-v2/a/README.md +++ b/model-v2/a/README.md @@ -7,7 +7,7 @@ This directory hosts the experiments, spikes, and spec drafts for top to bottom and you have working state. > **2026-07-07 — the ledger has been RUN.** E1–E7 and E10 are complete with -> verdicts; the lab implementation lives in [`lab/`](./lab) (**114** conformance-derived tests green). Start at **[`REPORT.md`](./REPORT.md)** +> verdicts; the lab implementation lives in [`lab/`](./lab) (**121** conformance-derived tests green — 114 model + 7 engine-setup seams). Start at **[`REPORT.md`](./REPORT.md)** > — win/lose/lessons-learnt and the fourteen spec deltas (E-A1…E-A14 + Taffy > guards). The consolidated model statement — the phase-3 seed — is > **[`MODEL.md`](./MODEL.md)**. @@ -65,29 +65,31 @@ One subdirectory per experiment (`e1-…/`), each with its own README Code: small standalone crates/scripts inside the experiment dir, **not** wired into the workspace; promote into `crates/`/`format/` only at phase 4. -| id | experiment | status (2026-07-07) | -| --------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **E1** | rotation-in-flow prototype | **DONE — layout-visible locked** (0 overlap vs 1,830 px²; smooth within analytic bound; breathing confirms two-lane) → [verdict](./e1-rotation-in-flow/verdict.md), [demo.html](./e1-rotation-in-flow/demo.html) | -| **E2** | fbs schema draft + codec round-trip | **DONE — pass** (S-1 byte-fixpoint, H11 structural unset witnessed, M-4 both directions; RMW policy owed) → [verdict](./e2-format/README.md) | -| **E3** | agent text IR | **DONE — validated** (frontier 22/22 cold prediction ×2; grammar + probes + scorer reusable) → [verdict](./e3-text-ir/verdict.md) | -| **E4** | resolver spike | **DONE — viable** (10k/5.4 ms; linear; 18 µs locality bound; 2 Taffy findings) → [verdict](./e4-resolver/verdict.md) | -| **E5** | SVG import corpus measurement | **DONE — quarantine + native-flip amendment** (1M transforms: shear 0.95%, flip 26.1%) → [verdict](./e5-svg-corpus/verdict.md) | -| **E6** | pluggable-layout sketch | **DONE — non-binding** (the contributions→slots seam exists; keep core at two modes) → [sketch](./e6-pluggable-layout/SKETCH.md) | -| **E7** | shape-vs-layout / points / two scales (owner question) | **DONE — research verdict** (XYWH-vs-shape solved by design; E-A9 vector reference-space; E-A10 two-scales rule; Graphics container rejected-as-requirement) → [findings](./e7-shape-points-scale/README.md) | -| **E-rev** | pedantic panel (bedrock/UX/eng/market) + edge sweep | **DONE** — core survives; 7 MODEL.md corrections applied; E-A11/E-A12 minted (grow/stretch × rotation pops, machine-verified); E8 (CSS projection) + E9 (Figma corpus) opened → [pedantic-review.md](./pedantic-review.md), [edge-cases/](./edge-cases) | -| **GROUP** | what a group is (owner question) | **DONE** — definition locked (“a named set with a coordinate space — nothing else”); E-A13 proposed (constraint pass-through, acyclic via E-A1); Figma semantics verified from primary sources → [`GROUP.md`](./GROUP.md) | -| **E-census** | edge census across all kinds + import maps | **DONE** — [`LIMITS.md`](./LIMITS.md) (kind×mechanism matrix, 5 bugs found+fixed, 5 blockers, the 🔶 punch list); [`COMPAT.md`](./COMPAT.md) (css/svg/figma → grida maps + harness clause H13); lab at **81 tests** (92 after the flip arm) | -| **E-flip** | cross-zero resize + flip semantics (owner red flag) | **BUILT** — E-A14: flip live in the lab (pivot per kind, `T·R·F` innermost, layout-invisible, σ-exact ungroup bake, NegativeExtent wall, `resize_drag` out-and-back identity); 11 tests; three-policy demo scene in [`edge-cases/`](./edge-cases); gesture policy = DEC-9 | -| **DECISIONS** | the open owner-call register | **DEC-0 DECIDED: visual-only (owner framing; second lock)** — [`DECISIONS.md`](./DECISIONS.md) + [`dec0-visual-only.md`](./dec0-visual-only.md); DEC-1/2/3 closed n/a by it · DEC-4 text determinism · DEC-5 f32 budget · DEC-6 bool bounds · DEC-7 format RMW · DEC-8 E8/E9 · DEC-9 wall/slide/flip · DEC-10 lens naming — answer in one pass; adopted-by-evidence appendix carries the veto window | -| **DEC-0 fork demo** | see the framing fork, not guess it | **BUILT** — [`dec0-fork/`](./dec0-fork): seven scenes × two framings side by side (`lab/src/bin/fork.rs`, real resolver both arms), live overlap / container-breathing / ink-escape meters; the two-lane asymmetry scene (header-rot vs lens-rot) | -| **DEC-0 flip** | the second lock: visual-only default (CSS framing, CSS-pure sizing) | **DONE** — [`dec0-visual-only.md`](./dec0-visual-only.md) (rules V-1…V-10; the V-4 group-box fork was real UB, decided sizing-tier); lab default flipped, hug/union CSS-pure behind the flag; `tests/visual_only.rs` (+14, suite **114**); E-A4/7/8/11/12 retired; spike re-cut (ink-bounds chrome, shots regenerated) | -| **free-editing demo** | the base case, hands on (owner question: "without layout, how does it work?") | **BUILT** — [`free-editing/`](./free-editing): live free-context mini-editor (JS mirror of the free rules — no Taffy, which is the point); live canonical IR, per-gesture write counts incl. typed errors, 0-write artboard responsiveness | -| **E10** | the feel spike: does the model drive a REAL editor? (owner: feel it + textbook) | **BUILT** — [`spike-canvas/`](./spike-canvas): native winit+Skia app on the lab (`cargo run --release`); resolve-per-frame thesis MEASURED (starter frame 0.17 ms; 10k nodes paint-bound at 9 ms); arena+SOA storage evolution in the lab (**up to 11.5× resolver speedup**, 100 tests); interaction FSM, HUD w/ E-A7 readout, cross-zero flip gesture, undo, live+editable IR panel, reports-as-badges; [`TEXTBOOK.md`](./spike-canvas/TEXTBOOK.md) + [`SPIKE.md`](./spike-canvas/SPIKE.md) | -| **ENGINE** | the phase-4 layer programs (owner: browser/game-engine techniques from day 1) | **DRAFTED 2026-07-08** — [`ENGINE.md`](./ENGINE.md): major contracts ENG-0…ENG-5 (stage purity + the oracle law · incremental resolve & damage · retained paint · spatial read tier · deterministic content oracles · time-as-data), growth paths where every stage keeps every contract, open studies OS-\*; every claim validity-tagged (`[MEASURED]`/`[PRIOR]`/`[PEER]`/`[CONTRACT]`/`[OPEN]`) — decision-independent sockets, does not pre-empt DEC-4/5/6 | +| id | experiment | status (2026-07-07) | +| --------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **E1** | rotation-in-flow prototype | **DONE — layout-visible locked** (0 overlap vs 1,830 px²; smooth within analytic bound; breathing confirms two-lane) → [verdict](./e1-rotation-in-flow/verdict.md), [demo.html](./e1-rotation-in-flow/demo.html) | +| **E2** | fbs schema draft + codec round-trip | **DONE — pass** (S-1 byte-fixpoint, H11 structural unset witnessed, M-4 both directions; RMW policy owed) → [verdict](./e2-format/README.md) | +| **E3** | agent text IR | **DONE — validated** (frontier 22/22 cold prediction ×2; grammar + probes + scorer reusable) → [verdict](./e3-text-ir/verdict.md) | +| **E4** | resolver spike | **DONE — viable** (10k/5.4 ms; linear; 18 µs locality bound; 2 Taffy findings) → [verdict](./e4-resolver/verdict.md) | +| **E5** | SVG import corpus measurement | **DONE — quarantine + native-flip amendment** (1M transforms: shear 0.95%, flip 26.1%) → [verdict](./e5-svg-corpus/verdict.md) | +| **E6** | pluggable-layout sketch | **DONE — non-binding** (the contributions→slots seam exists; keep core at two modes) → [sketch](./e6-pluggable-layout/SKETCH.md) | +| **E7** | shape-vs-layout / points / two scales (owner question) | **DONE — research verdict** (XYWH-vs-shape solved by design; E-A9 vector reference-space; E-A10 two-scales rule; Graphics container rejected-as-requirement) → [findings](./e7-shape-points-scale/README.md) | +| **E-rev** | pedantic panel (bedrock/UX/eng/market) + edge sweep | **DONE** — core survives; 7 MODEL.md corrections applied; E-A11/E-A12 minted (grow/stretch × rotation pops, machine-verified); E8 (CSS projection) + E9 (Figma corpus) opened → [pedantic-review.md](./pedantic-review.md), [edge-cases/](./edge-cases) | +| **GROUP** | what a group is (owner question) | **DONE** — definition locked (“a named set with a coordinate space — nothing else”); E-A13 proposed (constraint pass-through, acyclic via E-A1); Figma semantics verified from primary sources → [`GROUP.md`](./GROUP.md) | +| **E-census** | edge census across all kinds + import maps | **DONE** — [`LIMITS.md`](./LIMITS.md) (kind×mechanism matrix, 5 bugs found+fixed, 5 blockers, the 🔶 punch list); [`COMPAT.md`](./COMPAT.md) (css/svg/figma → grida maps + harness clause H13); lab at **81 tests** (92 after the flip arm) | +| **E-flip** | cross-zero resize + flip semantics (owner red flag) | **BUILT** — E-A14: flip live in the lab (pivot per kind, `T·R·F` innermost, layout-invisible, σ-exact ungroup bake, NegativeExtent wall, `resize_drag` out-and-back identity); 11 tests; three-policy demo scene in [`edge-cases/`](./edge-cases); gesture policy = DEC-9 | +| **DECISIONS** | the open owner-call register | **DEC-0 DECIDED: visual-only (owner framing; second lock)** — [`DECISIONS.md`](./DECISIONS.md) + [`dec0-visual-only.md`](./dec0-visual-only.md); DEC-1/2/3 closed n/a by it · DEC-4 text determinism · DEC-5 f32 budget · DEC-6 bool bounds · DEC-7 format RMW · DEC-8 E8/E9 · DEC-9 wall/slide/flip · DEC-10 lens naming — answer in one pass; adopted-by-evidence appendix carries the veto window | +| **DEC-0 fork demo** | see the framing fork, not guess it | **BUILT** — [`dec0-fork/`](./dec0-fork): seven scenes × two framings side by side (`lab/src/bin/fork.rs`, real resolver both arms), live overlap / container-breathing / ink-escape meters; the two-lane asymmetry scene (header-rot vs lens-rot) | +| **DEC-0 flip** | the second lock: visual-only default (CSS framing, CSS-pure sizing) | **DONE** — [`dec0-visual-only.md`](./dec0-visual-only.md) (rules V-1…V-10; the V-4 group-box fork was real UB, decided sizing-tier); lab default flipped, hug/union CSS-pure behind the flag; `tests/visual_only.rs` (+14, suite **114**); E-A4/7/8/11/12 retired; spike re-cut (ink-bounds chrome, shots regenerated) | +| **free-editing demo** | the base case, hands on (owner question: "without layout, how does it work?") | **BUILT** — [`free-editing/`](./free-editing): live free-context mini-editor (JS mirror of the free rules — no Taffy, which is the point); live canonical IR, per-gesture write counts incl. typed errors, 0-write artboard responsiveness | +| **E10** | the feel spike: does the model drive a REAL editor? (owner: feel it + textbook) | **BUILT** — [`spike-canvas/`](./spike-canvas): native winit+Skia app on the lab (`cargo run --release`); resolve-per-frame thesis MEASURED (starter frame 0.17 ms; 10k nodes paint-bound at 9 ms); arena+SOA storage evolution in the lab (**up to 11.5× resolver speedup**, 100 tests); interaction FSM, HUD w/ E-A7 readout, cross-zero flip gesture, undo, live+editable IR panel, reports-as-badges; [`TEXTBOOK.md`](./spike-canvas/TEXTBOOK.md) + [`SPIKE.md`](./spike-canvas/SPIKE.md) | +| **ENGINE** | the phase-4 layer programs (owner: browser/game-engine techniques from day 1) | **BUILT 2026-07-08** — [`ENGINE.md`](./ENGINE.md) (contracts ENG-0…ENG-5 + SETUP S-1…S-7, every claim validity-tagged) + a **day-1 skeleton** at [`engine/`](../engine) (`anchor-engine`): `resolve → drawlist → paint` pipeline, spatial `query`, `journal`/`replay`, `damage`, generational `ident`, `oracle` tags, `trace`; the spike is **re-hosted** onto it and the gate is green — 4 shots byte-identical, 3 replays deterministic, resolve/build budgets baselined ([`engine/bin/gate.rs`](../engine/src/bin/gate.rs)). Lab grew the model-crate seams (`Op`/`apply`/`DirtyClass`, generations, opt accessors, `serde`); suite **121** | All verdicts + the spec deltas roll up in [`REPORT.md`](./REPORT.md). -Implementation: [`lab/`](./lab) — standalone crate, `cargo test` (114 -green), bins `e1`, `e3`, `e4`, `e5scan`, `edge`, `fork`. +Implementation: [`lab/`](./lab) — standalone crate, `cargo test` (121 +green), bins `e1`, `e3`, `e4`, `e5scan`, `edge`, `fork`. The phase-4 +engine skeleton is [`engine/`](../engine) (`anchor-engine`), the spike +re-hosted onto it — see [`ENGINE.md`](./ENGINE.md). ## Phase-3 definition of done diff --git a/model-v2/a/spike-canvas/Cargo.lock b/model-v2/a/spike-canvas/Cargo.lock index 779c8670be..820cf0fed8 100644 --- a/model-v2/a/spike-canvas/Cargo.lock +++ b/model-v2/a/spike-canvas/Cargo.lock @@ -55,11 +55,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "anchor-engine" +version = "0.0.0" +dependencies = [ + "anchor-lab", + "serde_json", + "skia-safe", +] + [[package]] name = "anchor-lab" version = "0.0.0" dependencies = [ "quick-xml 0.37.5", + "serde", "taffy", ] @@ -67,6 +77,7 @@ dependencies = [ name = "anchor-spike" version = "0.0.0" dependencies = [ + "anchor-engine", "anchor-lab", "egui", "egui-winit", diff --git a/model-v2/a/spike-canvas/Cargo.toml b/model-v2/a/spike-canvas/Cargo.toml index 3d277b1d72..81c7528c53 100644 --- a/model-v2/a/spike-canvas/Cargo.toml +++ b/model-v2/a/spike-canvas/Cargo.toml @@ -11,6 +11,9 @@ description = "E10 — the feel spike: a native Skia canvas driven by the anchor [dependencies] anchor-lab = { path = "../lab" } +# The engine this spike is being re-hosted onto (ENGINE.md). Same skia +# version/features, so the shared target dir stays a single build. +anchor-engine = { path = "../../engine" } # Shell deps: versions matched to crates/grida_dev / crates/grida_editor # (the prior-art winit hosts) so the shared target dir can reuse the diff --git a/model-v2/a/spike-canvas/src/interaction.rs b/model-v2/a/spike-canvas/src/interaction.rs index 4ec2157721..87cc2eb273 100644 --- a/model-v2/a/spike-canvas/src/interaction.rs +++ b/model-v2/a/spike-canvas/src/interaction.rs @@ -124,10 +124,18 @@ fn fmt_size(s: &SizeIntent) -> String { pub fn diff_header(before: &Header, after: &Header) -> Vec { let mut out = Vec::new(); if before.x != after.x { - out.push(format!("x: {} -> {}", fmt_binding(&before.x), fmt_binding(&after.x))); + out.push(format!( + "x: {} -> {}", + fmt_binding(&before.x), + fmt_binding(&after.x) + )); } if before.y != after.y { - out.push(format!("y: {} -> {}", fmt_binding(&before.y), fmt_binding(&after.y))); + out.push(format!( + "y: {} -> {}", + fmt_binding(&before.y), + fmt_binding(&after.y) + )); } if before.width != after.width { out.push(format!( diff --git a/model-v2/a/spike-canvas/src/main.rs b/model-v2/a/spike-canvas/src/main.rs index a4ebd2612a..74d64be312 100644 --- a/model-v2/a/spike-canvas/src/main.rs +++ b/model-v2/a/spike-canvas/src/main.rs @@ -12,10 +12,10 @@ mod paint; mod scene; mod shell; -use anchor_lab::model::Document; -use anchor_lab::resolve::{resolve, Resolved, ResolveOptions}; +use anchor_lab::model::{Document, NodeId}; +use anchor_lab::ops::Op; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved}; use camera::Camera; -use paint::Painter; /// The resolve viewport — the root frame spans it; think "world extent /// of the infinite canvas' initial container", not the window. @@ -35,10 +35,25 @@ fn main() { let args: Vec = std::env::args().collect(); if let Some(i) = args.iter().position(|a| a == "--shot") { let path = args.get(i + 1).cloned().unwrap_or("spike.png".into()); - let state = args.get(i + 2).cloned().unwrap_or_default(); + // The state arg is positional but optional; a following flag is not it. + let state = args + .get(i + 2) + .filter(|s| !s.starts_with("--")) + .cloned() + .unwrap_or_default(); shot(&path, &state); return; } + if let Some(i) = args.iter().position(|a| a == "--record") { + let path = args.get(i + 1).cloned().unwrap_or("out.replay".into()); + let state = args + .get(i + 2) + .filter(|s| !s.starts_with("--")) + .cloned() + .unwrap_or_default(); + record(&path, &state); + return; + } if args.iter().any(|a| a == "--bench") { bench(); return; @@ -46,21 +61,18 @@ fn main() { shell::run(); } -/// Headless render: paint the starter scene (after an optional scripted -/// state) into a raster surface and encode PNG — no window, no GL. -fn shot(path: &str, state: &str) { - let (mut doc, artboard) = scene::starter(); - let mut selection: Option = None; - +/// A scripted state as a typed op sequence — the SAME writes the gesture would +/// issue, expressed as data so both `--shot` (apply + render) and `--record` +/// (serialize to a `.replay`) share one definition. Ops address nodes by name +/// so they survive the recorder's `parse(print(doc))` normalization. +fn state_ops(doc: &Document, state: &str) -> Vec { match state { "crosszero" => { - // Drag card.a's right edge THROUGH the left edge: |extent| + - // flip toggle + re-pin (E-A14). Scripted exactly as the - // gesture would issue it. - let id = find(&doc, "card.a"); - let r = resolve_doc(&doc); + // Drag card.a's right edge THROUGH the left edge (E-A14). + let id = find(doc, "card.a"); + let r = resolve_doc(doc); let drag = anchor_lab::ops::ResizeDrag::begin( - &doc, + doc, &r, id, anchor_lab::ops::Axis::X, @@ -68,29 +80,70 @@ fn shot(path: &str, state: &str) { ) .unwrap(); let b = r.box_of(id); - let target = b.x + b.w + 50.0; // 50px past the FIXED right edge - let r = resolve_doc(&doc); - anchor_lab::ops::resize_drag(&mut doc, &r, id, &drag, target).unwrap(); - selection = Some(id); - } - "ungroup" => { - let id = find(&doc, "chips"); - let r = resolve_doc(&doc); - anchor_lab::ops::ungroup(&mut doc, &r, id).unwrap(); - } - "rot45" => { - let id = find(&doc, "card.rot"); - anchor_lab::ops::set_rotation(&mut doc, id, 45.0).unwrap(); - selection = Some(id); - } - _ => { - selection = Some(find(&doc, "card.rot")); + vec![Op::ResizeDrag { + id, + drag, + target: b.x + b.w + 50.0, // 50px past the FIXED right edge + }] } + "ungroup" => vec![Op::Ungroup { + id: find(doc, "chips"), + }], + "rot45" => vec![Op::SetRotation { + id: find(doc, "card.rot"), + deg: 45.0, + }], + _ => vec![], + } +} + +/// The HUD selection a shot shows for a state (chrome only). +fn shot_selection(doc: &Document, state: &str) -> Option { + match state { + "crosszero" => Some(find(doc, "card.a")), + "ungroup" => None, + _ => Some(find(doc, "card.rot")), // rot45 and default } +} + +/// Record the starter + a scripted state as a `.replay` file (ENG-5.2). The +/// document is normalized so recorded ids are the parse-assigned ones; ops are +/// built against the normalized doc (by name), so the file is self-consistent. +fn record(path: &str, state: &str) { + let (starter, _) = scene::starter(); + let doc = anchor_lab::textir::parse(&anchor_lab::textir::print(&starter)).expect("normalize"); + let ops = state_ops(&doc, state); + let opts = ResolveOptions { + viewport: RESOLVE_VIEWPORT, + ..Default::default() + }; + let text = anchor_engine::replay::write_string( + &doc, + &ops, + &anchor_engine::oracle::OracleTags::default(), + &opts, + ); + std::fs::write(path, text).expect("write replay"); + println!("recorded {} op(s) to {path}", ops.len()); +} + +/// Headless render: paint the starter scene (after an optional scripted +/// state) into a raster surface and encode PNG — no window, no GL. The SCENE +/// is painted by the engine pipeline (`drawlist::build` -> `paint::execute`); +/// the HUD is spike chrome on top. These shots are the gate's goldens. +fn shot(path: &str, state: &str) { + let (mut doc, artboard) = scene::starter(); + + // Apply the scripted state as typed ops (byte-identical to the free-fn + // script — apply is pure dispatch), each with a fresh resolve. + for op in state_ops(&doc, state) { + let r = resolve_doc(&doc); + anchor_lab::ops::apply(&mut doc, &r, &op).expect("scripted op"); + } + let selection = shot_selection(&doc, state); let (w, h) = (1360, 900); - let mut surface = - skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); let resolved = resolve_doc(&doc); let mut cam = Camera::new(); let ab = resolved.aabb_of(artboard); @@ -98,9 +151,10 @@ fn shot(path: &str, state: &str) { let canvas = surface.canvas(); canvas.clear(skia_safe::Color::from_argb(255, 0xF7, 0xF8, 0xF9)); - let painter = Painter::new(); - painter.paint_scene(canvas, &doc, &resolved, &cam); - shell::hud::paint_hud(canvas, &doc, &resolved, &cam, selection, None, &painter); + let ctx = paint::paint_ctx(); + let list = anchor_engine::drawlist::build(&doc, &resolved); + anchor_engine::paint::execute(canvas, &list, &cam.view(), &ctx); + shell::hud::paint_hud(canvas, &doc, &resolved, &cam, selection, None, &ctx); let image = surface.image_snapshot(); let data = image @@ -114,46 +168,61 @@ fn find(doc: &Document, name: &str) -> anchor_lab::model::NodeId { scene::find_named(doc, name).expect("named node") } -/// Resolve + paint wall time on raster, starter scene and synthetic -/// flat canvases — the seam numbers phase 4 wants (native, no boundary). +/// Per-stage frame cost on raster, across scene sizes. Separates the three +/// pipeline stages — resolve (lab) / drawlist build (engine, CPU) / skia +/// execute (the draw calls) — so the paint wall is visible, and packs each +/// heavy scene so every node is ON the surface (the honest all-visible worst +/// case; real editing culls to the viewport). fps is against the 120fps +/// (8.33ms) budget. Every frame here is a FULL redraw — which is exactly the +/// architecture problem: a pan changes nothing in the document yet pays all +/// three stages, and a one-node mutation pays the whole scene's execute. fn bench() { use anchor_lab::model::*; use std::time::Instant; - let mut surface = skia_safe::surfaces::raster_n32_premul((1360, 900)).unwrap(); - let painter = Painter::new(); - let cam = Camera::new(); + const W: i32 = 1360; + const H: i32 = 900; + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).unwrap(); + let ctx = paint::paint_ctx(); + let view = Camera::new().view(); let mut run = |label: &str, doc: &Document| { - let mut resolve_ms = f64::MAX; - let mut paint_ms = f64::MAX; + let (mut resolve_ms, mut build_ms, mut exec_ms) = (f64::MAX, f64::MAX, f64::MAX); for _ in 0..11 { let t0 = Instant::now(); let resolved = resolve_doc(doc); let t1 = Instant::now(); + let list = anchor_engine::drawlist::build(doc, &resolved); + let t2 = Instant::now(); let canvas = surface.canvas(); canvas.clear(skia_safe::Color::WHITE); - painter.paint_scene(canvas, doc, &resolved, &cam); - let t2 = Instant::now(); - resolve_ms = resolve_ms.min((t1 - t0).as_secs_f64() * 1000.0); - paint_ms = paint_ms.min((t2 - t1).as_secs_f64() * 1000.0); + anchor_engine::paint::execute(canvas, &list, &view, &ctx); + let t3 = Instant::now(); + let ms = |a: Instant, b: Instant| (b - a).as_secs_f64() * 1000.0; + resolve_ms = resolve_ms.min(ms(t0, t1)); + build_ms = build_ms.min(ms(t1, t2)); + exec_ms = exec_ms.min(ms(t2, t3)); } + let frame = resolve_ms + build_ms + exec_ms; println!( - "{label:26} {:5} nodes resolve {resolve_ms:7.3} ms paint {paint_ms:7.3} ms frame {:7.3} ms", + "{label:20} {:>7} nodes resolve {resolve_ms:8.3} build {build_ms:8.3} execute {exec_ms:8.3} frame {frame:8.3} ms {:6.1} fps", doc.len(), - resolve_ms + paint_ms + 1000.0 / frame ); }; - let (starter, _) = scene::starter(); - run("starter scene", &starter); - - for n in [100usize, 1000, 10000] { + // Pack n nodes into the W×H surface so all are visible (worst case). + let packed = |n: usize| -> Document { + let cols = ((n as f32 * W as f32 / H as f32).sqrt().ceil() as usize).max(1); + let cell = W as f32 / cols as f32; let mut b = DocBuilder::new(); for i in 0..n { - let mut h = Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(28.0)); - h.x = AxisBinding::start((i % 100) as f32 * 19.0); - h.y = AxisBinding::start((i / 100) as f32 * 13.0); + let mut h = Header::new( + SizeIntent::Fixed((cell * 0.8).max(1.0)), + SizeIntent::Fixed((cell * 0.8).max(1.0)), + ); + h.x = AxisBinding::start((i % cols) as f32 * cell); + h.y = AxisBinding::start((i / cols) as f32 * cell); h.rotation = (i % 7) as f32 * 5.0; b.add( 0, @@ -163,7 +232,13 @@ fn bench() { }, ); } - let doc = b.build(); - run(&format!("flat canvas ({n})"), &doc); + b.build() + }; + + println!("== full-redraw cost (raster, min of 11); 120fps budget = 8.33 ms =="); + let (starter, _) = scene::starter(); + run("starter", &starter); + for n in [1_000usize, 10_000, 50_000, 100_000] { + run(&format!("packed {n}"), &packed(n)); } } diff --git a/model-v2/a/spike-canvas/src/paint.rs b/model-v2/a/spike-canvas/src/paint.rs index 039ed914d5..19a8925bb9 100644 --- a/model-v2/a/spike-canvas/src/paint.rs +++ b/model-v2/a/spike-canvas/src/paint.rs @@ -1,128 +1,16 @@ -//! Scene painter: resolved SOA columns → Skia canvas. Dumb by design — -//! every world transform comes from the resolver (lens children arrive -//! post-ops), the camera is one outer matrix, and nothing here writes. -//! -//! Declared spike limitation: text renders single-line via the default -//! typeface while the MODEL measures with the lab stub — the mismatch is -//! DEC-4/B-1 territory and stays visible, not patched over. - -use anchor_lab::math::Affine; -use anchor_lab::model::*; -use anchor_lab::resolve::Resolved; -use skia_safe::{Canvas, Color, Font, FontMgr, Matrix, Paint, PaintStyle, Rect}; - -use crate::camera::Camera; - -pub struct Painter { - pub font: Option, -} - -impl Painter { - pub fn new() -> Painter { - Painter { - font: FontMgr::new().legacy_make_typeface(None, skia_safe::FontStyle::default()), - } - } - - pub fn skia_matrix(t: &Affine) -> Matrix { - Matrix::new_all(t.a, t.c, t.e, t.b, t.d, t.f, 0.0, 0.0, 1.0) - } - - pub fn color(hex: Option<&str>, fallback: Color) -> Color { - let Some(h) = hex else { return fallback }; - let h = h.trim_start_matches('#'); - if h.len() == 6 { - if let Ok(v) = u32::from_str_radix(h, 16) { - return Color::from_argb( - 255, - ((v >> 16) & 0xff) as u8, - ((v >> 8) & 0xff) as u8, - (v & 0xff) as u8, - ); - } - } - fallback - } - - pub fn paint_scene( - &self, - canvas: &Canvas, - doc: &Document, - resolved: &Resolved, - camera: &Camera, - ) { - self.paint_node(canvas, doc, resolved, camera, doc.root); - } - - fn paint_node( - &self, - canvas: &Canvas, - doc: &Document, - resolved: &Resolved, - camera: &Camera, - id: NodeId, - ) { - let Some(world) = resolved.world_opt(id) else { - return; // hidden subtree - }; - let node = doc.get(id); - let total = camera.view().then(&world); - - // Root is the infinite canvas backdrop — no ink of its own here. - if id != doc.root && !node.payload.box_is_derived() { - canvas.save(); - canvas.set_matrix(&Self::skia_matrix(&total).into()); - let b = resolved.box_of(id); - match &node.payload { - Payload::Frame { .. } => { - let mut fill = Paint::default(); - fill.set_anti_alias(true); - fill.set_color(Self::color(node.fill.as_deref(), Color::WHITE)); - let r = Rect::from_wh(b.w, b.h); - canvas.draw_rect(r, &fill); - let mut stroke = Paint::default(); - stroke.set_anti_alias(true); - stroke.set_style(PaintStyle::Stroke); - stroke.set_stroke_width(1.0); - stroke.set_color(Color::from_argb(255, 0xC9, 0xCE, 0xD4)); - canvas.draw_rect(r, &stroke); - } - Payload::Shape { desc } => { - let mut p = Paint::default(); - p.set_anti_alias(true); - p.set_color(Self::color(node.fill.as_deref(), Color::from_argb(255, 0x4A, 0x90, 0xD9))); - match desc { - ShapeDesc::Rect => { - canvas.draw_rect(Rect::from_wh(b.w, b.h), &p); - } - ShapeDesc::Ellipse => { - canvas.draw_oval(Rect::from_wh(b.w, b.h), &p); - } - ShapeDesc::Line => { - p.set_style(PaintStyle::Stroke); - p.set_stroke_width(2.0); - canvas.draw_line((0.0, 0.0), (b.w, 0.0), &p); - } - } - } - Payload::Text { content, font_size } => { - if let Some(tf) = &self.font { - let font = Font::new(tf.clone(), *font_size); - let mut p = Paint::default(); - p.set_anti_alias(true); - p.set_color(Self::color(node.fill.as_deref(), Color::BLACK)); - // Baseline approximation against the lab's box - // metric (top-anchored, 1.2em line) — declared. - canvas.draw_str(content.as_str(), (0.0, font_size * 0.85), &font, &p); - } - } - Payload::Group | Payload::Lens { .. } => unreachable!(), - } - canvas.restore(); - } - - for &c in &node.children { - self.paint_node(canvas, doc, resolved, camera, c); - } +//! Typeface acquisition for the spike. The SCENE is painted by the engine +//! (`anchor_engine::paint::execute` via `drawlist::build`) — the spike no +//! longer owns a scene painter. This module only supplies the resolved +//! typeface that the engine executor and the HUD text share, as an engine +//! [`PaintCtx`]. + +use anchor_engine::paint::PaintCtx; + +/// The default paint context: the platform's legacy default typeface (the +/// same face the pre-engine painter used), or `None` — in which case text is +/// skipped, exactly as before. +pub fn paint_ctx() -> PaintCtx { + PaintCtx { + font: skia_safe::FontMgr::new().legacy_make_typeface(None, skia_safe::FontStyle::default()), } } diff --git a/model-v2/a/spike-canvas/src/scene.rs b/model-v2/a/spike-canvas/src/scene.rs index 168ce0b3ba..0e78e7cc9a 100644 --- a/model-v2/a/spike-canvas/src/scene.rs +++ b/model-v2/a/spike-canvas/src/scene.rs @@ -129,7 +129,10 @@ pub fn starter() -> (Document, NodeId) { // Span bar: stretches with the artboard, x is owned by the Span // (dragging it sideways is a TYPED error, visible in the log). - let mut bar = named(Header::new(SizeIntent::Auto, SizeIntent::Fixed(16.0)), "bar"); + let mut bar = named( + Header::new(SizeIntent::Auto, SizeIntent::Fixed(16.0)), + "bar", + ); bar.x = AxisBinding::Span { start: 32.0, end: 32.0, @@ -142,21 +145,33 @@ pub fn starter() -> (Document, NodeId) { let grp = b.add( artboard, named( - at(Header::new(SizeIntent::Auto, SizeIntent::Auto), 620.0, 240.0), + at( + Header::new(SizeIntent::Auto, SizeIntent::Auto), + 620.0, + 240.0, + ), "chips", ), Payload::Group, ); let g1 = b.add(grp, named(card(56.0, 36.0), "chip.a"), rect()); b.node_mut(g1).fill = Some("#8B7BD8".into()); - let g2 = b.add(grp, named(at(card(56.0, 36.0), 26.0, 46.0), "chip.b"), rect()); + let g2 = b.add( + grp, + named(at(card(56.0, 36.0), 26.0, 46.0), "chip.b"), + rect(), + ); b.node_mut(g2).fill = Some("#E2574C".into()); // Lens: the paint lane — rotates visually, layout-transparent. let lens = b.add( artboard, named( - at(Header::new(SizeIntent::Auto, SizeIntent::Auto), 620.0, 380.0), + at( + Header::new(SizeIntent::Auto, SizeIntent::Auto), + 620.0, + 380.0, + ), "lens", ), Payload::Lens { diff --git a/model-v2/a/spike-canvas/src/shell/app.rs b/model-v2/a/spike-canvas/src/shell/app.rs index 1dfd3032b0..b47c433857 100644 --- a/model-v2/a/spike-canvas/src/shell/app.rs +++ b/model-v2/a/spike-canvas/src/shell/app.rs @@ -5,9 +5,11 @@ use std::num::NonZeroU32; +use anchor_engine::journal::Journal; +use anchor_engine::query::Query; use anchor_lab::model::{Document, NodeId}; -use anchor_lab::ops::{self, Axis, ResizeDrag}; -use anchor_lab::pick::pick; +use anchor_lab::ops::{self, Axis, Op, ResizeDrag}; +use anchor_lab::resolve::Resolved; use glutin::prelude::GlSurface; use winit::application::ApplicationHandler; use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; @@ -20,9 +22,10 @@ use crate::interaction::{ box_center_screen, diff_header, dist, handle_at, parent_point, resize_anchors, screen_angle, Drag, Fsm, Gesture, LogEntry, DRAG_THRESHOLD, }; -use crate::paint::Painter; +use crate::paint::paint_ctx; use crate::shell::hud::{self, HandleKind}; use crate::{resolve_doc, scene}; +use anchor_engine::paint::PaintCtx; pub struct App { // document + editor state @@ -33,6 +36,9 @@ pub struct App { pub undo: Vec, pub redo: Vec, pub log: Vec, + /// The session op-log (ENG-5.1) — every gesture recorded as typed ops for + /// replay. Records only; undo remains the snapshot stacks above (ENG-5.5). + pub journal: Journal, // interaction pub fsm: Fsm, @@ -43,9 +49,31 @@ pub struct App { // view pub camera: Camera, - pub painter: Painter, + pub ctx: PaintCtx, pub dpr: f32, pub last_frame_ms: f32, + /// Per-stage frame breakdown (ms): [resolve, scene, flush, egui, gpu, swap]. + /// `gpu` is a `glFinish` — the REAL GPU work (blocks until done), so `swap` + /// after it is pure vsync idle, not work. This separates "the display is + /// pacing us" (fine) from "we are slow" (a problem) — the distinction my + /// headless probes and the raw swap-wall both hid. + pub bd: [f32; 6], + frame_count: u64, + /// One-time diagnostic: every frame appended to `/tmp/anchor-spike-frames.log` + /// (truncated per run), flushed each frame so a kill preserves it. Captures + /// the inter-frame GAP (true fps, incl. time spent outside draw) during a + /// real pan — the signal idle capture can't see. NOT the dev-iteration + /// method; a human-in-the-loop bridge to show the symptom once. + /// Opt-in via `ANCHOR_FRAMELOG=1` — off by default so its per-frame file + /// I/O never sits in the latency path during normal use. + want_framelog: bool, + frame_log: Option>, + frame_clock: std::time::Instant, + /// Previous frame's resolved tier and the count of nodes that changed + /// against it (ENG-2.2 damage) — shown in the panel; not yet used to + /// scope repaint (OS-2a). + last_resolved: Option, + pub last_damage: usize, // panels (egui overlay on the shared GL context) egui_ctx: egui::Context, @@ -54,6 +82,9 @@ pub struct App { pub ir_draft: String, pub ir_dirty: bool, pub ir_error: Option, + /// Cmd+\ toggles the egui dev panels off, to feel raw canvas paint cost + /// (the panel is a per-frame egui layout + GL composite). Default on. + pub ui_visible: bool, // shell gpu: super::window::GpuSurface, @@ -98,21 +129,30 @@ pub fn run(init: WindowInit) { undo: Vec::new(), redo: Vec::new(), log: Vec::new(), + journal: Journal::new(), fsm: Fsm::Idle, gesture: None, space_held: false, cursor: (0.0, 0.0), modifiers: ModifiersState::default(), camera: Camera::new(), - painter: Painter::new(), + ctx: paint_ctx(), dpr: scale_factor as f32, last_frame_ms: 0.0, + bd: [0.0; 6], + frame_count: 0, + want_framelog: std::env::var("ANCHOR_FRAMELOG").is_ok(), + frame_log: None, + frame_clock: std::time::Instant::now(), + last_resolved: None, + last_damage: 0, egui_ctx, egui_winit, egui_painter, ir_draft: String::new(), ir_dirty: false, ir_error: None, + ui_visible: true, gpu, window, gl_surface, @@ -153,6 +193,17 @@ impl App { self.redo.clear(); } + /// Apply a typed op through the engine's dispatch (`anchor_lab::ops::apply`) + /// and record it in the journal (ENG-5.1). `resolved` must be a fresh + /// resolve of the current document — callers pass the same resolve they + /// already computed, so this is byte-identical to the pre-journal free-fn + /// path (guarded by the lab's `apply ≡ free_fn` test). + fn apply_op(&mut self, resolved: &Resolved, op: Op) -> ops::OpResult { + let result = ops::apply(&mut self.doc, resolved, &op); + self.journal.record(op, result.clone()); + result + } + fn log_push(&mut self, entry: LogEntry) { self.log.push(entry); if self.log.len() > 40 { @@ -215,7 +266,7 @@ impl App { } let (wx, wy) = self.camera.screen_to_world(self.cursor); - match pick(&self.doc, &resolved, wx, wy) { + match Query::new(&self.doc, &resolved).hit_point(wx, wy) { Some(hit) if hit != self.doc.root => { self.selection = Some(hit); self.fsm = Fsm::Pressed { @@ -251,7 +302,8 @@ impl App { } HandleKind::Edge(_) | HandleKind::Corner(_) => { let (ax, ay) = resize_anchors(kind); - let begin = |axis, anchor| ResizeDrag::begin(&self.doc, &resolved, id, axis, anchor); + let begin = + |axis, anchor| ResizeDrag::begin(&self.doc, &resolved, id, axis, anchor); let dx = ax.map(|a| begin(Axis::X, a)); let dy = ay.map(|a| begin(Axis::Y, a)); // A derived box refuses resize — the wall is a log line, @@ -318,8 +370,20 @@ impl App { let p0 = parent_point(&self.doc, &resolved, &self.camera, id, last); let p1 = parent_point(&self.doc, &resolved, &self.camera, id, self.cursor); let b = resolved.box_of(id); - let rx = ops::set_x(&mut self.doc, &resolved, id, b.x + (p1.0 - p0.0)); - let ry = ops::set_y(&mut self.doc, &resolved, id, b.y + (p1.1 - p0.1)); + let rx = self.apply_op( + &resolved, + Op::SetX { + id, + value: b.x + (p1.0 - p0.0), + }, + ); + let ry = self.apply_op( + &resolved, + Op::SetY { + id, + value: b.y + (p1.1 - p0.1), + }, + ); if let Some(g) = &mut self.gesture { if let Err(e) = rx { g.error(format!("x: Err({e:?})")); @@ -338,7 +402,7 @@ impl App { Axis::X => p.0, Axis::Y => p.1, }; - let r = ops::resize_drag(&mut self.doc, &resolved, id, &drag, target); + let r = self.apply_op(&resolved, Op::ResizeDrag { id, drag, target }); if let (Some(g), Err(e)) = (&mut self.gesture, r) { g.error(format!("{:?}: Err({e:?})", drag.axis)); } @@ -348,10 +412,24 @@ impl App { let (dx, dy) = (*dx, *dy); let r1 = resolve_doc(&self.doc); let p = parent_point(&self.doc, &r1, &self.camera, id, self.cursor); - let rx = ops::resize_drag(&mut self.doc, &r1, id, &dx, p.0); + let rx = self.apply_op( + &r1, + Op::ResizeDrag { + id, + drag: dx, + target: p.0, + }, + ); let r2 = resolve_doc(&self.doc); let p = parent_point(&self.doc, &r2, &self.camera, id, self.cursor); - let ry = ops::resize_drag(&mut self.doc, &r2, id, &dy, p.1); + let ry = self.apply_op( + &r2, + Op::ResizeDrag { + id, + drag: dy, + target: p.1, + }, + ); if let Some(g) = &mut self.gesture { if let Err(e) = rx { g.error(format!("x: Err({e:?})")); @@ -374,11 +452,11 @@ impl App { deg = (deg / 15.0).round() * 15.0; } let derived = *derived; + let resolved = resolve_doc(&self.doc); let r = if derived { - let resolved = resolve_doc(&self.doc); - ops::rotate_derived_center_feel(&mut self.doc, &resolved, id, deg) + self.apply_op(&resolved, Op::RotateDerivedCenterFeel { id, deg }) } else { - ops::set_rotation(&mut self.doc, id, deg) + self.apply_op(&resolved, Op::SetRotation { id, deg }) }; if let (Some(g), Err(e)) = (&mut self.gesture, r) { g.error(format!("rotation: Err({e:?})")); @@ -388,7 +466,9 @@ impl App { // Idle / Pressed: hover tracking only. let resolved = resolve_doc(&self.doc); let (wx, wy) = self.camera.screen_to_world(self.cursor); - self.hover = pick(&self.doc, &resolved, wx, wy).filter(|h| *h != self.doc.root); + self.hover = Query::new(&self.doc, &resolved) + .hit_point(wx, wy) + .filter(|h| *h != self.doc.root); } } } @@ -434,7 +514,8 @@ impl App { if let Some(id) = self.selection { let name = self.name_of(id); self.push_undo(); - match ops::delete(&mut self.doc, id) { + let resolved = resolve_doc(&self.doc); + match self.apply_op(&resolved, Op::Delete { id }) { Ok(n) => { self.after_structural(); self.log_push(LogEntry { @@ -461,7 +542,11 @@ impl App { | NamedKey::ArrowDown), ) => { if let Some(id) = self.selection { - let step = if self.modifiers.shift_key() { 10.0 } else { 1.0 }; + let step = if self.modifiers.shift_key() { + 10.0 + } else { + 1.0 + }; let (dx, dy) = match k { NamedKey::ArrowLeft => (-step, 0.0), NamedKey::ArrowRight => (step, 0.0), @@ -471,8 +556,20 @@ impl App { self.push_undo(); let before = self.doc.get(id).header.clone(); let resolved = resolve_doc(&self.doc); - let rx = ops::set_x(&mut self.doc, &resolved, id, resolved.box_of(id).x + dx); - let ry = ops::set_y(&mut self.doc, &resolved, id, resolved.box_of(id).y + dy); + let rx = self.apply_op( + &resolved, + Op::SetX { + id, + value: resolved.box_of(id).x + dx, + }, + ); + let ry = self.apply_op( + &resolved, + Op::SetY { + id, + value: resolved.box_of(id).y + dy, + }, + ); let writes = diff_header(&before, &self.doc.get(id).header); let mut errors = vec![]; if let Err(e) = rx { @@ -504,7 +601,7 @@ impl App { let name = self.name_of(id); let resolved = resolve_doc(&self.doc); self.push_undo(); - match ops::ungroup(&mut self.doc, &resolved, id) { + match self.apply_op(&resolved, Op::Ungroup { id }) { Ok(n) => { self.selection = None; self.log_push(LogEntry { @@ -534,8 +631,16 @@ impl App { } Key::Character(ref c) if primary && c.as_str() == "-" => { let size = self.window.inner_size(); - self.camera - .zoom_about((size.width as f32 / 2.0, size.height as f32 / 2.0), 1.0 / 1.2); + self.camera.zoom_about( + (size.width as f32 / 2.0, size.height as f32 / 2.0), + 1.0 / 1.2, + ); + } + Key::Character(ref c) if primary && c.as_str() == "\\" => { + // Toggle the dev panels — feel raw canvas paint without the + // per-frame egui overlay. + self.ui_visible = !self.ui_visible; + self.window.request_redraw(); } _ => {} } @@ -544,12 +649,26 @@ impl App { // ── paint ─────────────────────────────────────────────────────── fn draw(&mut self) { + let ms = |t: std::time::Instant| t.elapsed().as_secs_f32() * 1000.0; let t0 = std::time::Instant::now(); + // The inter-frame GAP: wall time since the last draw START. This is the + // true frame period (fps = 1000/gap) — it includes everything BETWEEN + // draws (event loop, scheduling, present blocking), which the in-draw + // stage sum does not. If gap >> draw, the problem is not rendering. + let gap = t0.duration_since(self.frame_clock).as_secs_f32() * 1000.0; + self.frame_clock = t0; let resolved = resolve_doc(&self.doc); + self.last_damage = match &self.last_resolved { + Some(prev) => anchor_engine::damage::diff(prev, &resolved).changed.len(), + None => 0, + }; + let t_resolve = ms(t0); + + let t1 = std::time::Instant::now(); let canvas = self.gpu.surface.canvas(); canvas.clear(skia_safe::Color::from_argb(255, 0xF7, 0xF8, 0xF9)); - self.painter - .paint_scene(canvas, &self.doc, &resolved, &self.camera); + let list = anchor_engine::drawlist::build(&self.doc, &resolved); + anchor_engine::paint::execute(canvas, &list, &self.camera.view(), &self.ctx); hud::paint_hud_dpr( canvas, &self.doc, @@ -557,16 +676,73 @@ impl App { &self.camera, self.selection, self.hover, - &self.painter, + &self.ctx, self.dpr, ); + let t_scene = ms(t1); + + let t2 = std::time::Instant::now(); self.gpu.gr_context.flush_and_submit(); - // egui chrome — topmost, on the shared GL context. + let t_flush = ms(t2); + + let t3 = std::time::Instant::now(); self.paint_egui(resolved.reports.len()); + let t_egui = ms(t3); + + // glFinish: block until the GPU has ACTUALLY finished all submitted + // work (scene + egui). Its wall time is the real GPU cost; whatever + // swap_buffers then spends is pure vsync idle, not work. This is the + // measurement the OpenGL literature prescribes — a raw swap wall only + // ever shows multiples of the refresh interval and hides the truth. + let t_gpu = std::time::Instant::now(); + unsafe { + gl::Finish(); + } + let t_gpufinish = ms(t_gpu); + + let t4 = std::time::Instant::now(); if let Err(e) = self.gl_surface.swap_buffers(&self.gl_context) { eprintln!("swap_buffers: {e:?}"); } - self.last_frame_ms = t0.elapsed().as_secs_f32() * 1000.0; + let t_swap = ms(t4); + + self.last_frame_ms = ms(t0); + self.bd = [t_resolve, t_scene, t_flush, t_egui, t_gpufinish, t_swap]; + self.frame_count += 1; + + // Per-run frame log (opt-in `ANCHOR_FRAMELOG=1`). Truncated on the first + // frame; flushed every frame so a hard kill during panning preserves it. + if self.want_framelog && self.frame_log.is_none() { + let path = "/tmp/anchor-spike-frames.log"; + match std::fs::File::create(path) { + Ok(f) => { + let sz = self.window.inner_size(); + eprintln!( + "frame log -> {path} (dpr {:.1} surface {}x{})", + self.dpr, sz.width, sz.height + ); + self.frame_log = Some(std::io::BufWriter::new(f)); + } + Err(e) => eprintln!("frame log open failed: {e}"), + } + } + if let Some(w) = self.frame_log.as_mut() { + use std::io::Write as _; + let panning = matches!(self.fsm, Fsm::Dragging(Drag::Pan { .. })); + let dragging = matches!(self.fsm, Fsm::Dragging(_)); + let _ = writeln!( + w, + "f{:<6} gap {:7.2}ms ({:6.1}fps) draw {:6.2} = res {:.2} scene {:.2} flush {:.2} egui {:.2} gpu {:.2} vsync {:.2} | pan={} drag={} cursor=({:.0},{:.0}) damage={} zoom={:.2}", + self.frame_count, gap, 1000.0 / gap.max(0.001), + self.last_frame_ms, + t_resolve, t_scene, t_flush, t_egui, t_gpufinish, t_swap, + panning as u8, dragging as u8, + self.cursor.0, self.cursor.1, self.last_damage, self.camera.zoom, + ); + let _ = w.flush(); + } + + self.last_resolved = Some(resolved); } // ── panels (egui) ─────────────────────────────────────────────── @@ -574,6 +750,14 @@ impl App { fn paint_egui(&mut self, _report_count: usize) { use glow::HasContext as _; + if !self.ui_visible { + // Panels hidden (Cmd+\): drain accumulated input so it can't pile + // up while hidden, but skip the panel build + egui GL composite — + // the point is to feel raw canvas paint cost. + let _ = self.egui_winit.take_egui_input(&self.window); + return; + } + let raw = self.egui_winit.take_egui_input(&self.window); let ctx = self.egui_ctx.clone(); let full = ctx.run_ui(raw, |ui| self.build_panels(ui)); @@ -596,14 +780,15 @@ impl App { // egui issued raw GL — resync Ganesh before the next Skia frame // (the grida_editor reset dance). self.gpu.gr_context.reset(None); - if full - .viewport_output - .get(&egui::ViewportId::ROOT) - .map(|v| v.repaint_delay == std::time::Duration::ZERO) - .unwrap_or(false) - { - self.window.request_redraw(); - } + // RENDER ON DEMAND: do NOT auto-request another redraw here. egui asks + // for an immediate repaint every frame (the code-editor keeps it + // unsettled), which drove continuous rendering — the CPU racing ahead + // of vsync, filling the present chain 2-3 frames deep = the input lag + // (winit#2468 / glutin#1322). Redraws now come only from real input + // events (window_event), so each input maps to one frame presented + // ASAP: shallow chain, tight input->photon coupling, no idle GPU burn. + // Cost: egui's own animations (cursor blink) don't tick when idle — + // acceptable for a dev panel, and any click/keypress redraws it. } fn build_panels(&mut self, ui: &mut egui::Ui) { @@ -627,11 +812,21 @@ impl App { }); ui.label( egui::RichText::new(format!( - "frame {:.2} ms · zoom {:.0}% · undo {} · redo {}", + "frame {:.2} ms · zoom {:.0}% · undo {} · redo {} · damage {} · journal {}", self.last_frame_ms, self.camera.zoom * 100.0, self.undo.len(), - self.redo.len() + self.redo.len(), + self.last_damage, + self.journal.len(), + )) + .monospace() + .weak(), + ); + ui.label( + egui::RichText::new(format!( + " resolve {:.2} · scene {:.2} · flush {:.2} · egui {:.2} · GPU {:.2} · vsync {:.2}", + self.bd[0], self.bd[1], self.bd[2], self.bd[3], self.bd[4], self.bd[5], )) .monospace() .weak(), @@ -683,12 +878,9 @@ impl App { } if resolved.reports.len() > 6 { ui.label( - egui::RichText::new(format!( - "… {} more", - resolved.reports.len() - 6 - )) - .weak() - .small(), + egui::RichText::new(format!("… {} more", resolved.reports.len() - 6)) + .weak() + .small(), ); } ui.separator(); @@ -812,20 +1004,30 @@ impl ApplicationHandler for App { // sees every event; input it is using must not also reach the // canvas FSM. Non-input events always pass through. let resp = self.egui_winit.on_window_event(&self.window, &event); - if resp.repaint { - self.window.request_redraw(); - } - let gated = match &event { - WindowEvent::CursorMoved { .. } - | WindowEvent::MouseInput { .. } - | WindowEvent::MouseWheel { .. } - | WindowEvent::PinchGesture { .. } => { - resp.consumed || self.egui_ctx.egui_wants_pointer_input() - } - WindowEvent::KeyboardInput { .. } | WindowEvent::Ime(_) => { - resp.consumed || self.egui_ctx.egui_wants_keyboard_input() + // RENDER ON DEMAND: do NOT redraw just because egui wants to repaint. + // A RedrawRequested is itself an event, so `egui wants repaint -> + // request_redraw` self-perpetuated a continuous render loop (the input + // lag). Real input still drives redraws below (canvas via the fall- + // through request, egui-consumed input via the `gated` branch), so the + // panel stays responsive; only egui's idle self-animation is dropped. + let _ = resp.repaint; + // With the panels hidden there is nothing to arbitrate — every input + // goes to the canvas (incl. the Cmd+\ that toggles them back on). + let gated = if !self.ui_visible { + false + } else { + match &event { + WindowEvent::CursorMoved { .. } + | WindowEvent::MouseInput { .. } + | WindowEvent::MouseWheel { .. } + | WindowEvent::PinchGesture { .. } => { + resp.consumed || self.egui_ctx.egui_wants_pointer_input() + } + WindowEvent::KeyboardInput { .. } | WindowEvent::Ime(_) => { + resp.consumed || self.egui_ctx.egui_wants_keyboard_input() + } + _ => false, } - _ => false, }; if gated { self.window.request_redraw(); @@ -847,8 +1049,7 @@ impl ApplicationHandler for App { self.gl_surface.resize( &self.gl_context, NonZeroU32::new(size.width).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), - NonZeroU32::new(size.height) - .unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), + NonZeroU32::new(size.height).unwrap_or(unsafe { NonZeroU32::new_unchecked(1) }), ); self.gpu.recreate(size.width as i32, size.height as i32); } diff --git a/model-v2/a/spike-canvas/src/shell/hud.rs b/model-v2/a/spike-canvas/src/shell/hud.rs index 296a6e6d8c..cb43a595b5 100644 --- a/model-v2/a/spike-canvas/src/shell/hud.rs +++ b/model-v2/a/spike-canvas/src/shell/hud.rs @@ -13,7 +13,7 @@ use anchor_lab::resolve::Resolved; use skia_safe::{Canvas, Color, Font, Paint, PaintStyle, PathBuilder, Rect}; use crate::camera::Camera; -use crate::paint::Painter; +use anchor_engine::paint::PaintCtx; pub const HANDLE: f32 = 8.0; pub const ROTATE_STICK: f32 = 26.0; @@ -75,7 +75,13 @@ pub fn screen_corners(doc: &Document, r: &Resolved, cam: &Camera, id: NodeId) -> /// Handle anchor points in screen space, shared by paint and hit-testing /// (one geometry, two consumers — never disagree). -pub fn handles(doc: &Document, r: &Resolved, cam: &Camera, id: NodeId, dpr: f32) -> Vec<(HandleKind, (f32, f32))> { +pub fn handles( + doc: &Document, + r: &Resolved, + cam: &Camera, + id: NodeId, + dpr: f32, +) -> Vec<(HandleKind, (f32, f32))> { let c = screen_corners(doc, r, cam, id); let mid = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); let top = mid(c[0], c[1]); @@ -97,7 +103,10 @@ pub fn handles(doc: &Document, r: &Resolved, cam: &Camera, id: NodeId, dpr: f32) (HandleKind::Edge(1), mid(c[1], c[2])), (HandleKind::Edge(2), bottom), (HandleKind::Edge(3), mid(c[0], c[3])), - (HandleKind::Rotate, (top.0 + up.0 * stick, top.1 + up.1 * stick)), + ( + HandleKind::Rotate, + (top.0 + up.0 * stick, top.1 + up.1 * stick), + ), ] } @@ -128,9 +137,9 @@ pub fn paint_hud( cam: &Camera, selection: Option, hover: Option, - painter: &Painter, + ctx: &PaintCtx, ) { - paint_hud_dpr(canvas, doc, resolved, cam, selection, hover, painter, 1.0); + paint_hud_dpr(canvas, doc, resolved, cam, selection, hover, ctx, 1.0); } #[allow(clippy::too_many_arguments)] @@ -141,7 +150,7 @@ pub fn paint_hud_dpr( cam: &Camera, selection: Option, hover: Option, - painter: &Painter, + ctx: &PaintCtx, dpr: f32, ) { canvas.save(); @@ -196,7 +205,7 @@ pub fn paint_hud_dpr( // Ink readout (E-A7, demoted post-DEC-0): sizing never moves // with rotation anymore; the readout explains box vs INK. let node = doc.get(id); - if let Some(tf) = &painter.font { + if let Some(tf) = &ctx.font { let b = resolved.box_of(id); let aabb = resolved.aabb_of(id); let label = if node.header.rotation != 0.0 { @@ -218,9 +227,9 @@ pub fn paint_hud_dpr( ) }; let font = Font::new(tf.clone(), 12.0 * dpr); - let anchor = c - .iter() - .fold((f32::MAX, f32::MAX), |acc, p| (acc.0.min(p.0), acc.1.min(p.1))); + let anchor = c.iter().fold((f32::MAX, f32::MAX), |acc, p| { + (acc.0.min(p.0), acc.1.min(p.1)) + }); let pos = (anchor.0, anchor.1 - 10.0 * dpr); let mut bg = fill(Color::new(0xE6FFFFFF)); bg.set_style(PaintStyle::Fill); diff --git a/model-v2/engine/.cargo/config.toml b/model-v2/engine/.cargo/config.toml new file mode 100644 index 0000000000..d30e354cc8 --- /dev/null +++ b/model-v2/engine/.cargo/config.toml @@ -0,0 +1,5 @@ +# Reuse the repo's shared target dir so the already-compiled skia-safe +# build (grida_dev / anchor-spike — same version, same features) is hit +# instead of rebuilt inside this standalone workspace. +[build] +target-dir = "../../target" diff --git a/model-v2/engine/Cargo.lock b/model-v2/engine/Cargo.lock new file mode 100644 index 0000000000..d3e9273228 --- /dev/null +++ b/model-v2/engine/Cargo.lock @@ -0,0 +1,895 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anchor-engine" +version = "0.0.0" +dependencies = [ + "anchor-lab", + "gl", + "glutin", + "raw-window-handle", + "serde_json", + "skia-safe", +] + +[[package]] +name = "anchor-lab" +version = "0.0.0" +dependencies = [ + "quick-xml", + "serde", + "taffy", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "skia-bindings" +version = "0.93.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2359f7e30c9da3f322f8ca3d4ec0abbc12a40035ce758309db0cdab07b5d4476" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.93.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e837ea9d531c9efee8f980bfcdb7226b21db0285b0c3171d8be745829f940" +dependencies = [ + "base64", + "bitflags", + "percent-encoding", + "skia-bindings", + "skia-svg-macros", +] + +[[package]] +name = "skia-svg-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "044dd2233c9717a74f75197f3e7f0a966db2127c0ffb5e05013b480a9b75b2c7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/model-v2/engine/Cargo.toml b/model-v2/engine/Cargo.toml new file mode 100644 index 0000000000..0d7985a1a1 --- /dev/null +++ b/model-v2/engine/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "anchor-engine" +version = "0.0.0" +edition = "2021" +publish = false +description = "The phase-4 canvas engine skeleton for the model-v2 anchor model: resolve -> drawlist -> paint pipeline, spatial query, journal/replay, damage. Consumes anchor-lab; hosts (the spike, later crates/grida) consume this. See model-v2/a/ENGINE.md." + +# Standalone on purpose (model-v2 containment) — own workspace, shared +# target dir for skia reuse. Promoted into crates/ at the migration gate, +# where the public name becomes a commitment. See model-v2/a/ENGINE.md S-1. +[workspace] + +[features] +# ENG S-6 · observability compiled to zero unless asked. The profiler must +# never distort the profile. +trace = [] +# The headless-GPU probe (probe_gpu): real GL flush timing for the two +# GPU-only wins (scene raster cache, layerization) that raster mis-signs. +# Optional so the default raster build (probe, gate, spike) stays GL-free. +native-gl-context = ["dep:glutin", "dep:gl", "dep:raw-window-handle"] + +[dependencies] +# serde feature ON: the op-log wire for replay (`Op` becomes serializable). +anchor-lab = { path = "../a/lab", features = ["serde"] } +serde_json = "1.0" + +# Same exact version + features as anchor-spike / grida_dev so the shared +# target dir reuses the compiled skia-safe artifact instead of rebuilding +# it. `use skia_safe` stays confined to paint.rs (ENGINE.md S-1). +skia-safe = { version = "=0.93.1", features = [ + "gpu", + "gl", + "textlayout", + "pdf", + "svg", + "webp", +] } + +# Headless-GPU probe only (feature native-gl-context) — same versions as the +# spike shell / grida_dev, so the shared target dir reuses the artifacts. +glutin = { version = "0.32.0", optional = true } +gl = { version = "0.14.0", optional = true } +raw-window-handle = { version = "0.6.0", optional = true } + +[profile.release] +debug = true diff --git a/model-v2/engine/README.md b/model-v2/engine/README.md new file mode 100644 index 0000000000..edd248890e --- /dev/null +++ b/model-v2/engine/README.md @@ -0,0 +1,78 @@ +# anchor-engine — the phase-4 canvas engine skeleton + +The pipeline the `crates/grida` migration will read: `document → resolve → +drawlist → paint`, plus the read tier (`query`), time-as-data +(`journal`/`replay`), and the sockets every future optimization plugs into +(`damage`, `ident`, `oracle`). It consumes [`anchor-lab`](../a/lab) as a +library — the same relationship the migration will have with the model crate. +The contracts it encodes are catalogued in [`../a/ENGINE.md`](../a/ENGINE.md) +(ENG-0…ENG-5, S-1…S-7); each module names the contract it serves. + +This is a **day-1 skeleton**: every contract has a code socket and a guarding +test, and the spike ([`../a/spike-canvas`](../a/spike-canvas)) is **re-hosted** +onto it — painting, hit-testing, gestures, and damage all flow through the +engine. Growth (incremental resolve, tiles, a broadphase index, a real shaper) +is deferred to named studies; the sockets are here so that growth is additive. + +## Run + +```sh +# the engine's own tests (drawlist, query, journal, replay, damage, ident) +cd model-v2/engine && cargo test + +# the trace arm must keep compiling +cargo check --features trace + +# the gate: shots byte-identical + replay determinism + bench budgets +# (needs the spike built first — it owns the golden pixels) +(cd ../a/spike-canvas && cargo build --release) +cargo run --release --bin gate +``` + +How the engine proves it is _fast_ (the four measurement axes — automated work +& correctness, the auxiliary human-in-the-loop feel channel, and the +software-unmeasurable input→photon limit) is its own doctrine: +[`MEASURE.md`](./MEASURE.md). + +## Contract → module → guarding test + +| concern | module | contract | guarding test | +| ------------------------------------ | ---------------- | ------------- | ---------------------------------------------------------------------------- | +| stage purity + the oracle law | (whole pipeline) | ENG-0 | the gate's differential + determinism runs | +| drawlist (pure, diffable projection) | `drawlist.rs` | ENG-2.1 | `tests/drawlist.rs` (order · pruning · color · verbatim world · determinism) | +| paint executor (only skia module) | `paint.rs` | ENG-2.1 | spike shots byte-identical (gate) | +| one frame entry | `frame.rs` | ENG-2.4 | spike live loop + gate | +| damage as data | `damage.rs` | ENG-2.2 | `tests/damage.rs` (identity empty · single-op locality) | +| spatial read tier | `query.rs` | ENG-3 | `tests/query.rs` (`hit_point ≡ pick` over a grid) | +| journal (op-log) | `journal.rs` | ENG-5.1 | `tests/journal.rs` | +| replay (corpus, determinism) | `replay.rs` | ENG-5.2/5.3 | `tests/replay.rs` + `rig/corpus/*.replay` via the gate | +| cache identity | `ident.rs` | ENG-2.3/1.4 | `tests/ident.rs` (generation-stamped key) | +| oracle version tags | `oracle.rs` | ENG-4.2 | the `.replay` header | +| gated observability | `trace.rs` | S-6 | `cargo check --features trace` | +| the rig | `bin/gate.rs` | ENG-0.2 / S-5 | it _is_ the gate | + +The model-crate side of the setup lives in [`../a/lab`](../a/lab): the typed +`Op` + `apply` dispatcher + `DirtyClass` (`ops.rs`), the per-slot `generations` +column (`model.rs`), the non-panicking `Resolved` opt accessors (`resolve.rs`), +and the optional `serde` feature (the op-log wire) — each additive, the 121-test +lab suite green throughout. + +## The re-host, concretely + +The spike's scene painter is deleted; it calls `drawlist::build` + +`paint::execute` (shots prove this byte-identical to the old painter, including +the rotated and cross-zero-flip scenes). Pick/hover go through `query`. All +gesture ops go through `apply` and are recorded in the `journal` (undo stays +document snapshots — ENG-5.5). `--record` writes `.replay` corpus files; the +panel shows the per-frame damage count. + +## Scope fence (named, not silent) + +Skia stays the rasterizer (the engine is the architecture _above_ it). Not an +ECS — the arena/SOA is a storage layout, not a component model. Deferred to +studies, each behind a socket that is already here: incremental resolve +(OS-1a/1b — `DirtyClass` exists, the engine ignores it and full-resolves) · +tiles / partial repaint (OS-2a — damage is data only) · layer promotion (OS-2b +— re-measure the legacy finding) · broadphase BVH (OS-3a/3b — behind `query`) +· real shaper (OS-4a / DEC-4) · pathops-in-measure (OS-4b / DEC-6) · CRDT / +cross-session replay (OS-5b/5c — walled on stable ids, a.md §12). diff --git a/model-v2/engine/rig/baselines.json b/model-v2/engine/rig/baselines.json new file mode 100644 index 0000000000..f5e0bfaa57 --- /dev/null +++ b/model-v2/engine/rig/baselines.json @@ -0,0 +1,14 @@ +{ + "entries": { + "flat10k": { + "build_us": 82.5, + "resolve_us": 346.709 + }, + "starter": { + "build_us": 0.458, + "resolve_us": 7.25 + } + }, + "machine": "aarch64-macos", + "note": "min-of-11 microseconds; regenerate with `gate --bless-bench`" +} diff --git a/model-v2/engine/rig/corpus/crosszero.replay b/model-v2/engine/rig/corpus/crosszero.replay new file mode 100644 index 0000000000..6171bcba0d --- /dev/null +++ b/model-v2/engine/rig/corpus/crosszero.replay @@ -0,0 +1,31 @@ +#anchor-replay v0 +oracle text=stub@lab-0 +viewport 2000 1400 +arm visual +--- ir --- + +--- ops --- +{"ResizeDrag":{"id":3,"drag":{"axis":"X","anchor":80.0,"base_flip":false,"base_side_positive":false},"target":130.0}} diff --git a/model-v2/engine/rig/corpus/rot45.replay b/model-v2/engine/rig/corpus/rot45.replay new file mode 100644 index 0000000000..0f95b97a81 --- /dev/null +++ b/model-v2/engine/rig/corpus/rot45.replay @@ -0,0 +1,31 @@ +#anchor-replay v0 +oracle text=stub@lab-0 +viewport 2000 1400 +arm visual +--- ir --- + +--- ops --- +{"SetRotation":{"id":4,"deg":45.0}} diff --git a/model-v2/engine/rig/corpus/ungroup.replay b/model-v2/engine/rig/corpus/ungroup.replay new file mode 100644 index 0000000000..2cc3ba02cd --- /dev/null +++ b/model-v2/engine/rig/corpus/ungroup.replay @@ -0,0 +1,31 @@ +#anchor-replay v0 +oracle text=stub@lab-0 +viewport 2000 1400 +arm visual +--- ir --- + +--- ops --- +{"Ungroup":{"id":12}} diff --git a/model-v2/engine/src/bin/gate.rs b/model-v2/engine/src/bin/gate.rs new file mode 100644 index 0000000000..2b511273d1 --- /dev/null +++ b/model-v2/engine/src/bin/gate.rs @@ -0,0 +1,349 @@ +//! ENG-0.2 / S-5 · the rig. `cargo run --release --bin gate` runs the checks +//! that keep every optimization honest, before the engine grows: +//! +//! 1. **shots** — the re-hosted spike's `--shot` output is byte-identical to +//! the committed goldens (the pixel gate; the spike owns golden pixels). +//! 2. **replays** — each corpus `.replay` plays twice to a bit-identical +//! document and result sequence (determinism, ENG-5.2). +//! 3. **diff** — the oracle law (ENG-0.2): every render optimization proves +//! `optimized == reference` (pixel/drawlist). Empty until the first win; +//! pins the reference oracle's determinism meanwhile. +//! 4. **bench** — resolve + drawlist timings stay within the checked-in +//! budgets (`rig/baselines.json`), fail past `max(1.5x, +50us)`. +//! +//! `--bless-shots` / `--bless-bench` re-record the baselines. Paint TIMING is +//! deliberately not gated (GPU-noisy); paint CORRECTNESS is the shot gate. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Instant; + +use anchor_engine::cache::{composited_to_bytes, SceneCache}; +use anchor_engine::{drawlist, paint, replay}; +use anchor_lab::math::Affine; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; + +const STATES: [&str; 4] = ["default", "crosszero", "rot45", "ungroup"]; +const CORPUS: [&str; 3] = ["crosszero", "rot45", "ungroup"]; + +fn manifest() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let bless_shots = args.iter().any(|a| a == "--bless-shots"); + let bless_bench = args.iter().any(|a| a == "--bless-bench"); + + println!("== anchor-engine gate =="); + let mut ok = true; + ok &= gate_shots(bless_shots); + ok &= gate_replays(); + ok &= gate_diff(); + ok &= gate_bench(bless_bench); + + if ok { + println!("\nGATE: PASS"); + } else { + eprintln!("\nGATE: FAIL"); + std::process::exit(1); + } +} + +// ── 1. shots ──────────────────────────────────────────────────────────── + +fn gate_shots(bless: bool) -> bool { + println!("\n[shots] spike --shot vs goldens"); + let spike = manifest().join("../../target/release/anchor-spike"); + let goldens = manifest().join("../a/spike-canvas/shots"); + if !spike.exists() { + eprintln!( + " MISSING spike binary: {}\n build it first: (cd model-v2/a/spike-canvas && cargo build --release)", + spike.display() + ); + return false; + } + let mut all = true; + for state in STATES { + let tmp = std::env::temp_dir().join(format!("anchor-gate-{state}.png")); + let status = Command::new(&spike) + .arg("--shot") + .arg(&tmp) + .arg(state) + .status(); + let golden = goldens.join(format!("{state}.png")); + if bless { + if let (Ok(_), Ok(bytes)) = (status, std::fs::read(&tmp)) { + std::fs::write(&golden, bytes).expect("bless golden"); + println!(" {state:10} blessed"); + } + continue; + } + let same = matches!(status, Ok(s) if s.success()) && files_equal(&tmp, &golden); + println!(" {state:10} {}", if same { "IDENTICAL" } else { "DIFF" }); + all &= same; + } + all +} + +fn files_equal(a: &Path, b: &Path) -> bool { + match (std::fs::read(a), std::fs::read(b)) { + (Ok(x), Ok(y)) => x == y, + _ => false, + } +} + +// ── 2. replays ────────────────────────────────────────────────────────── + +fn gate_replays() -> bool { + println!("\n[replays] play twice, bit-identical"); + let dir = manifest().join("rig/corpus"); + let mut all = true; + for name in CORPUS { + let path = dir.join(format!("{name}.replay")); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + eprintln!(" {name:10} unreadable: {e}"); + all = false; + continue; + } + }; + let rep = match replay::parse_string(&text) { + Ok(r) => r, + Err(e) => { + eprintln!(" {name:10} parse error: {e}"); + all = false; + continue; + } + }; + let (d1, res1) = replay::play(&rep); + let (d2, res2) = replay::play(&rep); + let deterministic = anchor_lab::textir::print(&d1) == anchor_lab::textir::print(&d2) + && replay::resolved_bits_eq(&resolve(&d1, &rep.opts), &resolve(&d2, &rep.opts)) + && res1 == res2; + println!( + " {name:10} {} ({} op{})", + if deterministic { + "DETERMINISTIC" + } else { + "DIVERGED" + }, + rep.ops.len(), + if rep.ops.len() == 1 { "" } else { "s" } + ); + all &= deterministic; + } + all +} + +// ── 3. differential (the oracle law, ENG-0.2) ───────────────────────────── + +/// Every render optimization ships a row here proving `optimized(input) == +/// reference(input)` — a fast-but-wrong cache aborts the build before anyone +/// reads a speedup. Today it holds ZERO optimization rows (each lands with its +/// win); it instead pins the L2 reference oracle itself — `raster_to_bytes` +/// must be deterministic (ENG-0.3), so every future pixel row can trust it. +fn gate_diff() -> bool { + println!("\n[diff] oracle-law differential (ENG-0.2)"); + let opts = ResolveOptions { + viewport: (2000.0, 1400.0), + rotation_in_flow: RotationInFlow::VisualOnly, + }; + let ctx = paint::PaintCtx { font: None }; + let view = Affine::scale(0.6, 0.6); // fit-ish so the frame has real draws + let (w, h) = (1360, 900); + let mut all = true; + for name in CORPUS { + let path = manifest().join(format!("rig/corpus/{name}.replay")); + let rep = match std::fs::read_to_string(&path) + .ok() + .and_then(|t| replay::parse_string(&t).ok()) + { + Some(r) => r, + None => { + eprintln!(" {name:10} corpus unreadable"); + all = false; + continue; + } + }; + let (doc, _) = replay::play(&rep); + let resolved = resolve(&doc, &opts); + let list = drawlist::build(&doc, &resolved); + let a = paint::raster_to_bytes(&list, &view, w, h, &ctx); + let b = paint::raster_to_bytes(&list, &view, w, h, &ctx); + let same = a == b; + println!( + " {name:10} reference {}", + if same { + "DETERMINISTIC" + } else { + "NONDETERMINISTIC" + } + ); + all &= same; + + // Win 1 · scene raster cache (L2): an integer-pan blit is byte-identical + // to a fresh render. Prime the cache at `view`, pan by a whole pixel, + // and compare the composite against a fresh render at the panned view. + let panned = Affine { + e: view.e + 40.0, + f: view.f + 24.0, + ..view + }; + let mut cache = SceneCache::new(w, h); + let _ = composited_to_bytes(&mut cache, &doc, &opts, &view, &ctx, false, w, h); // cold + let blit = composited_to_bytes(&mut cache, &doc, &opts, &panned, &ctx, false, w, h); + let fresh = { + let r = resolve(&doc, &opts); + paint::raster_to_bytes(&drawlist::build(&doc, &r), &panned, w, h, &ctx) + }; + let cache_ok = blit == fresh; + println!( + " {name:10} scene-cache {}", + if cache_ok { + "MATCHES fresh (integer pan)" + } else { + "DIFFERS" + } + ); + all &= cache_ok; + } + all +} + +// ── 4. bench ──────────────────────────────────────────────────────────── + +fn gate_bench(bless: bool) -> bool { + println!("\n[bench] resolve + drawlist (min of 11, microseconds)"); + let baselines_path = manifest().join("rig/baselines.json"); + let machine = format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS); + + // "starter" = the corpus's normalized starter doc (no dependency on the + // spike's scene builder); "flat10k" = a synthetic stress canvas. + let starter = starter_doc(); + let starter_opts = ResolveOptions { + viewport: (2000.0, 1400.0), + rotation_in_flow: RotationInFlow::VisualOnly, + }; + let flat = flat_canvas(10_000); + + let measured = [ + ("starter", bench_doc(&starter, &starter_opts)), + ("flat10k", bench_doc(&flat, &starter_opts)), + ]; + + let prior = std::fs::read_to_string(&baselines_path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()); + + let same_machine = prior + .as_ref() + .and_then(|v| v.get("machine")) + .and_then(|m| m.as_str()) + .map(|m| m == machine) + .unwrap_or(false); + + let mut all = true; + for (name, (r_us, b_us)) in measured { + print!(" {name:10} resolve {r_us:8.1} build {b_us:8.1}"); + if bless || prior.is_none() { + println!(" (recording)"); + continue; + } + if !same_machine { + println!(" (other machine — comparison skipped)"); + continue; + } + let base = prior + .as_ref() + .and_then(|v| v.get("entries")) + .and_then(|e| e.get(name)); + let br = base + .and_then(|b| b.get("resolve_us")) + .and_then(|v| v.as_f64()); + let bb = base + .and_then(|b| b.get("build_us")) + .and_then(|v| v.as_f64()); + let r_ok = br.map(|base| within(r_us, base)).unwrap_or(true); + let b_ok = bb.map(|base| within(b_us, base)).unwrap_or(true); + println!( + " resolve {} build {}", + verdict(r_ok, r_us, br), + verdict(b_ok, b_us, bb) + ); + all &= r_ok && b_ok; + } + + if bless || prior.is_none() { + let json = serde_json::json!({ + "machine": machine, + "note": "min-of-11 microseconds; regenerate with `gate --bless-bench`", + "entries": { + "starter": { "resolve_us": measured[0].1.0, "build_us": measured[0].1.1 }, + "flat10k": { "resolve_us": measured[1].1.0, "build_us": measured[1].1.1 }, + } + }); + std::fs::write( + &baselines_path, + serde_json::to_string_pretty(&json).unwrap() + "\n", + ) + .expect("write baselines"); + println!(" baselines written -> {}", baselines_path.display()); + } + all +} + +/// Budget rule: pass under `max(1.5x baseline, baseline + 50us)` — the floor +/// stops timer noise from failing sub-microsecond-ish entries. +fn within(measured: f64, baseline: f64) -> bool { + measured <= (baseline * 1.5).max(baseline + 50.0) +} + +fn verdict(ok: bool, measured: f64, baseline: Option) -> String { + match baseline { + Some(b) => format!("{}({measured:.1}/{b:.1})", if ok { "OK " } else { "OVER " }), + None => "OK(new)".to_string(), + } +} + +fn bench_doc(doc: &Document, opts: &ResolveOptions) -> (f64, f64) { + let mut r_min = f64::MAX; + let mut b_min = f64::MAX; + for _ in 0..11 { + let t0 = Instant::now(); + let resolved = resolve(doc, opts); + let t1 = Instant::now(); + let _ = drawlist::build(doc, &resolved); + let t2 = Instant::now(); + r_min = r_min.min((t1 - t0).as_secs_f64() * 1e6); + b_min = b_min.min((t2 - t1).as_secs_f64() * 1e6); + } + (r_min, b_min) +} + +fn starter_doc() -> Document { + // The normalized starter IR lives in every corpus replay's header. + let path = manifest().join("rig/corpus/crosszero.replay"); + let text = std::fs::read_to_string(&path).expect("corpus present for bench"); + replay::parse_string(&text).expect("parse corpus").doc +} + +fn flat_canvas(n: usize) -> Document { + let mut b = DocBuilder::new(); + for i in 0..n { + let mut h = Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(28.0)); + h.x = AxisBinding::start((i % 100) as f32 * 19.0); + h.y = AxisBinding::start((i / 100) as f32 * 13.0); + h.rotation = (i % 7) as f32 * 5.0; + b.add( + 0, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + } + b.build() +} diff --git a/model-v2/engine/src/cache.rs b/model-v2/engine/src/cache.rs new file mode 100644 index 0000000000..7b24bd000d --- /dev/null +++ b/model-v2/engine/src/cache.rs @@ -0,0 +1,148 @@ +//! ENG-2 · the scene raster cache (the compositor tier). +//! +//! Rasters the scene ONCE into a backend-matched offscreen image (GPU stays +//! GPU via `Canvas::new_surface`) covering the viewport plus a margin, then +//! re-composites it under camera PANS with a single blit — turning the +//! O(nodes) `execute` wall into an O(1) image draw. It re-rasters only on a +//! document change, a ZOOM change (a bitmap can't be crisply rescaled — that +//! is the re-raster boundary, ENG-2 growth), or a pan beyond the cached +//! margin. The cached drawlist is reused across clean re-rasters, so a +//! camera-only frame never re-resolves or re-builds either (the retained- +//! drawlist win folded in). +//! +//! Correctness (gate_diff L2): for an INTEGER-pixel pan the composite is +//! byte-identical to a fresh render — a shape translated by a whole pixel +//! rasterizes to identically-shifted pixels. Fractional pan resamples (a +//! visual approximation the live editor accepts, re-rastering on settle); the +//! gate proves the integer case, which is the contract. + +use anchor_lab::math::Affine; +use anchor_lab::model::Document; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{Canvas, Color, FilterMode, Image, ImageInfo, MipmapMode, SamplingOptions}; + +use crate::drawlist::{build, DrawList}; +use crate::paint::{execute, PaintCtx}; + +/// Extra content rastered around the viewport, so small pans blit without a +/// re-raster. Larger margin = fewer re-raster hitches, more offscreen memory. +const MARGIN: f32 = 256.0; + +/// The scene compositor. Holds a cached image (backend-matched) and the view +/// it was rastered at. +pub struct SceneCache { + image: Option, + /// The drawlist the cached image was rendered from — reused across clean + /// re-rasters (pan-out / zoom) so only a doc change rebuilds it. + list: Option, + ref_view: Affine, + vw: i32, + vh: i32, +} + +impl SceneCache { + pub fn new(vw: i32, vh: i32) -> Self { + SceneCache { + image: None, + list: None, + ref_view: Affine::IDENTITY, + vw, + vh, + } + } + + /// Composite the scene for `view` onto `canvas`. `doc_dirty` = the host + /// mutated the document since the last frame (it knows: it applied an op). + /// Returns `true` if this frame re-rastered (a diagnostic for the probe; + /// the amortized win is that most frames return `false`). + pub fn frame( + &mut self, + canvas: &Canvas, + doc: &Document, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, + doc_dirty: bool, + ) -> bool { + let dx = view.e - self.ref_view.e; + let dy = view.f - self.ref_view.f; + let same_zoom = view.a == self.ref_view.a + && view.b == self.ref_view.b + && view.c == self.ref_view.c + && view.d == self.ref_view.d; + let reraster = self.image.is_none() + || doc_dirty + || !same_zoom + || dx.abs() > MARGIN + || dy.abs() > MARGIN; + + if reraster { + self.raster(canvas, doc, opts, view, ctx, doc_dirty); + } + + // Blit the cached image at the (now possibly zero) integer pan offset. + let (dx, dy) = (view.e - self.ref_view.e, view.f - self.ref_view.f); + let img = self.image.as_ref().expect("image present after raster"); + // Nearest sampling: for an integer offset each dest pixel maps to exactly + // one src pixel (byte-exact); it never silently blurs at non-integer. + let sampling = SamplingOptions::new(FilterMode::Nearest, MipmapMode::None); + canvas.draw_image_with_sampling_options(img, (-MARGIN + dx, -MARGIN + dy), sampling, None); + reraster + } + + /// (Re)render the scene into a fresh backend-matched offscreen image sized + /// viewport + 2·margin, shifted by +margin so screen (0,0) lands at image + /// pixel (margin, margin). + fn raster( + &mut self, + canvas: &Canvas, + doc: &Document, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, + doc_dirty: bool, + ) { + if doc_dirty || self.list.is_none() { + let resolved = resolve(doc, opts); + self.list = Some(build(doc, &resolved)); + } + let list = self.list.as_ref().unwrap(); + + let m = MARGIN as i32; + let info = ImageInfo::new_n32_premul((self.vw + 2 * m, self.vh + 2 * m), None); + let mut off = canvas + .new_surface(&info, None) + .expect("backend-matched offscreen surface"); + let oc = off.canvas(); + oc.clear(Color::WHITE); + let mut shifted = *view; + shifted.e += MARGIN; + shifted.f += MARGIN; + execute(oc, list, &shifted, ctx); + + self.image = Some(off.image_snapshot()); + self.ref_view = *view; + } +} + +/// Render one composited frame to a fresh raster surface and return its bytes — +/// the optimized side of the gate_diff L2 row. Pairs with +/// [`crate::paint::raster_to_bytes`] (the reference). A fresh cache is passed so +/// the first frame is a cache-cold re-raster; call twice with panned views to +/// exercise the blit path. +pub fn composited_to_bytes( + cache: &mut SceneCache, + doc: &Document, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, + doc_dirty: bool, + w: i32, + h: i32, +) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + cache.frame(canvas, doc, opts, view, ctx, doc_dirty); + crate::paint::read_pixels(&mut surface, w, h) +} diff --git a/model-v2/engine/src/damage.rs b/model-v2/engine/src/damage.rs new file mode 100644 index 0000000000..980231c63a --- /dev/null +++ b/model-v2/engine/src/damage.rs @@ -0,0 +1,78 @@ +//! ENG-2.2 · damage as data. `diff(prev, next) -> Damage` (step 11) +//! compares two resolved tiers column-by-column with exact f32 equality +//! — justified because the pipeline is order-deterministic (ENG-0.3), so +//! "unchanged" is bit-identity, not an epsilon guess. Damage flows +//! forward only (resolve diff -> drawlist diff -> screen rects); no stage +//! invents or widens it. Day 1 it is asserted and shown (HUD line), not +//! yet consumed for partial repaint (that is OS-2a). + +use anchor_lab::math::RectF; +use anchor_lab::model::NodeId; +use anchor_lab::resolve::Resolved; + +/// What changed between two resolves: the touched nodes and the world-space +/// rect that bounds their before+after ink (covers appear/disappear). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Damage { + pub changed: Vec, + pub union_world: Option, +} + +impl Damage { + pub fn is_empty(&self) -> bool { + self.changed.is_empty() + } +} + +/// Diff two resolved tiers: the nodes whose resolved geometry changed and the +/// world rect that bounds their before+after ink. Comparison is exact f32 +/// equality per column (justified by ENG-0.3 determinism — "unchanged" is +/// identity, not an epsilon guess; -0.0 vs 0.0 is not a meaningful visual +/// change, so `==` is the right relation here). O(n) in slot count; day 1 it +/// is asserted and shown (HUD), not yet consumed for partial repaint (OS-2a). +pub fn diff(prev: &Resolved, next: &Resolved) -> Damage { + let n = prev.slot_count().max(next.slot_count()); + let mut changed = Vec::new(); + let mut union_world: Option = None; + for id in 0..n as NodeId { + if slot_changed(prev, next, id) { + changed.push(id); + // Cover the node's ink in BOTH states (appear/disappear/move). + if let Some(r) = prev.aabb_opt(id) { + union_world = Some(union_rect(union_world, r)); + } + if let Some(r) = next.aabb_opt(id) { + union_world = Some(union_rect(union_world, r)); + } + } + } + Damage { + changed, + union_world, + } +} + +fn slot_changed(prev: &Resolved, next: &Resolved, id: NodeId) -> bool { + prev.box_opt(id) != next.box_opt(id) + || prev.local_opt(id) != next.local_opt(id) + || prev.world_opt(id) != next.world_opt(id) + || prev.aabb_opt(id) != next.aabb_opt(id) +} + +fn union_rect(acc: Option, r: RectF) -> RectF { + match acc { + None => r, + Some(a) => { + let x0 = a.x.min(r.x); + let y0 = a.y.min(r.y); + let x1 = (a.x + a.w).max(r.x + r.w); + let y1 = (a.y + a.h).max(r.y + r.h); + RectF { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + } + } + } +} diff --git a/model-v2/engine/src/drawlist.rs b/model-v2/engine/src/drawlist.rs new file mode 100644 index 0000000000..95e84b1eb7 --- /dev/null +++ b/model-v2/engine/src/drawlist.rs @@ -0,0 +1,188 @@ +//! ENG-2.1 · the display list — a pure, diffable projection of the +//! resolved tier into paint primitives. `build(doc, resolved) -> DrawList` +//! (step 5) reads the resolved columns and the document's kinds; it never +//! reads the document's mutable intent directly and it never touches +//! skia. The camera is NOT baked in — the executor ([`crate::paint`]) +//! takes the view transform, so the same drawlist paints at any zoom and +//! the host stays free. +//! +//! The item vocabulary is exactly what the current spike painter draws, +//! so the engine path reproduces it pixel-for-pixel (the re-host gate). + +use anchor_lab::math::Affine; +use anchor_lab::model::{Document, NodeId, Payload, ShapeDesc}; +use anchor_lab::resolve::Resolved; + +/// A packed 0xAARRGGBB color — resolved from the node's fill (or the +/// per-kind default) at build time, so the executor does no color logic. +pub type Argb = u32; + +/// One paint primitive, carrying the world transform copied verbatim from +/// `resolved.world_of(node)` (never recomputed — pixel identity depends +/// on it) and the geometry in the node's own local space. +#[derive(Debug, Clone, PartialEq)] +pub struct Item { + pub node: NodeId, + pub world: Affine, + pub kind: ItemKind, +} + +/// The primitive set. A frame emits a `RectFill` then a `RectStroke` +/// (two items, fill under stroke — today's order). Derived kinds +/// (group/lens) and the root emit nothing (no ink); their children are +/// still visited. +#[derive(Debug, Clone, PartialEq)] +pub enum ItemKind { + RectFill { + w: f32, + h: f32, + argb: Argb, + }, + RectStroke { + w: f32, + h: f32, + argb: Argb, + stroke_width: f32, + }, + Oval { + w: f32, + h: f32, + argb: Argb, + }, + Line { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + argb: Argb, + }, + /// Baseline already baked to today's `0.85 * font_size` in local space. + TextRun { + text: String, + font_size: f32, + baseline_y: f32, + argb: Argb, + }, +} + +/// The whole scene as an ordered primitive stream, in paint order +/// (pre-order DFS, parent ink before children). Diffable by `==`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DrawList { + pub items: Vec, +} + +// Per-kind default fills — the exact fallbacks the spike painter uses, +// packed as 0xAARRGGBB (skia `Color::from_argb(255, r, g, b)`). +const FRAME_FILL: Argb = 0xFFFF_FFFF; // white +const FRAME_STROKE: Argb = 0xFFC9_CED4; +const SHAPE_FILL: Argb = 0xFF4A_90D9; +const TEXT_FILL: Argb = 0xFF00_0000; // black + +/// Resolve a node's optional `#rrggbb` fill to packed ARGB, or the default. +/// Byte-for-byte the painter's `color()`: opaque, 6-hex only, else fallback. +fn resolve_color(hex: Option<&str>, fallback: Argb) -> Argb { + let Some(h) = hex else { return fallback }; + let h = h.trim_start_matches('#'); + if h.len() == 6 { + if let Ok(v) = u32::from_str_radix(h, 16) { + return 0xFF00_0000 | v; + } + } + fallback +} + +/// Project the resolved tier into an ordered primitive stream — the pure +/// `resolve -> drawlist` stage. Traversal is exactly the spike painter's +/// (`paint_node`): a hidden subtree (`world_opt == None`) prunes; the root +/// and derived kinds (group/lens) emit no ink but their children are still +/// visited; a frame emits fill then stroke; geometry is local-space +/// (`Rect::from_wh` etc.), positioned by `world` — copied verbatim, never +/// recomputed. The camera is applied later by the executor. +pub fn build(doc: &Document, resolved: &Resolved) -> DrawList { + let mut items = Vec::new(); + emit(doc, resolved, doc.root, &mut items); + DrawList { items } +} + +fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) { + let Some(world) = resolved.world_opt(id) else { + return; // hidden subtree — pruned, children not visited + }; + let node = doc.get(id); + + // Root is the backdrop; derived kinds have no ink. Both still recurse. + if id != doc.root && !node.payload.box_is_derived() { + let b = resolved.box_of(id); + match &node.payload { + Payload::Frame { .. } => { + items.push(Item { + node: id, + world, + kind: ItemKind::RectFill { + w: b.w, + h: b.h, + argb: resolve_color(node.fill.as_deref(), FRAME_FILL), + }, + }); + items.push(Item { + node: id, + world, + kind: ItemKind::RectStroke { + w: b.w, + h: b.h, + argb: FRAME_STROKE, + stroke_width: 1.0, + }, + }); + } + Payload::Shape { desc } => { + let argb = resolve_color(node.fill.as_deref(), SHAPE_FILL); + let kind = match desc { + ShapeDesc::Rect => ItemKind::RectFill { + w: b.w, + h: b.h, + argb, + }, + ShapeDesc::Ellipse => ItemKind::Oval { + w: b.w, + h: b.h, + argb, + }, + ShapeDesc::Line => ItemKind::Line { + x1: 0.0, + y1: 0.0, + x2: b.w, + y2: 0.0, + width: 2.0, + argb, + }, + }; + items.push(Item { + node: id, + world, + kind, + }); + } + Payload::Text { content, font_size } => { + items.push(Item { + node: id, + world, + kind: ItemKind::TextRun { + text: content.clone(), + font_size: *font_size, + baseline_y: font_size * 0.85, + argb: resolve_color(node.fill.as_deref(), TEXT_FILL), + }, + }); + } + // Excluded by the box_is_derived guard above. + Payload::Group | Payload::Lens { .. } => unreachable!(), + } + } + + for &c in &node.children { + emit(doc, resolved, c, items); + } +} diff --git a/model-v2/engine/src/frame.rs b/model-v2/engine/src/frame.rs new file mode 100644 index 0000000000..47625cf065 --- /dev/null +++ b/model-v2/engine/src/frame.rs @@ -0,0 +1,52 @@ +//! ENG-2.4 socket · the one frame entry point. `render(...)` (step 6) +//! runs `resolve -> build -> execute` and returns the resolved tier, the +//! drawlist, and timings — the host clears the canvas and paints its own +//! chrome around this, never the other way round (the compositor owns +//! pacing; the host adapts). Kept a single seam so the fragmented +//! tick/redraw rot the legacy `FrameLoop` unified never regrows. + +use anchor_lab::math::Affine; +use anchor_lab::model::Document; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved}; +use std::time::Instant; + +use crate::drawlist::{build, DrawList}; +use crate::paint::{execute, PaintCtx}; + +/// Per-frame timings for the three pipeline seams (nanoseconds). Populated +/// by the same spans [`crate::trace`] reads when the `trace` feature is on; +/// always cheap enough to compute unconditionally here. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FrameStats { + pub resolve_ns: u128, + pub build_ns: u128, + pub execute_ns: u128, +} + +/// The one frame entry: `resolve -> build -> execute`, immediate, no caches +/// (the spike's proven thesis). The host clears the canvas and paints its own +/// chrome around this; it never reaches into the stages. Returns the resolved +/// tier and drawlist (the host reuses them for HUD/pick/damage) plus timings. +/// The only skia this module names is the `Canvas` it hands to the executor — +/// all raster work stays in [`crate::paint`] (S-1). +pub fn render( + canvas: &skia_safe::Canvas, + doc: &Document, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, +) -> (Resolved, DrawList, FrameStats) { + let t0 = Instant::now(); + let resolved = resolve(doc, opts); + let t1 = Instant::now(); + let list = build(doc, &resolved); + let t2 = Instant::now(); + execute(canvas, &list, view, ctx); + let t3 = Instant::now(); + let stats = FrameStats { + resolve_ns: (t1 - t0).as_nanos(), + build_ns: (t2 - t1).as_nanos(), + execute_ns: (t3 - t2).as_nanos(), + }; + (resolved, list, stats) +} diff --git a/model-v2/engine/src/ident.rs b/model-v2/engine/src/ident.rs new file mode 100644 index 0000000000..bc8ac35e11 --- /dev/null +++ b/model-v2/engine/src/ident.rs @@ -0,0 +1,28 @@ +//! ENG-2.3 / ENG-1.4 · the cache-key type. Cache identity is +//! `(slot, generation)`, never a bare [`NodeId`] — the arena reuses +//! nothing today, but the moment any cache (raster, layout, glyph atlas) +//! exists, a future reused slot must not be able to alias a prior node's +//! cached artifacts. No cache consumes [`Key`] yet: the type IS the +//! socket, and `key_of` (step 3, once `Document::gen_of` lands) is the +//! only sanctioned way to mint one. + +use anchor_lab::model::{Document, NodeId}; + +/// A generation-stamped node identity — the mandated key for every future +/// cache tier. Equality includes the generation, so a recycled slot is a +/// different key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Key { + pub id: NodeId, + pub gen: u32, +} + +/// Mint the cache key for a node — the ONLY sanctioned way. Reads the +/// live generation from the arena so a future reused slot yields a +/// distinct key from the node that previously occupied it. +pub fn key_of(doc: &Document, id: NodeId) -> Key { + Key { + id, + gen: doc.gen_of(id), + } +} diff --git a/model-v2/engine/src/journal.rs b/model-v2/engine/src/journal.rs new file mode 100644 index 0000000000..0451970369 --- /dev/null +++ b/model-v2/engine/src/journal.rs @@ -0,0 +1,59 @@ +//! ENG-5.1 · time as data. A `Transaction` (step 9) groups the typed ops +//! of one gesture with all-or-nothing history semantics; it records each +//! `(Op, OpResult)` — including typed errors, which are deterministic +//! document no-ops and a stronger determinism proof. The journal is a +//! recorder, not the undo mechanism: undo stays document snapshots +//! (ENG-5.5) until an op's inverse is property-tested. It lands on +//! `anchor_lab::ops::Op` (step 4). + +use anchor_lab::ops::{Op, OpResult}; + +/// The session op-log: every issued op in order with its result (errors +/// included — deterministic document no-ops, and a stronger determinism +/// proof). This is the replay source of truth (`replay` reads [`Journal::ops`]). +/// It RECORDS; it does not undo — undo stays document snapshots (ENG-5.5), +/// which give all-or-nothing history without an invertible op set. Per-gesture +/// grouping (a labeled transaction) is a display layer to add later; replay +/// needs only the flat issue order. +#[derive(Debug, Clone, Default)] +pub struct Journal { + entries: Vec<(Op, OpResult)>, +} + +impl Journal { + pub fn new() -> Self { + Journal::default() + } + + /// Record one applied op and its result, in issue order. + pub fn record(&mut self, op: Op, result: OpResult) { + self.entries.push((op, result)); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The ops in issue order — the replay log. + pub fn ops(&self) -> impl Iterator + '_ { + self.entries.iter().map(|(op, _)| op) + } + + /// The full record, ops and results. + pub fn entries(&self) -> &[(Op, OpResult)] { + &self.entries + } + + /// Whether any op recorded at or after `mark` actually wrote (`Ok(n>0)`) + /// — the "did anything change" test a gesture uses to keep or drop its + /// undo snapshot. + pub fn wrote_since(&self, mark: usize) -> bool { + self.entries[mark..] + .iter() + .any(|(_, r)| matches!(r, Ok(n) if *n > 0)) + } +} diff --git a/model-v2/engine/src/lib.rs b/model-v2/engine/src/lib.rs new file mode 100644 index 0000000000..3f529e2045 --- /dev/null +++ b/model-v2/engine/src/lib.rs @@ -0,0 +1,26 @@ +//! anchor-engine — the phase-4 canvas engine skeleton on the `anchor` +//! model ([`anchor_lab`], consumed as a library — the same relationship +//! `crates/grida` will have with the model crate after the migration). +//! +//! This crate is the pipeline: `document -> resolve -> drawlist -> paint` +//! (the browser's staged-and-pure discipline) plus the read tier +//! (`query`), time-as-data (`journal`/`replay`), and the sockets every +//! future optimization plugs into (`damage`, `ident`, `oracle`). The +//! contracts it encodes are catalogued in `model-v2/a/ENGINE.md` +//! (ENG-0…ENG-5); each module names the contract it serves. +//! +//! Host chrome (winit/egui/GL) lives in the host (the spike, later +//! `crates/grida`), never here. `use skia_safe` is confined to +//! [`paint`] — the one place raster lives (ENGINE.md S-1). + +pub mod cache; +pub mod damage; +pub mod drawlist; +pub mod frame; +pub mod ident; +pub mod journal; +pub mod oracle; +pub mod paint; +pub mod query; +pub mod replay; +pub mod trace; diff --git a/model-v2/engine/src/oracle.rs b/model-v2/engine/src/oracle.rs new file mode 100644 index 0000000000..27a9134f5b --- /dev/null +++ b/model-v2/engine/src/oracle.rs @@ -0,0 +1,25 @@ +//! ENG-4.2 · oracle version tags. Every content oracle (text shaper, +//! pathops for bool bounds, image-decode metrics) is a versioned pure +//! function; its version is stamped into a replay header and checked on +//! load, so a tier measured under one oracle can never be silently +//! reinterpreted by another. Today only the text metric exists, and it is +//! the lab stub — named honestly rather than pretending to be a shaper +//! (the real shaper is DEC-4 / OS-4a). + +/// The one oracle that exists today: the lab's stub text metric. +pub const TEXT_STUB: &str = "text=stub@lab-0"; + +/// The oracle version set stamped into a replay header. Grows a field per +/// oracle as they land (pathops, image); today it is text only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OracleTags { + pub text: String, +} + +impl Default for OracleTags { + fn default() -> Self { + Self { + text: TEXT_STUB.to_string(), + } + } +} diff --git a/model-v2/engine/src/paint.rs b/model-v2/engine/src/paint.rs new file mode 100644 index 0000000000..ff982849ac --- /dev/null +++ b/model-v2/engine/src/paint.rs @@ -0,0 +1,127 @@ +//! ENG-2.1 · the paint executor — the ONLY module that touches skia +//! (containment rule S-1; a later core/paint crate split is then +//! mechanical). `execute(canvas, drawlist, view, ctx)` (step 6) replays a +//! [`DrawList`](crate::drawlist::DrawList) onto a skia `Canvas`, +//! composing `view.then(&item.world)` per item in the exact mathematical +//! form the current spike painter uses — pixel identity is a property of +//! doing the same float ops in the same order, not a tolerance. + +use anchor_lab::math::Affine; +use skia_safe::{Canvas, Color, Font, ImageInfo, Matrix, Paint, PaintStyle, Rect}; + +use crate::drawlist::{Argb, DrawList, ItemKind}; + +/// Host-supplied paint state the drawlist does not carry: the resolved +/// typeface (mirrors the spike painter's field; `None` skips text, which +/// is today's behavior when no system face is found). +pub struct PaintCtx { + pub font: Option, +} + +/// Row-major `Affine` -> skia `Matrix`, byte-identical to the spike +/// painter's `skia_matrix` (SVG a b c d e f order). +fn skia_matrix(t: &Affine) -> Matrix { + Matrix::new_all(t.a, t.c, t.e, t.b, t.d, t.f, 0.0, 0.0, 1.0) +} + +/// Packed 0xAARRGGBB -> skia `Color`. Colors are already opaque and resolved +/// at build time, so this is a pure repack. +fn color(argb: Argb) -> Color { + Color::new(argb) +} + +/// Replay a [`DrawList`] onto a skia canvas under `view`. Each item composes +/// `view.then(&item.world)` — the exact call and operands the spike painter +/// used (`item.world` is the resolver's world, copied verbatim) — sets it as +/// the absolute matrix, and issues the same skia draw with the same final +/// `Paint` state. Per-item save/restore is pixel-neutral because the matrix +/// is set absolutely each time; a frame's fill and stroke are two items with +/// the same world, so they land identically to the painter's single-save +/// fill-then-stroke. +pub fn execute(canvas: &Canvas, list: &DrawList, view: &Affine, ctx: &PaintCtx) { + for item in &list.items { + let total = view.then(&item.world); + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + match &item.kind { + ItemKind::RectFill { w, h, argb } => { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_color(color(*argb)); + canvas.draw_rect(Rect::from_wh(*w, *h), &p); + } + ItemKind::RectStroke { + w, + h, + argb, + stroke_width, + } => { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_style(PaintStyle::Stroke); + p.set_stroke_width(*stroke_width); + p.set_color(color(*argb)); + canvas.draw_rect(Rect::from_wh(*w, *h), &p); + } + ItemKind::Oval { w, h, argb } => { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_color(color(*argb)); + canvas.draw_oval(Rect::from_wh(*w, *h), &p); + } + ItemKind::Line { + x1, + y1, + x2, + y2, + width, + argb, + } => { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_style(PaintStyle::Stroke); + p.set_stroke_width(*width); + p.set_color(color(*argb)); + canvas.draw_line((*x1, *y1), (*x2, *y2), &p); + } + ItemKind::TextRun { + text, + font_size, + baseline_y, + argb, + } => { + if let Some(tf) = &ctx.font { + let font = Font::new(tf.clone(), *font_size); + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_color(color(*argb)); + canvas.draw_str(text.as_str(), (0.0, *baseline_y), &font, &p); + } + } + } + canvas.restore(); + } +} + +/// Render a drawlist to a fresh raster surface and return its premultiplied +/// pixel bytes — the reference for differential pixel tests (`gate_diff` L2). +/// Bytes, NOT PNG: the encoder is not the system under test, and byte +/// equality is exact (ENG-0.3), not a tolerance. `font: None` in the gate +/// removes font-availability nondeterminism. +pub fn raster_to_bytes(list: &DrawList, view: &Affine, w: i32, h: i32, ctx: &PaintCtx) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + execute(canvas, list, view, ctx); + read_pixels(&mut surface, w, h) +} + +/// Read a raster surface's premultiplied N32 pixels into a byte buffer. +pub fn read_pixels(surface: &mut skia_safe::Surface, w: i32, h: i32) -> Vec { + let info = ImageInfo::new_n32_premul((w, h), None); + let row_bytes = (w * 4) as usize; + let mut buf = vec![0u8; row_bytes * h as usize]; + let ok = surface.read_pixels(&info, &mut buf, row_bytes, (0, 0)); + assert!(ok, "read_pixels failed"); + buf +} diff --git a/model-v2/engine/src/query.rs b/model-v2/engine/src/query.rs new file mode 100644 index 0000000000..19bf859de2 --- /dev/null +++ b/model-v2/engine/src/query.rs @@ -0,0 +1,68 @@ +//! ENG-3 · the spatial read tier — one query API family over the resolved +//! `world_aabb` column. `hit_point` / `nodes_in_rect` / `cull` (step 8) +//! are the ONE door every consumer (HUD, marquee, snapping, culling, +//! pick) goes through, so the index that later sits behind them +//! (broadphase BVH — OS-3a) slots in without changing a call site. Day 1 +//! the bodies are linear walks; the model laws (paint-order topmost, +//! transparent-select promotion, lens post-ops, hairline slop) stay in +//! the lab narrowphase [`anchor_lab::pick`] — an index may over- +//! approximate candidates but never changes what gets selected. + +use anchor_lab::math::RectF; +use anchor_lab::model::{Document, NodeId}; +use anchor_lab::resolve::Resolved; + +/// The spatial read tier — the one door for point-hit, marquee, and cull. +/// Fronts linear walks today; a broadphase index (OS-3a) slots in behind +/// these signatures without touching a caller. Model semantics (paint-order +/// topmost, transparent-select, lens post-ops, hairline slop) live in the lab +/// narrowphase [`anchor_lab::pick`], never here — an index may over- +/// approximate candidates but never changes what is selected. +pub struct Query<'a> { + pub doc: &'a Document, + pub resolved: &'a Resolved, +} + +impl<'a> Query<'a> { + pub fn new(doc: &'a Document, resolved: &'a Resolved) -> Self { + Query { doc, resolved } + } + + /// Topmost node under a world point — delegates the narrowphase, so what + /// gets selected is defined in one place (`pick`), not re-derived here. + pub fn hit_point(&self, x: f32, y: f32) -> Option { + anchor_lab::pick::pick(self.doc, self.resolved, x, y) + } + + /// Nodes whose world AABB overlaps `rect` — marquee candidates. Over- + /// approximates (AABB, not oriented box; the caller refines). Today + /// identical to [`Self::cull`]; they will diverge (marquee gains a + /// contained-vs-touched mode, cull gains subtree granularity). + pub fn nodes_in_rect(&self, rect: RectF) -> Vec { + self.overlapping(rect) + } + + /// Nodes whose world AABB overlaps the viewport — the visible candidate + /// set for culling. See [`Self::nodes_in_rect`]. + pub fn cull(&self, viewport: RectF) -> Vec { + self.overlapping(viewport) + } + + fn overlapping(&self, rect: RectF) -> Vec { + let mut out = Vec::new(); + for id in 0..self.resolved.slot_count() as NodeId { + if let Some(aabb) = self.resolved.aabb_opt(id) { + if aabb_overlap(&aabb, &rect) { + out.push(id); + } + } + } + out + } +} + +/// Inclusive AABB overlap (edge touches count — an over-approximation never +/// misses a candidate). +fn aabb_overlap(a: &RectF, b: &RectF) -> bool { + a.x <= b.x + b.w && b.x <= a.x + a.w && a.y <= b.y + b.h && b.y <= a.y + a.h +} diff --git a/model-v2/engine/src/replay.rs b/model-v2/engine/src/replay.rs new file mode 100644 index 0000000000..e2522552b9 --- /dev/null +++ b/model-v2/engine/src/replay.rs @@ -0,0 +1,220 @@ +//! ENG-5.2 / ENG-5.3 · one corpus, four consumers. A `.replay` file is +//! `(initial document, op log)` — canonical IR text for the document plus +//! JSONL ops — and it is simultaneously the bug repro, the perf-bench +//! input, the fuzz seed, and the conformance fixture. Playing it back +//! yields a bit-identical document AND resolved tier (stands on order- +//! determinism, ENG-0.3, and versioned oracles, ENG-4.3). +//! +//! Id constraint (enforced by the recorder, not by convention): record +//! start normalizes `doc = parse(&print(&doc))`, so node ids are the +//! parse-assigned ones the round-trip law already pins; ids minted mid-log +//! are deterministic (append-only arena). Cross-session replay stays +//! walled until format-level stable ids land (ENG-5.4, a.md §12). +//! +//! Lands in step 10 (needs the lab `serde` feature + `anchor_lab::ops::Op`). + +use anchor_lab::math::{Affine, RectF}; +use anchor_lab::model::Document; +use anchor_lab::ops::{apply, Op, OpResult}; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved, RotationInFlow}; +use anchor_lab::textir; + +use crate::oracle::OracleTags; + +const MAGIC: &str = "#anchor-replay v0"; +const IR_MARK: &str = "--- ir ---"; +const OPS_MARK: &str = "--- ops ---"; + +/// A loaded replay: the oracle tags and resolve options it was recorded +/// under (so it replays faithfully with no external context), the initial +/// (canonical) document, and the op log. +#[derive(Debug, Clone)] +pub struct Replay { + pub tags: OracleTags, + pub opts: ResolveOptions, + pub doc: Document, + pub ops: Vec, +} + +fn arm_word(a: RotationInFlow) -> &'static str { + match a { + RotationInFlow::VisualOnly => "visual", + RotationInFlow::AabbParticipates => "aabb", + } +} + +fn parse_arm(w: &str) -> RotationInFlow { + match w { + "aabb" => RotationInFlow::AabbParticipates, + _ => RotationInFlow::VisualOnly, + } +} + +/// Serialize `(initial, ops)` to the `.replay` text: header + canonical IR + +/// JSONL ops. The document is NORMALIZED (`parse(print(doc))`) so node ids are +/// the parse-assigned ones the round-trip law pins — callers must record ops +/// against that normalized doc (its named nodes keep their ids). +pub fn write_string( + initial: &Document, + ops: &[Op], + tags: &OracleTags, + opts: &ResolveOptions, +) -> String { + let doc = textir::parse(&textir::print(initial)).expect("canonical IR round-trips"); + let ir = textir::print(&doc); + let mut s = String::new(); + s.push_str(MAGIC); + s.push('\n'); + s.push_str("oracle "); + s.push_str(&tags.text); + s.push('\n'); + s.push_str(&format!( + "viewport {} {}\n", + opts.viewport.0, opts.viewport.1 + )); + s.push_str(&format!("arm {}\n", arm_word(opts.rotation_in_flow))); + s.push_str(IR_MARK); + s.push('\n'); + s.push_str(&ir); + if !ir.ends_with('\n') { + s.push('\n'); + } + s.push_str(OPS_MARK); + s.push('\n'); + for op in ops { + s.push_str(&serde_json::to_string(op).expect("op serializes")); + s.push('\n'); + } + s +} + +/// Parse `.replay` text back into a [`Replay`]. +pub fn parse_string(text: &str) -> Result { + let mut lines = text.lines(); + let magic = lines.next().unwrap_or(""); + if magic != MAGIC { + return Err(format!("bad magic: {magic:?} (want {MAGIC:?})")); + } + + enum Sec { + Header, + Ir, + Ops, + } + let mut sec = Sec::Header; + let mut oracle_text = OracleTags::default().text; + let mut viewport = (0.0f32, 0.0f32); + let mut arm = RotationInFlow::VisualOnly; + let mut ir_lines: Vec<&str> = Vec::new(); + let mut op_lines: Vec<&str> = Vec::new(); + + for line in lines { + if line == IR_MARK { + sec = Sec::Ir; + } else if line == OPS_MARK { + sec = Sec::Ops; + } else { + match sec { + Sec::Header => { + if let Some(rest) = line.strip_prefix("oracle ") { + oracle_text = rest.to_string(); + } else if let Some(rest) = line.strip_prefix("viewport ") { + let mut it = rest.split_whitespace(); + let w = it.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + let h = it.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + viewport = (w, h); + } else if let Some(rest) = line.strip_prefix("arm ") { + arm = parse_arm(rest.trim()); + } + } + Sec::Ir => ir_lines.push(line), + Sec::Ops => { + if !line.trim().is_empty() { + op_lines.push(line); + } + } + } + } + } + + let ir = ir_lines.join("\n"); + let doc = textir::parse(&ir).map_err(|e| format!("ir parse: {}", e.0))?; + let mut ops = Vec::with_capacity(op_lines.len()); + for (i, l) in op_lines.iter().enumerate() { + ops.push(serde_json::from_str::(l).map_err(|e| format!("op {i}: {e}"))?); + } + Ok(Replay { + tags: OracleTags { text: oracle_text }, + opts: ResolveOptions { + viewport, + rotation_in_flow: arm, + }, + doc, + ops, + }) +} + +/// Play a replay: apply each op with a FRESH resolve under the replay's own +/// options (mirrors the editor loop), returning the final document and the +/// result sequence. Deterministic by ENG-0.3 — playing twice is bit-identical. +pub fn play(replay: &Replay) -> (Document, Vec) { + let mut doc = replay.doc.clone(); + let mut results = Vec::with_capacity(replay.ops.len()); + for op in &replay.ops { + let r = resolve(&doc, &replay.opts); + results.push(apply(&mut doc, &r, op)); + } + (doc, results) +} + +/// Bit-for-bit equality of two resolved tiers across all four columns — the +/// determinism oracle. `==` would pass -0.0/0.0 and fail NaN==NaN; bits are +/// exact, which is what "same computation" means. +pub fn resolved_bits_eq(a: &Resolved, b: &Resolved) -> bool { + if a.slot_count() != b.slot_count() { + return false; + } + for id in 0..a.slot_count() as u32 { + if !opt_rect_eq(a.box_opt(id), b.box_opt(id)) { + return false; + } + if !opt_aff_eq(a.local_opt(id), b.local_opt(id)) { + return false; + } + if !opt_aff_eq(a.world_opt(id), b.world_opt(id)) { + return false; + } + if !opt_rect_eq(a.aabb_opt(id), b.aabb_opt(id)) { + return false; + } + } + true +} + +fn opt_rect_eq(a: Option, b: Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => { + a.x.to_bits() == b.x.to_bits() + && a.y.to_bits() == b.y.to_bits() + && a.w.to_bits() == b.w.to_bits() + && a.h.to_bits() == b.h.to_bits() + } + _ => false, + } +} + +fn opt_aff_eq(a: Option, b: Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => { + a.a.to_bits() == b.a.to_bits() + && a.b.to_bits() == b.b.to_bits() + && a.c.to_bits() == b.c.to_bits() + && a.d.to_bits() == b.d.to_bits() + && a.e.to_bits() == b.e.to_bits() + && a.f.to_bits() == b.f.to_bits() + } + _ => false, + } +} diff --git a/model-v2/engine/src/trace.rs b/model-v2/engine/src/trace.rs new file mode 100644 index 0000000000..7d978fc5e9 --- /dev/null +++ b/model-v2/engine/src/trace.rs @@ -0,0 +1,60 @@ +//! ENG S-6 · feature-gated observability. With the `trace` feature off, +//! [`t_span!`] expands to the bare block and [`t_count!`] to nothing — +//! zero cost, so the profiler can never distort the profile (the legacy +//! loop measured exactly this trap: devtools cost-prediction polluted +//! plan build until it was gated). With `trace` on, spans accumulate +//! into a thread-local [`sink`] the host or gate drains per frame. +//! +//! Instrument only the three frame seams (resolve / build / execute) — +//! more is noise. The on/off delta is a one-time documented measurement, +//! not a per-frame check. + +#[cfg(feature = "trace")] +pub mod sink { + use std::cell::RefCell; + + thread_local! { + static FRAME: RefCell> = const { RefCell::new(Vec::new()) }; + } + + /// Record a span sample (nanoseconds) under `name`. + pub fn record(name: &'static str, nanos: u128) { + FRAME.with(|f| f.borrow_mut().push((name, nanos))); + } + + /// Take and clear this thread's accumulated spans. + pub fn drain() -> Vec<(&'static str, u128)> { + FRAME.with(|f| f.borrow_mut().drain(..).collect()) + } +} + +/// Time `$body` under `$name`. Off: just the block. On: the block, plus a +/// sample into the thread-local sink. Value-transparent either way. +#[macro_export] +macro_rules! t_span { + ($name:expr, $body:block) => {{ + #[cfg(feature = "trace")] + { + let __t0 = std::time::Instant::now(); + let __r = $body; + $crate::trace::sink::record($name, __t0.elapsed().as_nanos()); + __r + } + #[cfg(not(feature = "trace"))] + { + $body + } + }}; +} + +/// Count an event under `$name`. Off: nothing. On: one nanosecond-free +/// sample (count lives in the same sink as a zero-duration marker). +#[macro_export] +macro_rules! t_count { + ($name:expr) => {{ + #[cfg(feature = "trace")] + { + $crate::trace::sink::record($name, 0); + } + }}; +} diff --git a/model-v2/engine/tests/cache.rs b/model-v2/engine/tests/cache.rs new file mode 100644 index 0000000000..88eea4e780 --- /dev/null +++ b/model-v2/engine/tests/cache.rs @@ -0,0 +1,132 @@ +//! ENG-0.2 / ENG-2 · the scene raster cache must be byte-identical to a fresh +//! render — a fast-but-wrong compositor is the one failure worse than slow. +//! Covers: cache-cold, integer-pan blit (the win path), zoom re-raster (the +//! bitmap-can't-rescale boundary), and doc-dirty re-raster. + +use anchor_engine::cache::{composited_to_bytes, SceneCache}; +use anchor_engine::drawlist::build; +use anchor_engine::paint::{raster_to_bytes, PaintCtx}; +use anchor_lab::math::Affine; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; + +const W: i32 = 1360; +const H: i32 = 900; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (2000.0, 1400.0), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +fn ctx() -> PaintCtx { + PaintCtx { font: None } +} + +/// root + a handful of free shapes (one rotated) at known world positions. +fn scene() -> Document { + let mut b = DocBuilder::new(); + let mk = |x: f32, y: f32, w: f32, h: f32, rot: f32, kind: ShapeDesc| { + let mut hd = Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)); + hd.x = AxisBinding::start(x); + hd.y = AxisBinding::start(y); + hd.rotation = rot; + (hd, Payload::Shape { desc: kind }) + }; + let (h1, p1) = mk(100.0, 100.0, 80.0, 60.0, 0.0, ShapeDesc::Rect); + let a = b.add(0, h1, p1); + let (h2, p2) = mk(240.0, 160.0, 70.0, 70.0, 20.0, ShapeDesc::Rect); + let c = b.add(0, h2, p2); + let (h3, p3) = mk(360.0, 120.0, 90.0, 50.0, 0.0, ShapeDesc::Ellipse); + let e = b.add(0, h3, p3); + let mut doc = b.build(); + doc.get_mut(a).fill = Some("#4A90D9".to_string()); + doc.get_mut(c).fill = Some("#E2574C".to_string()); + doc.get_mut(e).fill = Some("#57B894".to_string()); + doc +} + +fn fresh_bytes(doc: &Document, view: &Affine) -> Vec { + let r = resolve(doc, &opts()); + let list = build(doc, &r); + raster_to_bytes(&list, view, W, H, &ctx()) +} + +#[test] +fn cache_cold_matches_fresh() { + let doc = scene(); + let view = Affine::translate(200.0, 150.0); + let mut cache = SceneCache::new(W, H); + // First frame is a cache-cold re-raster at `view`. + let got = composited_to_bytes(&mut cache, &doc, &opts(), &view, &ctx(), false, W, H); + assert_eq!(got, fresh_bytes(&doc, &view), "cache-cold must equal fresh"); +} + +#[test] +fn integer_pan_blit_matches_fresh() { + let doc = scene(); + let ref_view = Affine::translate(200.0, 150.0); + let panned = Affine::translate(250.0, 180.0); // +50,+30 — integer, within margin + + let mut cache = SceneCache::new(W, H); + // Prime at ref_view (cold), then pan: the second frame is a pure blit. + let _ = composited_to_bytes(&mut cache, &doc, &opts(), &ref_view, &ctx(), false, W, H); + let got = composited_to_bytes(&mut cache, &doc, &opts(), &panned, &ctx(), false, W, H); + + assert_eq!( + got, + fresh_bytes(&doc, &panned), + "an integer-pan blit must be byte-identical to a fresh render" + ); +} + +#[test] +fn zoom_forces_reraster_and_matches_fresh() { + let doc = scene(); + let ref_view = Affine::translate(200.0, 150.0); + let zoomed = Affine { + a: 1.5, + b: 0.0, + c: 0.0, + d: 1.5, + e: 200.0, + f: 150.0, + }; + let mut cache = SceneCache::new(W, H); + let _ = composited_to_bytes(&mut cache, &doc, &opts(), &ref_view, &ctx(), false, W, H); + // Different zoom → a bitmap blit would be wrong; the cache must re-raster. + let got = composited_to_bytes(&mut cache, &doc, &opts(), &zoomed, &ctx(), false, W, H); + assert_eq!( + got, + fresh_bytes(&doc, &zoomed), + "zoom must re-raster crisply" + ); +} + +#[test] +fn doc_dirty_forces_reraster_and_matches_fresh() { + let mut doc = scene(); + let view = Affine::translate(200.0, 150.0); + let mut cache = SceneCache::new(W, H); + let _ = composited_to_bytes(&mut cache, &doc, &opts(), &view, &ctx(), false, W, H); + + // Mutate: move the first shape. With doc_dirty the cache rebuilds + re-rasters. + let r = resolve(&doc, &opts()); + anchor_lab::ops::apply( + &mut doc, + &r, + &anchor_lab::ops::Op::SetX { + id: 1, + value: 400.0, + }, + ) + .unwrap(); + + let got = composited_to_bytes(&mut cache, &doc, &opts(), &view, &ctx(), true, W, H); + assert_eq!( + got, + fresh_bytes(&doc, &view), + "a dirty re-raster must reflect the mutation" + ); +} diff --git a/model-v2/engine/tests/damage.rs b/model-v2/engine/tests/damage.rs new file mode 100644 index 0000000000..5092b32690 --- /dev/null +++ b/model-v2/engine/tests/damage.rs @@ -0,0 +1,78 @@ +//! ENG-2.2 · damage is a data diff of two resolved tiers. Identical tiers +//! produce no damage; a single independent move damages exactly that node +//! (the locality the incremental resolver will exploit — OS-1a). + +use anchor_engine::damage::diff; +use anchor_lab::model::*; +use anchor_lab::ops::{apply, Op}; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +fn free_shape(x: f32, y: f32) -> (Header, Payload) { + let mut h = Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)); + h.x = AxisBinding::start(x); + h.y = AxisBinding::start(y); + ( + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +/// root + two independent free shapes. +fn scene() -> (Document, NodeId, NodeId) { + let mut b = DocBuilder::new(); + let (s1h, s1p) = free_shape(50.0, 50.0); + let s1 = b.add(0, s1h, s1p); + let (s2h, s2p) = free_shape(300.0, 300.0); + let s2 = b.add(0, s2h, s2p); + (b.build(), s1, s2) +} + +#[test] +fn diff_of_identical_is_empty() { + let (doc, ..) = scene(); + let r = resolve(&doc, &opts()); + let d = diff(&r, &r); + assert!(d.is_empty()); + assert!(d.union_world.is_none()); +} + +#[test] +fn moving_one_node_damages_only_that_node() { + let (mut doc, s1, s2) = scene(); + let prev = resolve(&doc, &opts()); + + let r = resolve(&doc, &opts()); + apply( + &mut doc, + &r, + &Op::SetX { + id: s1, + value: 800.0, + }, + ) + .unwrap(); + let next = resolve(&doc, &opts()); + + let d = diff(&prev, &next); + assert_eq!(d.changed, vec![s1], "only the moved leaf is damaged"); + assert!( + !d.changed.contains(&s2), + "the independent sibling is untouched" + ); + assert!( + !d.changed.contains(&doc.root), + "the fixed root is untouched" + ); + // The damage rect covers the node's before and after ink. + let dr = d.union_world.expect("moved node has ink"); + assert!(dr.w >= 40.0 && dr.h >= 40.0, "covers at least the node box"); +} diff --git a/model-v2/engine/tests/drawlist.rs b/model-v2/engine/tests/drawlist.rs new file mode 100644 index 0000000000..e5f9c74ad9 --- /dev/null +++ b/model-v2/engine/tests/drawlist.rs @@ -0,0 +1,158 @@ +//! ENG-2.1 · the drawlist is a pure, deterministic projection whose +//! traversal and item vocabulary reproduce the spike painter exactly. This +//! guards the LOGIC (order, pruning, derived/root skip, color resolution, +//! verbatim world); pixel identity on the real starter scene is proven in +//! the spike A/B (step 6). + +use anchor_engine::drawlist::{build, ItemKind}; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +fn tag(k: &ItemKind) -> &'static str { + match k { + ItemKind::RectFill { .. } => "rectfill", + ItemKind::RectStroke { .. } => "rectstroke", + ItemKind::Oval { .. } => "oval", + ItemKind::Line { .. } => "line", + ItemKind::TextRun { .. } => "text", + } +} + +/// root with: a red frame, rect, ellipse, line, a group (of one rect), +/// text, and a hidden shape. Returns (doc, frame_id, rect_id). +fn scene() -> (Document, NodeId, NodeId) { + let mut b = DocBuilder::new(); + let f = b.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(60.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let r = b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)), + Payload::Shape { + desc: ShapeDesc::Ellipse, + }, + ); + b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(2.0)), + Payload::Shape { + desc: ShapeDesc::Line, + }, + ); + let g = b.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); + b.add( + g, + Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(20.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + b.add( + 0, + Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(20.0)), + Payload::Text { + content: "hi".to_string(), + font_size: 16.0, + }, + ); + let mut hidden_h = Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)); + hidden_h.active = false; + b.add( + 0, + hidden_h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + + let mut doc = b.build(); + doc.get_mut(f).fill = Some("ff0000".to_string()); + (doc, f, r) +} + +#[test] +fn traversal_order_and_pruning() { + let (doc, _f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + let list = build(&doc, &resolved); + + let tags: Vec<&str> = list.items.iter().map(|it| tag(&it.kind)).collect(); + // frame(fill,stroke), rect, ellipse, line, group-child rect, text; + // root & group emit no ink, hidden shape is pruned. + assert_eq!( + tags, + [ + "rectfill", + "rectstroke", + "rectfill", + "oval", + "line", + "rectfill", + "text" + ] + ); +} + +#[test] +fn colors_resolve_like_the_painter() { + let (doc, _f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + let list = build(&doc, &resolved); + + // Frame fill = the node's #ff0000, opaque. + let ItemKind::RectFill { argb, .. } = list.items[0].kind else { + panic!("expected frame fill first"); + }; + assert_eq!(argb, 0xFFFF_0000); + + // Frame stroke = the fixed chrome color. + let ItemKind::RectStroke { argb, .. } = list.items[1].kind else { + panic!("expected frame stroke second"); + }; + assert_eq!(argb, 0xFFC9_CED4); + + // The bare rect (no fill set) = the shape default. + let ItemKind::RectFill { argb, .. } = list.items[2].kind else { + panic!("expected rect fill third"); + }; + assert_eq!(argb, 0xFF4A_90D9); +} + +#[test] +fn world_is_copied_verbatim_not_recomputed() { + let (doc, f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + let list = build(&doc, &resolved); + // The frame's item carries the resolver's world transform unchanged. + assert_eq!(list.items[0].world, resolved.world_of(f)); +} + +#[test] +fn build_is_deterministic() { + let (doc, _f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + assert_eq!(build(&doc, &resolved), build(&doc, &resolved)); +} diff --git a/model-v2/engine/tests/ident.rs b/model-v2/engine/tests/ident.rs new file mode 100644 index 0000000000..a21f56f3ab --- /dev/null +++ b/model-v2/engine/tests/ident.rs @@ -0,0 +1,23 @@ +//! ENG-2.3 · key_of mints a generation-stamped cache key from the arena. +//! A tombstoned slot's key advances, so no future occupant can alias it. + +use anchor_engine::ident::{key_of, Key}; +use anchor_lab::model::*; + +#[test] +fn key_of_reads_generation() { + let mut b = DocBuilder::new(); + let c = b.add( + 0, + Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut doc = b.build(); + + assert_eq!(key_of(&doc, c), Key { id: c, gen: 0 }); + + doc.remove_subtree(c); + assert_eq!(key_of(&doc, c), Key { id: c, gen: 1 }); +} diff --git a/model-v2/engine/tests/journal.rs b/model-v2/engine/tests/journal.rs new file mode 100644 index 0000000000..0db5986311 --- /dev/null +++ b/model-v2/engine/tests/journal.rs @@ -0,0 +1,34 @@ +//! ENG-5.1 · the journal records issued ops in order with their results +//! (errors included) and can report whether anything wrote since a mark — +//! the "keep or drop the undo snapshot" test a gesture uses. + +use anchor_engine::journal::Journal; +use anchor_lab::ops::{Op, OpError}; + +#[test] +fn records_ops_in_order_with_results() { + let mut j = Journal::new(); + assert!(j.is_empty()); + + j.record(Op::SetX { id: 1, value: 5.0 }, Ok(1)); + let after_first = j.len(); + j.record( + Op::SetWidth { id: 1, value: -3.0 }, + Err(OpError::NegativeExtent), + ); + + assert_eq!(j.len(), 2); + let ops: Vec<_> = j.ops().cloned().collect(); + assert_eq!( + ops, + vec![ + Op::SetX { id: 1, value: 5.0 }, + Op::SetWidth { id: 1, value: -3.0 } + ] + ); + + // Only the errored op followed `after_first` — nothing wrote since. + assert!(!j.wrote_since(after_first)); + // From the start, the first op wrote. + assert!(j.wrote_since(0)); +} diff --git a/model-v2/engine/tests/query.rs b/model-v2/engine/tests/query.rs new file mode 100644 index 0000000000..cfd4812064 --- /dev/null +++ b/model-v2/engine/tests/query.rs @@ -0,0 +1,99 @@ +//! ENG-3 · the query facade fronts the lab narrowphase without changing what +//! is selected: `hit_point` equals `anchor_lab::pick::pick` at every point of +//! a sweep (the walk is the oracle), and `nodes_in_rect` finds AABB overlaps. + +use anchor_engine::query::Query; +use anchor_lab::math::RectF; +use anchor_lab::model::*; +use anchor_lab::pick::pick; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +/// root with two free shapes and a group of one rect, at spread positions. +fn scene() -> (Document, NodeId, NodeId, NodeId) { + let mut b = DocBuilder::new(); + + let (mut s1h, s1p) = ( + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + s1h.x = AxisBinding::start(50.0); + s1h.y = AxisBinding::start(50.0); + let s1 = b.add(0, s1h, s1p); + + let (mut s2h, s2p) = ( + Header::new(SizeIntent::Fixed(60.0), SizeIntent::Fixed(30.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + s2h.x = AxisBinding::start(200.0); + s2h.y = AxisBinding::start(120.0); + let s2 = b.add(0, s2h, s2p); + + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(400.0); + gh.y = AxisBinding::start(400.0); + let g = b.add(0, gh, Payload::Group); + b.add( + g, + Header::new(SizeIntent::Fixed(50.0), SizeIntent::Fixed(50.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + + (b.build(), s1, s2, g) +} + +#[test] +fn hit_point_matches_pick_over_grid() { + let (doc, ..) = scene(); + let r = resolve(&doc, &opts()); + let q = Query::new(&doc, &r); + + let mut gx = 0.0; + while gx <= 500.0 { + let mut gy = 0.0; + while gy <= 500.0 { + assert_eq!( + q.hit_point(gx, gy), + pick(&doc, &r, gx, gy), + "hit_point must equal pick at ({gx},{gy})" + ); + gy += 15.0; + } + gx += 15.0; + } +} + +#[test] +fn nodes_in_rect_selects_overlaps() { + let (doc, s1, ..) = scene(); + let r = resolve(&doc, &opts()); + let q = Query::new(&doc, &r); + + let near = q.nodes_in_rect(RectF { + x: 45.0, + y: 45.0, + w: 20.0, + h: 20.0, + }); + assert!(near.contains(&s1), "rect over s1 should include s1"); + + let far = q.nodes_in_rect(RectF { + x: 900.0, + y: 10.0, + w: 5.0, + h: 5.0, + }); + assert!(!far.contains(&s1), "rect far from s1 should not include s1"); +} diff --git a/model-v2/engine/tests/replay.rs b/model-v2/engine/tests/replay.rs new file mode 100644 index 0000000000..5f6de74e27 --- /dev/null +++ b/model-v2/engine/tests/replay.rs @@ -0,0 +1,131 @@ +//! ENG-5.2/5.3 · a replay is (canonical IR + op log). It round-trips through +//! text, and playing it twice is bit-identical (document print, resolved tier, +//! and result sequence) — the determinism the whole optimization program +//! stands on. Ops are recorded against the NORMALIZED doc (ids the round-trip +//! law pins), found here by name. + +use anchor_engine::oracle::OracleTags; +use anchor_engine::replay::{parse_string, play, resolved_bits_eq, write_string}; +use anchor_lab::model::*; +use anchor_lab::ops::{apply, Axis, Op, ResizeDrag}; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; +use anchor_lab::textir; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (1000.0, 1000.0), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +fn named(w: f32, h: f32, name: &str, x: f32, y: f32) -> (Header, Payload) { + let mut h0 = Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)); + h0.x = AxisBinding::start(x); + h0.y = AxisBinding::start(y); + h0.name = Some(name.to_string()); + ( + h0, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +/// A named scene: free shape 'card' and group 'chips' of two shapes. +fn scene() -> Document { + let mut b = DocBuilder::new(); + let (sh, sp) = named(80.0, 40.0, "card", 20.0, 20.0); + b.add(0, sh, sp); + + let mut gh = Header::new(SizeIntent::Auto, SizeIntent::Auto); + gh.x = AxisBinding::start(200.0); + gh.y = AxisBinding::start(60.0); + gh.name = Some("chips".to_string()); + let g = b.add(0, gh, Payload::Group); + let (c1, c1p) = named(30.0, 30.0, "chip.a", 0.0, 0.0); + b.add(g, c1, c1p); + let (c2, c2p) = named(30.0, 30.0, "chip.b", 40.0, 0.0); + b.add(g, c2, c2p); + + b.build() +} + +fn by_name(doc: &Document, name: &str) -> NodeId { + (0..doc.capacity() as NodeId) + .find(|&id| doc.get_opt(id).and_then(|n| n.header.name.as_deref()) == Some(name)) + .expect("named node") +} + +/// Normalize + build the crosszero-then-ungroup op script against it. +fn recorded() -> (Document, Vec) { + let doc = textir::parse(&textir::print(&scene())).expect("normalize"); + let r = resolve(&doc, &opts()); + let card = by_name(&doc, "card"); + let chips = by_name(&doc, "chips"); + + // card: drag the right edge past the left → cross-zero flip. + let drag = ResizeDrag::begin(&doc, &r, card, Axis::X, AnchorEdge::End).unwrap(); + let b = r.box_of(card); + let ops = vec![ + Op::ResizeDrag { + id: card, + drag, + target: b.x + b.w + 30.0, + }, + Op::Ungroup { id: chips }, + ]; + (doc, ops) +} + +#[test] +fn round_trips_through_text() { + let (doc, ops) = recorded(); + let text = write_string(&doc, &ops, &OracleTags::default(), &opts()); + let replay = parse_string(&text).expect("parse replay"); + + assert!(replay.doc == doc, "initial doc survives the round-trip"); + assert_eq!(replay.ops, ops, "op log survives the round-trip"); + assert_eq!(replay.tags, OracleTags::default(), "oracle tags survive"); + assert_eq!(replay.opts.viewport, opts().viewport, "viewport survives"); +} + +#[test] +fn playing_twice_is_bit_identical() { + let (doc, ops) = recorded(); + let replay = parse_string(&write_string(&doc, &ops, &OracleTags::default(), &opts())).unwrap(); + + let (d1, res1) = play(&replay); + let (d2, res2) = play(&replay); + + assert_eq!( + textir::print(&d1), + textir::print(&d2), + "document print equal" + ); + assert!( + resolved_bits_eq(&resolve(&d1, &opts()), &resolve(&d2, &opts())), + "resolved tier bit-identical" + ); + assert_eq!(res1, res2, "result sequence equal"); + + // Not a trivial no-op run: the script actually mutated the document. + assert!( + textir::print(&d1) != textir::print(&replay.doc), + "the ops must have changed the document" + ); +} + +#[test] +fn play_equals_direct_application() { + let (doc, ops) = recorded(); + let replay = parse_string(&write_string(&doc, &ops, &OracleTags::default(), &opts())).unwrap(); + let (played, _) = play(&replay); + + // Apply the same ops directly to the same start — play must match. + let mut direct = replay.doc.clone(); + for op in &ops { + let r = resolve(&direct, &opts()); + let _ = apply(&mut direct, &r, op); + } + assert_eq!(textir::print(&played), textir::print(&direct)); +} From f5eeb829cf2e51a7f2880347a75aa85ba3bcc989 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 8 Jul 2026 17:51:36 +0900 Subject: [PATCH 07/33] docs(model-v2/engine): MEASURE doctrine + the perf harness it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance is not one question, so it is not one tool — it splits on a hard line between what a machine can measure alone and what needs a human. This is the doctrine that stops us measuring the wrong axis (as a session's detour did: render *work* was green while the felt lag lived in the *present path*). MEASURE.md — the four axes and which tool owns each: - work + correctness are automated & headless (probe / probe_gpu / gate) — the default loop, no human. - feel & pacing need a real window: the opt-in ANCHOR_FRAMELOG channel (human runs + interacts + kills; the reviewer reads the per-frame gap/stage log). An on-demand bridge, not the iteration loop. - input→photon latency is NOT software-measurable — the lag accrues in the OS compositor/display after swap_buffers returns; measuring it needs a photodiode rig or a human glance. Named so a green gate is never mistaken for good feel. The automated work axis it documents: - bin/probe — deterministic CPU raster, per-stage distributions, JSON baselines, panicking audit-guards. The always-on regression detector. - bin/probe_gpu — headless surfaceless-GL, real flush_and_submit timing, for the wins whose value is a persistent GPU texture (CPU raster reports the wrong sign there). Worked example folded in: render-on-demand (idle frames 466 → 2, throughput 125fps median — the symptom was pacing/latency, not work). --- model-v2/engine/MEASURE.md | 136 ++++ model-v2/engine/rig/probe-baseline.json | 821 ++++++++++++++++++++++++ model-v2/engine/src/bin/probe.rs | 627 ++++++++++++++++++ model-v2/engine/src/bin/probe_gpu.rs | 352 ++++++++++ 4 files changed, 1936 insertions(+) create mode 100644 model-v2/engine/MEASURE.md create mode 100644 model-v2/engine/rig/probe-baseline.json create mode 100644 model-v2/engine/src/bin/probe.rs create mode 100644 model-v2/engine/src/bin/probe_gpu.rs diff --git a/model-v2/engine/MEASURE.md b/model-v2/engine/MEASURE.md new file mode 100644 index 0000000000..9324dbfc2c --- /dev/null +++ b/model-v2/engine/MEASURE.md @@ -0,0 +1,136 @@ +# MEASURE — how the engine proves it is fast, and who asks + +Performance is not one question, so it is not one tool. It splits along a hard +line: **what a machine can measure alone, and what needs a human.** The whole +discipline is to know which axis a symptom lives on and reach for the matching +tool — never the wrong one. This session cost us a full detour because render +_work_ was measured (headless, green) while the felt lag lived in the _present +path_ — a different axis entirely, invisible to those tools. This doc draws the +line so we don't cross it again. + +## The four axes + +| axis | the question | tool | automated? | human? | +| ----------------- | ----------------------------------------------- | ------------------------------------------------------------ | ------------------------------------- | ----------------- | +| **work** | is the engine _doing_ too much per frame? | `bin/probe` (CPU raster), `bin/probe_gpu` (headless GL) | yes — headless, deterministic | none | +| **correctness** | did an optimization change a pixel? | `bin/gate` → `gate_diff` (L1 drawlist ==, L2 raster byte ==) | yes — headless, deterministic | none | +| **feel / pacing** | does it _stutter_ or _lag_ in a real window? | the **auxiliary frame log** (below) | no — needs a real window + real input | runs + reviews | +| **input→photon** | does the photon feel _instant_ after the input? | — (a human eye, or a hardware latency rig) | **no — not software-measurable** | is the instrument | + +The top two are the daily loop: fast, repeatable, no one in the room. The bottom +two are the subject of this doc — the axes where a machine cannot stand in for a +person, and where reaching for a tool has a human cost. + +## The automated loop (axes 1–2) — the default + +Almost all perf work is _reducing work_ and _proving it still renders the same +pixels_. Both are fully headless and belong in the tight iteration loop: + +- **`probe`** — deterministic CPU raster of scenario scenes (view / mutation / + animation axes), per-stage (`resolve`/`build`/`execute`), p50–p99 + distributions, JSON baselines, panicking audit-guards. The always-on + regression detector. +- **`probe_gpu`** — headless surfaceless-GL, real `flush_and_submit` wall time. + The escalation for the two wins whose value is a persistent GPU texture (scene + cache, layerization), where CPU raster would report the wrong _sign_. +- **`gate` / `gate_diff`** — the oracle law: every optimization ships a + differential proving `optimized == reference`, byte-identical. No fast-but- + wrong compositor ever lands. + +If a question can be answered here, it **must** be — a human is never spent on +what a machine can settle. + +## The auxiliary channel (axis 3) — human-in-the-loop, on demand + +Some symptoms only exist in a real window: frame **pacing** (the gap _between_ +draws, not the work _inside_ one), present-queue depth, redraw scheduling. No +headless probe sees them — there is no display, no vsync, no compositor. For +these, and **only** these, we deliberately spend a human. + +**The instrument** is the live spike, opt-in behind an env var so its per-frame +file I/O never taxes a normal run: + +```sh +ANCHOR_FRAMELOG=1 cargo run --release # in model-v2/a/spike-canvas +# …interact (pan / zoom / drag) to reproduce the felt symptom, then kill it. +# Inspect the log — the human runs, the reviewer reads: +cat /tmp/anchor-spike-frames.log +``` + +Each line is one real frame, flushed immediately so a hard kill mid-gesture +still preserves it: + +``` +f1234 gap 8.01ms ( 124.8fps) draw 1.90 = res 0.02 scene 0.31 flush 0.44 egui 0.61 gpu 0.52 vsync 0.00 | pan=1 drag=0 cursor=(812,410) damage=0 zoom=1.00 +``` + +- **`gap`** — wall time since the previous frame. This is the **true fps** and + the true judder signal: it counts time spent _outside_ `draw()` (event + scheduling, present-queue waits) that per-stage numbers structurally cannot. + Read its _distribution_, not its mean — 125fps median with 35ms spikes still + _feels_ like 30. Judder is variance, and variance is a number. +- **`draw = res + scene + flush + egui + gpu + vsync`** — the per-stage split of + our own frame. `gpu` is a real `glFinish` (blocks until the GPU is actually + done), so whatever `vsync` (the raw `swap_buffers`) spends _after_ it is pure + display-pacing idle, **not our cost**. This split is the one that answers "are + _we_ slow, or is the display pacing us?" — a raw swap wall only ever shows + multiples of the refresh interval and hides the truth. +- **`pan` / `drag` / `cursor` / `damage` / `zoom`** — the gesture context, so a + bad frame correlates to what the hand was doing. + +**When to reach for it:** only when a felt symptom (stutter, lag) cannot be +reproduced headlessly. It is a **bridge, not the loop** — a way for a person to +_show_ the engine a symptom the automated tools are blind to, once, so it can be +diagnosed and then chased with a proxy. It is not how we iterate day to day. + +## The hard limit (axis 4) — named so we never fool ourselves + +**Input-to-photon latency cannot be measured in software.** The lag that matters +— finger moves, glass updates — accrues in the OS compositor and the display +_after_ `swap_buffers` returns, where no timer of ours reaches. Every serious +measurement of it (NVIDIA LDAT, the research literature) puts a **photodiode or +high-speed camera on the physical screen**, triggered by a known input. So this +axis has an irreducible human/hardware core: + +- **today:** a human glance — "does panning feel tight now?" — is the + instrument. That is not a gap in our tooling; it is the nature of the quantity. +- **if we ever want it automated:** a hardware latency rig (a photodiode on the + panel + a scripted input trigger) — a real but bounded project, not a timer we + forgot to add. + +The frame log reaches the _app-side_ boundary (input event → present submit); +the last hop to the photon is the human's. Green headless gates say the engine +is doing little work and drawing correct pixels — they say **nothing** about how +it feels. Keeping this axis explicit is what stops us mistaking one for the other. + +## Discipline + +- **Pick the axis for the symptom.** "Too much work" → `probe`. "Wrong pixel" → + `gate_diff`. "Stutters" → the frame log. "Doesn't feel instant" → the human + glance. Measuring the wrong axis is the failure that cost us a round. +- **The human channel is opt-in and rare.** `ANCHOR_FRAMELOG` is off by default; + reaching for a person is a cost, spent only when a machine provably can't. +- **Green ≠ good feel.** Axes 1–2 and axes 3–4 are orthogonal. Never let a green + gate stand in for a feel check, or a good feel excuse a work regression. + +### Worked example — the render-on-demand fix (this session) + +Headless probes were green; the owner reported panning "painfully slow." The +frame log (axis 3) settled it: throughput was **125fps median** — not the +sub-30 it _felt_ — so the symptom was pacing + latency, not work. The `gap` +column plus the continuous-redraw pattern pointed at the app rendering every +frame and racing vsync into a deep present queue. The fix (render-on-demand: +draw only on real input) dropped idle frames **466 → 2**, verified headlessly — +and the human glance (axis 4) confirmed the feel: _"its butter now."_ Four axes, +each doing the one job the others can't. + +### The one automation still owed + +The frame log is manual: a human runs, interacts, kills; the reviewer reads the +file. The named next step is to **formalize it** — a scripted `--scenario` +mode that drives a deterministic pan/zoom/mutate through the _real_ frame loop, +emits the same per-frame breakdown as JSON with pacing metrics (gap p99, +variance, frames-over-budget), and exits. That turns axis 3 into a repeatable, +baseline-able tool (a window flashes, but no human _input_ is needed) — leaving +only axis 4 as irreducibly human. Until then, axis 3 stays the manual bridge +this doc describes. diff --git a/model-v2/engine/rig/probe-baseline.json b/model-v2/engine/rig/probe-baseline.json new file mode 100644 index 0000000000..1dcc4ae800 --- /dev/null +++ b/model-v2/engine/rig/probe-baseline.json @@ -0,0 +1,821 @@ +[ + { + "scenario": "view_pan", + "n": 1000, + "resolve_ms": { + "min": 0.0343, + "p50": 0.0352, + "p95": 0.043, + "p99": 0.0498, + "max": 0.0504, + "mean": 0.0369 + }, + "build_ms": { + "min": 0.0124, + "p50": 0.013, + "p95": 0.0195, + "p99": 0.0266, + "max": 0.0317, + "mean": 0.014 + }, + "execute_ms": { + "min": 1.6387, + "p50": 1.8809, + "p95": 2.1659, + "p99": 2.3393, + "max": 2.5572, + "mean": 1.8884 + }, + "full_ms": { + "min": 1.6859, + "p50": 1.928, + "p95": 2.2157, + "p99": 2.4158, + "max": 2.6171, + "mean": 1.9394 + }, + "fps_mean": 515.63, + "fps_p99": 413.95, + "changed_p50": 0 + }, + { + "scenario": "view_pan", + "n": 10000, + "resolve_ms": { + "min": 0.342, + "p50": 0.3594, + "p95": 0.4203, + "p99": 0.4781, + "max": 0.8141, + "mean": 0.3676 + }, + "build_ms": { + "min": 0.1094, + "p50": 0.1124, + "p95": 0.1346, + "p99": 0.1718, + "max": 0.2258, + "mean": 0.117 + }, + "execute_ms": { + "min": 8.6811, + "p50": 9.535, + "p95": 10.592, + "p99": 11.7108, + "max": 26.3637, + "mean": 9.6185 + }, + "full_ms": { + "min": 9.1568, + "p50": 10.0123, + "p95": 11.0745, + "p99": 12.3277, + "max": 26.8929, + "mean": 10.1033 + }, + "fps_mean": 98.98, + "fps_p99": 81.12, + "changed_p50": 0 + }, + { + "scenario": "view_pan", + "n": 100000, + "resolve_ms": { + "min": 5.3755, + "p50": 6.1483, + "p95": 6.5435, + "p99": 6.9323, + "max": 15.2585, + "mean": 6.1659 + }, + "build_ms": { + "min": 1.6247, + "p50": 1.7667, + "p95": 1.9552, + "p99": 2.0275, + "max": 2.8658, + "mean": 1.7823 + }, + "execute_ms": { + "min": 53.1384, + "p50": 57.7396, + "p95": 61.2272, + "p99": 62.201, + "max": 71.6192, + "mean": 57.6977 + }, + "full_ms": { + "min": 60.9353, + "p50": 65.6607, + "p95": 69.2316, + "p99": 74.103, + "max": 79.5518, + "mean": 65.6463 + }, + "fps_mean": 15.23, + "fps_p99": 13.49, + "changed_p50": 0 + }, + { + "scenario": "view_zoom", + "n": 1000, + "resolve_ms": { + "min": 0.0343, + "p50": 0.0366, + "p95": 0.0454, + "p99": 0.0475, + "max": 0.0597, + "mean": 0.0379 + }, + "build_ms": { + "min": 0.0122, + "p50": 0.0128, + "p95": 0.0206, + "p99": 0.0217, + "max": 0.0227, + "mean": 0.014 + }, + "execute_ms": { + "min": 1.48, + "p50": 1.9061, + "p95": 2.2758, + "p99": 2.3125, + "max": 2.3528, + "mean": 1.7992 + }, + "full_ms": { + "min": 1.531, + "p50": 1.961, + "p95": 2.3231, + "p99": 2.3598, + "max": 2.4032, + "mean": 1.8512 + }, + "fps_mean": 540.19, + "fps_p99": 423.77, + "changed_p50": 0 + }, + { + "scenario": "view_zoom", + "n": 10000, + "resolve_ms": { + "min": 0.3403, + "p50": 0.3508, + "p95": 0.4297, + "p99": 0.5167, + "max": 0.5672, + "mean": 0.3625 + }, + "build_ms": { + "min": 0.1087, + "p50": 0.113, + "p95": 0.1359, + "p99": 0.1841, + "max": 0.2134, + "mean": 0.1173 + }, + "execute_ms": { + "min": 6.5505, + "p50": 9.6422, + "p95": 10.6251, + "p99": 11.1559, + "max": 11.2849, + "mean": 8.5751 + }, + "full_ms": { + "min": 7.0426, + "p50": 10.1059, + "p95": 11.0957, + "p99": 11.6369, + "max": 11.8171, + "mean": 9.055 + }, + "fps_mean": 110.44, + "fps_p99": 85.93, + "changed_p50": 0 + }, + { + "scenario": "view_zoom", + "n": 100000, + "resolve_ms": { + "min": 5.1335, + "p50": 6.1813, + "p95": 6.5258, + "p99": 6.8102, + "max": 7.015, + "mean": 6.1121 + }, + "build_ms": { + "min": 1.5874, + "p50": 1.7359, + "p95": 1.945, + "p99": 2.0582, + "max": 2.0873, + "mean": 1.755 + }, + "execute_ms": { + "min": 38.3286, + "p50": 59.8702, + "p95": 61.4187, + "p99": 62.4284, + "max": 62.8786, + "mean": 49.8839 + }, + "full_ms": { + "min": 45.7382, + "p50": 66.9515, + "p95": 69.5994, + "p99": 70.7322, + "max": 71.2214, + "mean": 57.7514 + }, + "fps_mean": 17.32, + "fps_p99": 14.14, + "changed_p50": 0 + }, + { + "scenario": "mutate_move", + "n": 1000, + "resolve_ms": { + "min": 0.0343, + "p50": 0.038, + "p95": 0.0465, + "p99": 0.0527, + "max": 0.0659, + "mean": 0.0388 + }, + "build_ms": { + "min": 0.0123, + "p50": 0.0132, + "p95": 0.0204, + "p99": 0.0217, + "max": 0.0249, + "mean": 0.0144 + }, + "execute_ms": { + "min": 1.8739, + "p50": 1.9237, + "p95": 2.2849, + "p99": 2.3494, + "max": 2.386, + "mean": 1.9948 + }, + "full_ms": { + "min": 1.921, + "p50": 1.979, + "p95": 2.3394, + "p99": 2.4071, + "max": 2.4425, + "mean": 2.0481 + }, + "fps_mean": 488.27, + "fps_p99": 415.44, + "changed_p50": 1 + }, + { + "scenario": "mutate_move", + "n": 10000, + "resolve_ms": { + "min": 0.343, + "p50": 0.3598, + "p95": 0.4441, + "p99": 0.5194, + "max": 0.5371, + "mean": 0.3686 + }, + "build_ms": { + "min": 0.1105, + "p50": 0.1208, + "p95": 0.135, + "p99": 0.1496, + "max": 0.2218, + "mean": 0.123 + }, + "execute_ms": { + "min": 9.6144, + "p50": 10.128, + "p95": 10.9086, + "p99": 11.3018, + "max": 11.3914, + "mean": 10.2023 + }, + "full_ms": { + "min": 10.0835, + "p50": 10.6169, + "p95": 11.3895, + "p99": 11.8607, + "max": 11.8837, + "mean": 10.6941 + }, + "fps_mean": 93.51, + "fps_p99": 84.31, + "changed_p50": 1 + }, + { + "scenario": "mutate_move", + "n": 100000, + "resolve_ms": { + "min": 4.8745, + "p50": 5.7093, + "p95": 6.5166, + "p99": 6.8032, + "max": 22.7436, + "mean": 5.7679 + }, + "build_ms": { + "min": 1.5898, + "p50": 1.7023, + "p95": 1.8754, + "p99": 2.0102, + "max": 3.3695, + "mean": 1.7279 + }, + "execute_ms": { + "min": 59.8379, + "p50": 60.7454, + "p95": 61.831, + "p99": 63.0724, + "max": 64.7415, + "mean": 60.795 + }, + "full_ms": { + "min": 67.2258, + "p50": 67.938, + "p95": 69.7791, + "p99": 71.5693, + "max": 88.131, + "mean": 68.2911 + }, + "fps_mean": 14.64, + "fps_p99": 13.97, + "changed_p50": 1 + }, + { + "scenario": "mutate_rotate", + "n": 1000, + "resolve_ms": { + "min": 0.0351, + "p50": 0.0356, + "p95": 0.0385, + "p99": 0.0399, + "max": 0.0411, + "mean": 0.0359 + }, + "build_ms": { + "min": 0.0124, + "p50": 0.0128, + "p95": 0.0148, + "p99": 0.0162, + "max": 0.0207, + "mean": 0.0131 + }, + "execute_ms": { + "min": 1.923, + "p50": 1.9391, + "p95": 2.0377, + "p99": 2.1883, + "max": 5.3966, + "mean": 1.9664 + }, + "full_ms": { + "min": 1.9711, + "p50": 1.9876, + "p95": 2.0905, + "p99": 2.2415, + "max": 5.4514, + "mean": 2.0154 + }, + "fps_mean": 496.17, + "fps_p99": 446.14, + "changed_p50": 1 + }, + { + "scenario": "mutate_rotate", + "n": 10000, + "resolve_ms": { + "min": 0.3434, + "p50": 0.3489, + "p95": 0.3558, + "p99": 0.3831, + "max": 0.4041, + "mean": 0.3504 + }, + "build_ms": { + "min": 0.1095, + "p50": 0.1118, + "p95": 0.1154, + "p99": 0.1194, + "max": 0.1225, + "mean": 0.1122 + }, + "execute_ms": { + "min": 9.6495, + "p50": 9.8116, + "p95": 9.8703, + "p99": 10.4041, + "max": 11.2077, + "mean": 9.8265 + }, + "full_ms": { + "min": 10.1485, + "p50": 10.2727, + "p95": 10.3449, + "p99": 10.8695, + "max": 11.7086, + "mean": 10.2892 + }, + "fps_mean": 97.19, + "fps_p99": 92.0, + "changed_p50": 1 + }, + { + "scenario": "mutate_rotate", + "n": 100000, + "resolve_ms": { + "min": 4.8747, + "p50": 5.1325, + "p95": 6.1923, + "p99": 6.5051, + "max": 6.7512, + "mean": 5.2572 + }, + "build_ms": { + "min": 1.6177, + "p50": 1.7056, + "p95": 1.7792, + "p99": 1.8683, + "max": 1.9161, + "mean": 1.7106 + }, + "execute_ms": { + "min": 59.8588, + "p50": 60.642, + "p95": 61.0684, + "p99": 62.5817, + "max": 76.6184, + "mean": 60.7316 + }, + "full_ms": { + "min": 67.0484, + "p50": 67.4724, + "p95": 68.9412, + "p99": 70.1058, + "max": 83.3936, + "mean": 67.6996 + }, + "fps_mean": 14.77, + "fps_p99": 14.26, + "changed_p50": 1 + }, + { + "scenario": "mutate_color", + "n": 1000, + "resolve_ms": { + "min": 0.0351, + "p50": 0.0355, + "p95": 0.0358, + "p99": 0.0396, + "max": 0.0453, + "mean": 0.0356 + }, + "build_ms": { + "min": 0.0124, + "p50": 0.0127, + "p95": 0.0143, + "p99": 0.0144, + "max": 0.0145, + "mean": 0.0129 + }, + "execute_ms": { + "min": 1.9255, + "p50": 1.9368, + "p95": 1.9498, + "p99": 1.9564, + "max": 1.9656, + "mean": 1.9379 + }, + "full_ms": { + "min": 1.9735, + "p50": 1.9851, + "p95": 1.9984, + "p99": 2.0057, + "max": 2.0137, + "mean": 1.9864 + }, + "fps_mean": 503.43, + "fps_p99": 498.58, + "changed_p50": 0 + }, + { + "scenario": "mutate_color", + "n": 10000, + "resolve_ms": { + "min": 0.3475, + "p50": 0.3489, + "p95": 0.3579, + "p99": 0.3715, + "max": 0.4158, + "mean": 0.3505 + }, + "build_ms": { + "min": 0.1103, + "p50": 0.1114, + "p95": 0.1134, + "p99": 0.1174, + "max": 0.1184, + "mean": 0.1117 + }, + "execute_ms": { + "min": 9.7894, + "p50": 9.8256, + "p95": 9.871, + "p99": 10.367, + "max": 10.5442, + "mean": 9.8387 + }, + "full_ms": { + "min": 10.2509, + "p50": 10.287, + "p95": 10.3456, + "p99": 10.8998, + "max": 11.0465, + "mean": 10.301 + }, + "fps_mean": 97.08, + "fps_p99": 91.74, + "changed_p50": 0 + }, + { + "scenario": "mutate_color", + "n": 100000, + "resolve_ms": { + "min": 4.8849, + "p50": 5.1253, + "p95": 6.0227, + "p99": 6.4251, + "max": 7.205, + "mean": 5.2433 + }, + "build_ms": { + "min": 1.5892, + "p50": 1.7032, + "p95": 1.7709, + "p99": 1.8152, + "max": 1.8732, + "mean": 1.707 + }, + "execute_ms": { + "min": 60.1033, + "p50": 60.5253, + "p95": 61.1626, + "p99": 62.3224, + "max": 73.7911, + "mean": 60.6635 + }, + "full_ms": { + "min": 67.0042, + "p50": 67.3926, + "p95": 68.7306, + "p99": 70.15, + "max": 80.7237, + "mean": 67.614 + }, + "fps_mean": 14.79, + "fps_p99": 14.26, + "changed_p50": 0 + }, + { + "scenario": "anim_transform_1", + "n": 10000, + "resolve_ms": { + "min": 0.3477, + "p50": 0.3491, + "p95": 0.3668, + "p99": 0.4395, + "max": 0.5266, + "mean": 0.3531 + }, + "build_ms": { + "min": 0.1105, + "p50": 0.112, + "p95": 0.119, + "p99": 0.1306, + "max": 0.174, + "mean": 0.1132 + }, + "execute_ms": { + "min": 9.8057, + "p50": 9.8391, + "p95": 10.2503, + "p99": 11.4777, + "max": 36.3306, + "mean": 9.9998 + }, + "full_ms": { + "min": 10.2658, + "p50": 10.3011, + "p95": 10.7289, + "p99": 12.042, + "max": 36.8007, + "mean": 10.4662 + }, + "fps_mean": 95.55, + "fps_p99": 83.04, + "changed_p50": 1 + }, + { + "scenario": "anim_transform_1", + "n": 100000, + "resolve_ms": { + "min": 4.86, + "p50": 5.1688, + "p95": 6.2189, + "p99": 6.577, + "max": 6.6409, + "mean": 5.3203 + }, + "build_ms": { + "min": 1.6235, + "p50": 1.7016, + "p95": 1.808, + "p99": 1.8421, + "max": 1.9283, + "mean": 1.7123 + }, + "execute_ms": { + "min": 60.2673, + "p50": 60.5217, + "p95": 61.2596, + "p99": 62.8327, + "max": 76.6633, + "mean": 60.6869 + }, + "full_ms": { + "min": 67.0113, + "p50": 67.4412, + "p95": 68.788, + "p99": 70.0324, + "max": 85.0765, + "mean": 67.7197 + }, + "fps_mean": 14.77, + "fps_p99": 14.28, + "changed_p50": 1 + }, + { + "scenario": "anim_transform_100", + "n": 10000, + "resolve_ms": { + "min": 0.3406, + "p50": 0.3493, + "p95": 0.3621, + "p99": 0.389, + "max": 0.3973, + "mean": 0.3512 + }, + "build_ms": { + "min": 0.1087, + "p50": 0.1118, + "p95": 0.1161, + "p99": 0.1237, + "max": 0.1366, + "mean": 0.1125 + }, + "execute_ms": { + "min": 9.7372, + "p50": 9.8612, + "p95": 10.0061, + "p99": 10.1608, + "max": 10.4888, + "mean": 9.8829 + }, + "full_ms": { + "min": 10.1914, + "p50": 10.324, + "p95": 10.4735, + "p99": 10.6332, + "max": 10.9523, + "mean": 10.3467 + }, + "fps_mean": 96.65, + "fps_p99": 94.04, + "changed_p50": 100 + }, + { + "scenario": "anim_transform_100", + "n": 100000, + "resolve_ms": { + "min": 4.9061, + "p50": 5.1502, + "p95": 6.2282, + "p99": 6.702, + "max": 7.0521, + "mean": 5.2881 + }, + "build_ms": { + "min": 1.5995, + "p50": 1.7051, + "p95": 1.7984, + "p99": 1.8585, + "max": 1.8951, + "mean": 1.7101 + }, + "execute_ms": { + "min": 60.6176, + "p50": 60.7778, + "p95": 61.4765, + "p99": 62.272, + "max": 78.2182, + "mean": 60.9339 + }, + "full_ms": { + "min": 67.2101, + "p50": 67.6597, + "p95": 69.0854, + "p99": 70.3203, + "max": 85.9567, + "mean": 67.9322 + }, + "fps_mean": 14.72, + "fps_p99": 14.22, + "changed_p50": 100 + }, + { + "scenario": "anim_color_100", + "n": 10000, + "resolve_ms": { + "min": 0.3471, + "p50": 0.3488, + "p95": 0.3575, + "p99": 0.3822, + "max": 0.3854, + "mean": 0.3502 + }, + "build_ms": { + "min": 0.1104, + "p50": 0.1117, + "p95": 0.1177, + "p99": 0.1194, + "max": 0.1255, + "mean": 0.1123 + }, + "execute_ms": { + "min": 9.8363, + "p50": 9.8649, + "p95": 10.0397, + "p99": 10.416, + "max": 10.7073, + "mean": 9.8903 + }, + "full_ms": { + "min": 10.2971, + "p50": 10.3263, + "p95": 10.5056, + "p99": 10.8803, + "max": 11.1923, + "mean": 10.3529 + }, + "fps_mean": 96.59, + "fps_p99": 91.91, + "changed_p50": 0 + }, + { + "scenario": "anim_color_100", + "n": 100000, + "resolve_ms": { + "min": 4.8946, + "p50": 5.1736, + "p95": 6.289, + "p99": 6.7428, + "max": 7.1405, + "mean": 5.3373 + }, + "build_ms": { + "min": 1.5749, + "p50": 1.6933, + "p95": 1.8049, + "p99": 1.8773, + "max": 1.8907, + "mean": 1.7019 + }, + "execute_ms": { + "min": 60.7546, + "p50": 60.9563, + "p95": 61.8597, + "p99": 64.755, + "max": 85.8519, + "mean": 61.2449 + }, + "full_ms": { + "min": 67.485, + "p50": 67.8263, + "p95": 69.7644, + "p99": 73.272, + "max": 94.7767, + "mean": 68.2843 + }, + "fps_mean": 14.64, + "fps_p99": 13.65, + "changed_p50": 0 + } +] diff --git a/model-v2/engine/src/bin/probe.rs b/model-v2/engine/src/bin/probe.rs new file mode 100644 index 0000000000..36555e9ebb --- /dev/null +++ b/model-v2/engine/src/bin/probe.rs @@ -0,0 +1,627 @@ +//! probe — the autonomous render-perf harness (deterministic CPU raster). +//! +//! Drives the real `resolve -> drawlist::build -> paint::execute` seam across +//! three axes — VIEW (pan/zoom, no doc mutation), MUTATION (one node/frame), +//! ANIMATION (N nodes/frame) — at several scene sizes, reporting per-STAGE +//! distributions (min/p50/p95/p99/max/mean) with fps at mean AND p99. 120fps = +//! 8.333 ms/frame. +//! +//! Why raster, not GPU: deterministic + low-variance, so a ~5% regression is +//! detectable unattended, and correct-signed for every CPU-side win (retained +//! drawlist, cull, per-draw overhead). The two GPU-only wins (scene raster +//! cache, layerization) are validated by `probe_gpu` instead — on raster a +//! texture re-blit inverts sign. +//! +//! Trust guards (A1..A5) PANIC rather than print pretty-but-fake numbers: +//! a VIEW frame that actually re-resolved, a mutation frame that changed +//! nothing, camera drift off-scene, or `black_box` not biting all abort. +//! +//! Usage: +//! cargo run --release --bin probe # full table +//! cargo run --release --bin probe -- --quick # fewer frames / sizes +//! cargo run --release --bin probe -- --json # machine-readable +//! cargo run --release --bin probe -- --bless F # write baseline JSON +//! cargo run --release --bin probe -- --baseline F # diff vs baseline + +use std::hint::black_box; +use std::time::Instant; + +use anchor_engine::cache::SceneCache; +use anchor_engine::{damage, frame::render, paint::PaintCtx}; +use anchor_lab::math::{Affine, RectF}; +use anchor_lab::model::*; +use anchor_lab::ops::{self, dirty_class, Op, PhaseMask}; +use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; + +const W: f32 = 1360.0; +const H: f32 = 900.0; +const WARMUP: usize = 20; + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (W, H), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +// ── distributions ───────────────────────────────────────────────────────── + +struct Dist { + min: f64, + p50: f64, + p95: f64, + p99: f64, + max: f64, + mean: f64, +} + +impl Dist { + /// From nanosecond samples → milliseconds. + fn from_ns(samples: &[u128]) -> Dist { + let mut ms: Vec = samples.iter().map(|&n| n as f64 / 1e6).collect(); + ms.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let pick = |q: f64| ms[((ms.len() as f64 * q) as usize).min(ms.len() - 1)]; + Dist { + min: ms[0], + p50: pick(0.50), + p95: pick(0.95), + p99: pick(0.99), + max: ms[ms.len() - 1], + mean: ms.iter().sum::() / ms.len() as f64, + } + } + fn json(&self) -> String { + format!( + "{{\"min\":{:.4},\"p50\":{:.4},\"p95\":{:.4},\"p99\":{:.4},\"max\":{:.4},\"mean\":{:.4}}}", + self.min, self.p50, self.p95, self.p99, self.max, self.mean + ) + } +} + +// ── camera (world→screen Affine, manipulated directly to avoid drift) ─────── + +fn fit_view(aabb: RectF) -> Affine { + let s = (W / aabb.w).min(H / aabb.h) * 0.95; + let tx = (W - s * aabb.w) / 2.0 - s * aabb.x; + let ty = (H - s * aabb.h) / 2.0 - s * aabb.y; + Affine { + a: s, + b: 0.0, + c: 0.0, + d: s, + e: tx, + f: ty, + } +} + +fn zoom_about(base: &Affine, factor: f32, cx: f32, cy: f32) -> Affine { + let s = base.a; + let (wx, wy) = ((cx - base.e) / s, (cy - base.f) / s); + let s2 = s * factor; + Affine { + a: s2, + b: 0.0, + c: 0.0, + d: s2, + e: cx - s2 * wx, + f: cy - s2 * wy, + } +} + +// ── deterministic heavy scene ─────────────────────────────────────────────── + +/// A flat grid packed so its content AABB fills the viewport at fit-zoom (the +/// honest all-visible worst case). Deterministic: rotation (i%7)*5°, kind +/// cycle {Rect,Ellipse,Line} with every 20th a Text run (exercises the +/// resolver measure phase + executor text branch), palette cycle. +/// Returns (doc, leaf ids in z-order, content AABB). +fn packed(n: usize) -> (Document, Vec, RectF) { + const CELL: f32 = 20.0; + let cols = ((n as f32 * W / H).sqrt().ceil() as usize).max(1); + let rows = n.div_ceil(cols); + let mut b = DocBuilder::new(); + let palette = ["4A90D9", "E2574C", "57B894", "8B7BD8", "E2A23F"]; + let mut ids = Vec::with_capacity(n); + for i in 0..n { + let mut h = Header::new(SizeIntent::Fixed(CELL * 0.8), SizeIntent::Fixed(CELL * 0.8)); + h.x = AxisBinding::start((i % cols) as f32 * CELL); + h.y = AxisBinding::start((i / cols) as f32 * CELL); + h.rotation = (i % 7) as f32 * 5.0; + let payload = if i % 20 == 19 { + Payload::Text { + content: "hi".to_string(), + font_size: 10.0, + } + } else { + Payload::Shape { + desc: match i % 3 { + 0 => ShapeDesc::Rect, + 1 => ShapeDesc::Ellipse, + _ => ShapeDesc::Line, + }, + } + }; + ids.push(b.add(0, h, payload)); + } + let mut doc = b.build(); + for (i, &id) in ids.iter().enumerate() { + doc.get_mut(id).fill = Some(format!("#{}", palette[i % palette.len()])); + } + let aabb = RectF { + x: 0.0, + y: 0.0, + w: cols as f32 * CELL, + h: rows as f32 * CELL, + }; + (doc, ids, aabb) +} + +// ── scenarios ─────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq)] +enum Axis { + View, + Mutation, + Animation, +} + +struct Scenario { + name: &'static str, + axis: Axis, + n_anim: usize, // animation only +} + +fn scenarios(quick: bool) -> Vec { + let mut v = vec![ + Scenario { + name: "view_pan", + axis: Axis::View, + n_anim: 0, + }, + Scenario { + name: "view_zoom", + axis: Axis::View, + n_anim: 0, + }, + Scenario { + name: "mutate_move", + axis: Axis::Mutation, + n_anim: 0, + }, + Scenario { + name: "mutate_rotate", + axis: Axis::Mutation, + n_anim: 0, + }, + Scenario { + name: "mutate_color", + axis: Axis::Mutation, + n_anim: 0, + }, + ]; + if !quick { + v.push(Scenario { + name: "anim_transform_1", + axis: Axis::Animation, + n_anim: 1, + }); + v.push(Scenario { + name: "anim_transform_100", + axis: Axis::Animation, + n_anim: 100, + }); + v.push(Scenario { + name: "anim_color_100", + axis: Axis::Animation, + n_anim: 100, + }); + } + v +} + +struct Result { + scenario: &'static str, + n: usize, + resolve: Dist, + build: Dist, + execute: Dist, + full: Dist, + changed_p50: usize, // measured damage (mutation/anim) +} + +/// Run one scenario at one size. Drives the real render seam; PANICS on any +/// trust-guard violation. +fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { + let (mut doc, ids, aabb) = packed(n); + let base = fit_view(aabb); + let mut surface = skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).unwrap(); + + // Reference resolve/drawlist for the VIEW-redundancy audit (A4). + let ref_resolved = resolve(&doc, &opts()); + let ref_list = anchor_engine::drawlist::build(&doc, &ref_resolved); + // Rotation is a paint-only transform under DEC-0 (grounded in the + // classifier, not the scenario name). + if sc.name == "mutate_rotate" { + let dc = dirty_class( + &Op::SetRotation { + id: ids[0], + deg: 1.0, + }, + RotationInFlow::VisualOnly, + ); + assert_eq!( + dc.phases, + PhaseMask::T.or(PhaseMask::B), + "DEC-0: SetRotation must be paint-only (T|B)" + ); + } + + let target = ids[0]; + let mut prev = ref_resolved.clone(); + let mut view = base; + let mut pan = 0.0f32; + let mut pan_dir = 1.0f32; + + let mut r_ns = Vec::with_capacity(frames); + let mut b_ns = Vec::with_capacity(frames); + let mut e_ns = Vec::with_capacity(frames); + let mut f_ns = Vec::with_capacity(frames); + let mut changed = Vec::with_capacity(frames); + let mut nonempty_view_frames = 0usize; + let mut redundant_view_frames = 0usize; + + for frame in 0..(WARMUP + frames) { + let prev_view = view; // last frame's camera (Affine is Copy) + // ── drive: mutate doc and/or view for this frame ── + match sc.axis { + Axis::View => { + if sc.name == "view_pan" { + pan += pan_dir * 6.0; + if pan.abs() > 240.0 { + pan_dir = -pan_dir; // reversal — bounded, no drift + } + view = base; + view.e += pan; + } else { + // view_zoom: A/B two-level alternation (drift-free). + let factor = if frame % 2 == 0 { 1.0 } else { 1.6 }; + view = zoom_about(&base, factor, W / 2.0, H / 2.0); + } + } + Axis::Mutation => match sc.name { + "mutate_move" => { + // oscillating move via the delta-retarget op (uses prev). + let dx = if frame % 40 < 20 { 3.0 } else { -3.0 }; + let _ = ops::apply( + &mut doc, + &prev, + &Op::MoveBy { + id: target, + dx, + dy: 0.0, + }, + ); + } + "mutate_rotate" => { + let deg = (frame % 30) as f32; // 0..30 sweep, paint-only + let _ = ops::apply(&mut doc, &prev, &Op::SetRotation { id: target, deg }); + } + "mutate_color" => { + // NOTE: fill is not an Op (no set_fill in the lab). This + // deliberately exercises the missing paint-damage channel: + // damage::diff sees NO geometry change though the pixels do. + let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; + doc.get_mut(target).fill = Some(format!("#{hex}")); + } + _ => {} + }, + Axis::Animation => { + let k = sc.n_anim.min(ids.len()); + if sc.name.starts_with("anim_transform") { + let deg = (frame % 30) as f32; + for &id in &ids[..k] { + let _ = ops::apply(&mut doc, &prev, &Op::SetRotation { id, deg }); + } + } else { + let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; + for &id in &ids[..k] { + doc.get_mut(id).fill = Some(format!("#{hex}")); + } + } + } + } + + // ── the measured frame: clear + render (resolve+build+execute) ── + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let t = Instant::now(); + let (resolved, list, stats) = render(canvas, &doc, &opts(), &view, ctx); + let full = t.elapsed().as_nanos(); + + // black_box the pure outputs so LLVM can't hoist resolve/build. + black_box(list.items.len()); + black_box(resolved.resolved_count()); + + let dmg = damage::diff(&prev, &resolved); + + if frame >= WARMUP { + r_ns.push(stats.resolve_ns); + b_ns.push(stats.build_ns); + e_ns.push(stats.execute_ns); + f_ns.push(full); + changed.push(dmg.changed.len()); + + match sc.axis { + Axis::View => { + // A4: on a VIEW frame the doc is unchanged → resolve+build + // are provably redundant. This is the whole premise. + if damage::diff(&ref_resolved, &resolved).is_empty() && list == ref_list { + redundant_view_frames += 1; + } + // A2: the camera must change frame-to-frame (no no-op frame + // measuring an un-moved view). + if view != prev_view { + nonempty_view_frames += 1; + } + } + _ => {} + } + } + + prev = resolved; + } + + // ── trust guards (panic, not fake numbers) ── + if sc.axis == Axis::View { + assert_eq!( + redundant_view_frames, frames, + "A4 [{}]: every VIEW frame must be resolve+build-redundant (got {}/{})", + sc.name, redundant_view_frames, frames + ); + assert!( + nonempty_view_frames * 100 >= frames * 95, + "A2 [{}]: camera must actually move ({}/{})", + sc.name, + nonempty_view_frames, + frames + ); + } + if sc.axis == Axis::Mutation && sc.name != "mutate_color" { + let moved = changed.iter().filter(|&&c| c > 0).count(); + assert!( + moved * 100 >= frames * 90, + "A2 [{}]: geometric mutation must damage ≥90% frames ({}/{})", + sc.name, + moved, + frames + ); + } + let mut sorted = changed.clone(); + sorted.sort_unstable(); + let changed_p50 = sorted.get(sorted.len() / 2).copied().unwrap_or(0); + + Result { + scenario: sc.name, + n, + resolve: Dist::from_ns(&r_ns), + build: Dist::from_ns(&b_ns), + execute: Dist::from_ns(&e_ns), + full: Dist::from_ns(&f_ns), + changed_p50, + } +} + +// ── Win 1: the scene raster cache, on the view scenarios ──────────────────── + +/// Drive a VIEW scenario through the compositor. Pan reverses at ±500 (beyond +/// the 256 margin), so re-rasters happen periodically — the honest amortized +/// picture (p50 = blit, p99 catches a re-raster hitch). Returns the frame +/// distribution and the re-raster percentage. +fn run_cached(name: &'static str, n: usize, frames: usize, ctx: &PaintCtx) -> (Dist, f64) { + let (doc, _ids, aabb) = packed(n); + let base = fit_view(aabb); + let mut surface = skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).unwrap(); + let mut cache = SceneCache::new(W as i32, H as i32); + let mut pan = 0.0f32; + let mut dir = 1.0f32; + let mut wall = Vec::with_capacity(frames); + let mut rerasters = 0usize; + + for frame in 0..(WARMUP + frames) { + let view = if name == "view_pan" { + pan += dir * 6.0; + if pan.abs() > 500.0 { + dir = -dir; // beyond the margin → periodic re-raster + } + let mut v = base; + v.e += pan; + v + } else { + let factor = if frame % 2 == 0 { 1.0 } else { 1.6 }; + zoom_about(&base, factor, W / 2.0, H / 2.0) + }; + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let t = Instant::now(); + let did = cache.frame(canvas, &doc, &opts(), &view, ctx, false); + let elapsed = t.elapsed().as_nanos(); + black_box(did); + if frame >= WARMUP { + wall.push(elapsed); + if did { + rerasters += 1; + } + } + } + ( + Dist::from_ns(&wall), + rerasters as f64 / frames as f64 * 100.0, + ) +} + +// ── output ────────────────────────────────────────────────────────────────── + +fn print_table(results: &[Result]) { + println!( + "\n{:<20} {:>8} {:>18} {:>18} {:>18} {:>18} {:>7} {:>7} {:>7}", + "scenario", + "nodes", + "resolve p50/p99", + "build p50/p99", + "execute p50/p99", + "frame p50/p99", + "fps", + "fps99", + "damage" + ); + for r in results { + let fps = 1000.0 / r.full.mean; + let fps99 = 1000.0 / r.full.p99; + let flag = if r.full.p99 <= 8.333 { " " } else { "!" }; + println!( + "{:<20} {:>8} {:>8.3}/{:>8.3} {:>8.3}/{:>8.3} {:>8.3}/{:>8.3} {:>8.3}/{:>8.3} {:>7.1}{}{:>6.1} {:>7}", + r.scenario, r.n, + r.resolve.p50, r.resolve.p99, + r.build.p50, r.build.p99, + r.execute.p50, r.execute.p99, + r.full.p50, r.full.p99, + fps, flag, fps99, r.changed_p50, + ); + } + println!("(120fps budget = 8.333 ms; `!` marks frame p99 over budget)"); +} + +fn to_json(results: &[Result]) -> String { + let mut s = String::from("[\n"); + for (i, r) in results.iter().enumerate() { + s.push_str(&format!( + " {{\"scenario\":\"{}\",\"n\":{},\"resolve_ms\":{},\"build_ms\":{},\"execute_ms\":{},\"full_ms\":{},\"fps_mean\":{:.2},\"fps_p99\":{:.2},\"changed_p50\":{}}}{}\n", + r.scenario, r.n, r.resolve.json(), r.build.json(), r.execute.json(), r.full.json(), + 1000.0 / r.full.mean, 1000.0 / r.full.p99, r.changed_p50, + if i + 1 < results.len() { "," } else { "" }, + )); + } + s.push(']'); + s +} + +/// Diff full-frame p99 against a prior JSON baseline; flag regressions >~5%. +fn diff_baseline(results: &[Result], baseline_path: &str) { + let Ok(text) = std::fs::read_to_string(baseline_path) else { + eprintln!("baseline not found: {baseline_path}"); + return; + }; + let base: serde_json::Value = serde_json::from_str(&text).unwrap(); + let arr = base.as_array().cloned().unwrap_or_default(); + println!( + "\n{:<20} {:>8} {:>12} {:>12} {:>8}", + "scenario", "nodes", "base p99", "now p99", "Δ%" + ); + for r in results { + let prior = arr.iter().find(|v| { + v.get("scenario").and_then(|s| s.as_str()) == Some(r.scenario) + && v.get("n").and_then(|x| x.as_u64()) == Some(r.n as u64) + }); + let base_p99 = prior + .and_then(|v| v.get("full_ms")) + .and_then(|v| v.get("p99")) + .and_then(|v| v.as_f64()); + match base_p99 { + Some(bp) => { + let d = (r.full.p99 - bp) / bp * 100.0; + let mark = if d > 5.0 { + " REGRESSION" + } else if d < -5.0 { + " win" + } else { + "" + }; + println!( + "{:<20} {:>8} {:>12.3} {:>12.3} {:>+7.1}%{}", + r.scenario, r.n, bp, r.full.p99, d, mark + ); + } + None => println!( + "{:<20} {:>8} {:>12} {:>12.3}", + r.scenario, r.n, "(new)", r.full.p99 + ), + } + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let quick = args.iter().any(|a| a == "--quick"); + let json = args.iter().any(|a| a == "--json"); + let bless = args + .iter() + .position(|a| a == "--bless") + .and_then(|i| args.get(i + 1).cloned()); + let baseline = args + .iter() + .position(|a| a == "--baseline") + .and_then(|i| args.get(i + 1).cloned()); + + let sizes: &[usize] = if quick { + &[1_000, 10_000] + } else { + &[1_000, 10_000, 100_000] + }; + let frames = if quick { 60 } else { 240 }; + let cache_mode = args.iter().any(|a| a == "--cache"); + + // font: None → text is skipped by the executor (deterministic, no + // font-availability nondeterminism), same as the gate's raster path. + let ctx = PaintCtx { font: None }; + + if cache_mode { + println!("== Win 1: scene raster cache (view scenarios) =="); + println!( + "{:<12} {:>8} {:>10} {:>10} {:>7} {:>7} {:>9}", + "scenario", "nodes", "p50 ms", "p99 ms", "fps", "fps99", "reraster%" + ); + for name in ["view_pan", "view_zoom"] { + for &n in sizes { + let (d, rr) = run_cached(name, n, frames, &ctx); + println!( + "{:<12} {:>8} {:>10.4} {:>10.4} {:>7.1} {:>6.1} {:>8.1}%", + name, + n, + d.p50, + d.p99, + 1000.0 / d.mean, + 1000.0 / d.p99, + rr + ); + } + } + return; + } + + let mut results = Vec::new(); + for sc in scenarios(quick) { + for &n in sizes { + // animation ratio is only meaningful in a large static field. + if sc.axis == Axis::Animation && n < 10_000 { + continue; + } + eprint!(" {} @ {} ... ", sc.name, n); + let r = run(&sc, n, frames, &ctx); + eprintln!("done"); + results.push(r); + } + } + + if json { + println!("{}", to_json(&results)); + } else { + print_table(&results); + } + if let Some(path) = bless { + std::fs::write(&path, to_json(&results) + "\n").expect("write baseline"); + eprintln!("baseline written -> {path}"); + } + if let Some(path) = baseline { + diff_baseline(&results, &path); + } +} diff --git a/model-v2/engine/src/bin/probe_gpu.rs b/model-v2/engine/src/bin/probe_gpu.rs new file mode 100644 index 0000000000..467eee4882 --- /dev/null +++ b/model-v2/engine/src/bin/probe_gpu.rs @@ -0,0 +1,352 @@ +//! probe_gpu — real GPU frame-cost harness, headless (no window). +//! +//! The raster probe is the always-on tool; this one exists for the TWO wins +//! whose value is reusing a persistent GPU texture across frames — the scene +//! raster cache and layerization. On CPU raster a texture re-blit is a +//! full-frame memcpy that can be SLOWER than redrawing rects, so raster would +//! call a real GPU win a regression. Only here is the sign correct. +//! +//! Method (surfaceless CGL on macOS, EGL on Linux — recipe from +//! `crates/grida/src/window/headless.rs`): render to a GPU-backed Skia surface, +//! then `flush_and_submit()` to force the GPU to finish — the wall time around +//! (render + flush) is the real frame cost. `execute_ns` alone is a lie on GPU +//! (Skia only records commands). +//! +//! This is a MANUAL escalation tool, never a gate section: GPU timing is +//! high-variance (driver scheduling, thermal), so it informs judgement on the +//! two GPU wins, it does not auto-fail a build. +//! +//! cargo run --release --features native-gl-context --bin probe_gpu + +#[cfg(not(feature = "native-gl-context"))] +fn main() { + eprintln!("probe_gpu needs the native-gl-context feature:"); + eprintln!(" cargo run --release --features native-gl-context --bin probe_gpu"); + std::process::exit(2); +} + +#[cfg(feature = "native-gl-context")] +fn main() { + imp::run(); +} + +#[cfg(feature = "native-gl-context")] +mod imp { + use std::ffi::CString; + use std::time::Instant; + + use anchor_engine::cache::SceneCache; + use anchor_engine::{frame::render, paint::PaintCtx}; + use anchor_lab::math::{Affine, RectF}; + use anchor_lab::model::*; + use anchor_lab::resolve::{ResolveOptions, RotationInFlow}; + + use gl::types::GLenum; + use glutin::config::ConfigTemplateBuilder; + use glutin::context::{ContextApi, ContextAttributesBuilder, Version}; + use glutin::display::{DisplayApiPreference, GlDisplay}; + use skia_safe::gpu; + + const W: i32 = 1360; + const H: i32 = 900; + const WARMUP: usize = 20; + const FRAMES: usize = 240; + + // ── headless GPU context (copied recipe; Skia-managed FBO) ────────────── + + struct HeadlessGpu { + surface: skia_safe::Surface, + gr_context: gpu::DirectContext, + _gl_context: glutin::context::PossiblyCurrentContext, + } + + impl HeadlessGpu { + fn new(w: i32, h: i32) -> Result { + #[cfg(target_os = "macos")] + let pref = DisplayApiPreference::Cgl; + #[cfg(target_os = "linux")] + let pref = DisplayApiPreference::Egl; + + let display = unsafe { + glutin::display::Display::new(platform_display_handle(), pref) + .map_err(|e| format!("glutin display: {e}"))? + }; + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_stencil_size(8) + .with_depth_size(24); + let cfg = unsafe { + display + .find_configs(template.build()) + .map_err(|e| format!("find configs: {e}"))? + .next() + .ok_or("no GL config")? + }; + let attrs = ContextAttributesBuilder::new() + .with_context_api(ContextApi::OpenGl(Some(Version::new(3, 3)))) + .build(None); + let fallback = ContextAttributesBuilder::new() + .with_context_api(ContextApi::OpenGl(None)) + .build(None); + let not_current = unsafe { + display + .create_context(&cfg, &attrs) + .or_else(|_| display.create_context(&cfg, &fallback)) + .map_err(|e| format!("create context: {e}"))? + }; + let gl_context = make_current_surfaceless(not_current)?; + + gl::load_with(|s| { + CString::new(s) + .map(|c| display.get_proc_address(c.as_c_str())) + .unwrap_or(std::ptr::null()) + }); + let interface = gpu::gl::Interface::new_load_with(|name| { + if name == "eglGetCurrentDisplay" { + return std::ptr::null(); + } + CString::new(name) + .map(|c| display.get_proc_address(c.as_c_str())) + .unwrap_or(std::ptr::null()) + }) + .ok_or("skia GL interface")?; + let mut gr_context = + gpu::direct_contexts::make_gl(interface, None).ok_or("skia DirectContext")?; + + let surface = gpu::surfaces::render_target( + &mut gr_context, + gpu::Budgeted::Yes, + &skia_safe::ImageInfo::new_n32_premul((w, h), None), + Some(0), + gpu::SurfaceOrigin::TopLeft, + None, + false, + None, + ) + .ok_or("GPU render target")?; + + Ok(Self { + surface, + gr_context, + _gl_context: gl_context, + }) + } + + fn gl_info(&self) { + println!( + " GL: {} / {} / {}", + gl_string(gl::VENDOR), + gl_string(gl::RENDERER), + gl_string(gl::VERSION) + ); + } + } + + #[cfg(target_os = "macos")] + fn platform_display_handle() -> raw_window_handle::RawDisplayHandle { + use raw_window_handle::{AppKitDisplayHandle, RawDisplayHandle}; + RawDisplayHandle::AppKit(AppKitDisplayHandle::new()) + } + #[cfg(target_os = "linux")] + fn platform_display_handle() -> raw_window_handle::RawDisplayHandle { + use raw_window_handle::{RawDisplayHandle, XlibDisplayHandle}; + RawDisplayHandle::Xlib(XlibDisplayHandle::new(None, 0)) + } + + #[cfg(target_os = "macos")] + fn make_current_surfaceless( + ctx: glutin::context::NotCurrentContext, + ) -> Result { + match ctx { + glutin::context::NotCurrentContext::Cgl(c) => { + Ok(glutin::context::PossiblyCurrentContext::Cgl( + c.make_current_surfaceless() + .map_err(|e| format!("CGL surfaceless: {e}"))?, + )) + } + #[allow(unreachable_patterns)] + _ => Err("expected CGL context".into()), + } + } + #[cfg(target_os = "linux")] + fn make_current_surfaceless( + ctx: glutin::context::NotCurrentContext, + ) -> Result { + match ctx { + glutin::context::NotCurrentContext::Egl(c) => { + Ok(glutin::context::PossiblyCurrentContext::Egl( + c.make_current_surfaceless() + .map_err(|e| format!("EGL surfaceless: {e}"))?, + )) + } + #[allow(unreachable_patterns)] + _ => Err("expected EGL context".into()), + } + } + + fn gl_string(name: GLenum) -> String { + unsafe { + let p = gl::GetString(name); + if p.is_null() { + "".into() + } else { + std::ffi::CStr::from_ptr(p as *const _) + .to_string_lossy() + .into_owned() + } + } + } + + // ── scene + view (minimal; mirrors probe.rs packed) ───────────────────── + + fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (W as f32, H as f32), + rotation_in_flow: RotationInFlow::VisualOnly, + } + } + + fn packed(n: usize) -> (Document, RectF) { + const CELL: f32 = 20.0; + let cols = ((n as f32 * W as f32 / H as f32).sqrt().ceil() as usize).max(1); + let rows = n.div_ceil(cols); + let mut b = DocBuilder::new(); + for i in 0..n { + let mut h = Header::new(SizeIntent::Fixed(CELL * 0.8), SizeIntent::Fixed(CELL * 0.8)); + h.x = AxisBinding::start((i % cols) as f32 * CELL); + h.y = AxisBinding::start((i / cols) as f32 * CELL); + h.rotation = (i % 7) as f32 * 5.0; + b.add( + 0, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + } + ( + b.build(), + RectF { + x: 0.0, + y: 0.0, + w: cols as f32 * CELL, + h: rows as f32 * CELL, + }, + ) + } + + fn fit_view(a: RectF) -> Affine { + let s = (W as f32 / a.w).min(H as f32 / a.h) * 0.95; + Affine { + a: s, + b: 0.0, + c: 0.0, + d: s, + e: (W as f32 - s * a.w) / 2.0, + f: (H as f32 - s * a.h) / 2.0, + } + } + + fn dist_ms(mut ns: Vec) -> (f64, f64, f64) { + ns.sort_unstable(); + let ms = |i: usize| ns[i.min(ns.len() - 1)] as f64 / 1e6; + (ms(ns.len() / 2), ms(ns.len() * 99 / 100), ms(ns.len() - 1)) + } + + pub fn run() { + println!("== probe_gpu (headless GPU, real flush timing) =="); + let mut gpu = match HeadlessGpu::new(W, H) { + Ok(g) => g, + Err(e) => { + eprintln!("headless GPU unavailable: {e}"); + eprintln!("(the raster probe remains the primary tool)"); + std::process::exit(1); + } + }; + gpu.gl_info(); + let ctx = PaintCtx { font: None }; + + println!( + "\n{:<12} {:>8} {:>22} {:>7} {:>7}", + "scenario", "nodes", "gpu frame p50/p99/max", "fps", "fps99" + ); + for &n in &[10_000usize, 100_000] { + let (doc, aabb) = packed(n); + let base = fit_view(aabb); + let mut pan = 0.0f32; + let mut dir = 1.0f32; + let mut wall = Vec::with_capacity(FRAMES); + + for frame in 0..(WARMUP + FRAMES) { + pan += dir * 6.0; + if pan.abs() > 240.0 { + dir = -dir; + } + let mut view = base; + view.e += pan; + + let t = Instant::now(); + { + let canvas = gpu.surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let _ = render(canvas, &doc, &opts(), &view, &ctx); + } + gpu.gr_context.flush_and_submit(); // force GPU to finish + let elapsed = t.elapsed().as_nanos(); + if frame >= WARMUP { + wall.push(elapsed); + } + } + let (p50, p99, max) = dist_ms(wall); + println!( + "{:<12} {:>8} {:>8.3}/{:>7.3}/{:>7.3} {:>7.1} {:>6.1}", + "view_pan", + n, + p50, + p99, + max, + 1000.0 / p50, + 1000.0 / p99 + ); + + // Win 1 on real GPU: the compositor's offscreen is backend-matched + // (Canvas::new_surface → GPU), so the cached image is a GPU texture + // and the blit is a GPU texture draw. Pan reverses beyond the margin + // so re-rasters are periodic (the honest amortized number). + let mut cache = SceneCache::new(W, H); + let mut cpan = 0.0f32; + let mut cdir = 1.0f32; + let mut cwall = Vec::with_capacity(FRAMES); + for frame in 0..(WARMUP + FRAMES) { + cpan += cdir * 6.0; + if cpan.abs() > 500.0 { + cdir = -cdir; + } + let mut view = base; + view.e += cpan; + let t = Instant::now(); + { + let canvas = gpu.surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + cache.frame(canvas, &doc, &opts(), &view, &ctx, false); + } + gpu.gr_context.flush_and_submit(); + let elapsed = t.elapsed().as_nanos(); + if frame >= WARMUP { + cwall.push(elapsed); + } + } + let (cp50, cp99, cmax) = dist_ms(cwall); + println!( + "{:<12} {:>8} {:>8.3}/{:>7.3}/{:>7.3} {:>7.1} {:>6.1}", + " +cache", + n, + cp50, + cp99, + cmax, + 1000.0 / cp50, + 1000.0 / cp99 + ); + } + } +} From 59bbe1a82aebbe94726d4554ef1b3572bc31de23 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 9 Jul 2026 16:04:59 +0900 Subject: [PATCH 08/33] feat(model-v2): numeric Color + dense-index resolve caches, DATA-MODEL doctrine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gate-verified data-layout moves aligned to browser prior art, plus the decisions doc that grounds them: - Color: fill `Option` → `model::Color(u32)` (0xAARRGGBB), read straight by the drawlist. No per-node heap String, no per-build hex parse; text-IR/SVG convert at the boundary via from_hex/to_hex. Parse byte-identical to the old resolve_color (pixels unchanged, gate 4/4 IDENTICAL); build −31% at 100k nodes. - Resolve caches: HashMap → Vec> (union_cache, ops_cache). NodeId is a dense arena index — hashing it is the cc anti-pattern. Correctness preserved (gate PASS, 121 lab tests). - spike-canvas: `pages` stress scene (realistic nested flex cards, ANCHOR_SCENE selected) + --bench rows; goldens still use starter(). - engine/DATA-MODEL.md: six data-layout details decided against cc/Blink/Stylo/ Skia prior art, validity-tagged (ALIGNED/ADOPTED/SOCKET), with honesty corrections to the record. --- model-v2/a/lab/src/bin/e1.rs | 2 +- model-v2/a/lab/src/model.rs | 55 ++++++- model-v2/a/lab/src/resolve.rs | 49 +++--- model-v2/a/lab/src/svgout.rs | 3 +- model-v2/a/lab/src/textir.rs | 6 +- model-v2/a/spike-canvas/src/main.rs | 4 + model-v2/a/spike-canvas/src/scene.rs | 200 +++++++++++++++++++++++ model-v2/a/spike-canvas/src/shell/app.rs | 2 +- model-v2/engine/DATA-MODEL.md | 116 +++++++++++++ model-v2/engine/README.md | 4 + model-v2/engine/src/bin/probe.rs | 6 +- model-v2/engine/src/drawlist.rs | 23 +-- model-v2/engine/tests/cache.rs | 6 +- model-v2/engine/tests/drawlist.rs | 2 +- 14 files changed, 422 insertions(+), 56 deletions(-) create mode 100644 model-v2/engine/DATA-MODEL.md diff --git a/model-v2/a/lab/src/bin/e1.rs b/model-v2/a/lab/src/bin/e1.rs index be94414687..75882368a3 100644 --- a/model-v2/a/lab/src/bin/e1.rs +++ b/model-v2/a/lab/src/bin/e1.rs @@ -66,7 +66,7 @@ fn scene(fixed_w: Option) -> Scene { desc: ShapeDesc::Rect, }, ); - b.node_mut(c).fill = Some(color.to_string()); + b.node_mut(c).fill = Some((*color).into()); let _ = i; cards.push(c); } diff --git a/model-v2/a/lab/src/model.rs b/model-v2/a/lab/src/model.rs index 7c49b95b81..39374d0bbc 100644 --- a/model-v2/a/lab/src/model.rs +++ b/model-v2/a/lab/src/model.rs @@ -252,13 +252,66 @@ impl Payload { } } +/// A packed opaque color, `0xAARRGGBB` — the numeric paint value. Browsers +/// never store a resolved color as a string (a CSS string is a parse *input* +/// and a serialize *output* only — verified across Blink/Stylo/Skia); the text +/// IR and SVG boundaries convert via `from_hex`/`to_hex`, and everything in +/// between is this fixed-size number, read straight by the drawlist with no +/// per-build parse. The wide-gamut canonical form the engines converged on +/// (f32×4 channels + a color-space tag) is the deferred upgrade — see +/// `engine/DATA-MODEL.md` (tied to the color-management day-1 gap). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Color(pub u32); + +impl Color { + pub const BLACK: Color = Color(0xFF00_0000); + + /// Packed `0xAARRGGBB`. + pub fn argb(self) -> u32 { + self.0 + } + + /// Parse `#rrggbb` (forced opaque, byte-identical to the painter's old + /// `resolve_color`). `None` on any other form — the caller keeps its own + /// per-kind fallback. + pub fn from_hex(s: &str) -> Option { + let h = s.trim_start_matches('#'); + if h.len() == 6 { + if let Ok(v) = u32::from_str_radix(h, 16) { + return Some(Color(0xFF00_0000 | v)); + } + } + None + } + + /// Serialize as `#RRGGBB` (the IR/SVG boundary; alpha dropped — lab paint + /// is opaque). + pub fn to_hex(self) -> String { + format!("#{:06X}", self.0 & 0x00FF_FFFF) + } +} + +/// Ergonomic authoring + the IR/SVG parse boundary: `"#4A90D9".into()`. A +/// malformed literal falls back to opaque black (no exercised path hits it — +/// every fill is a valid `#rrggbb`; the gate proves pixel-identity). +impl From<&str> for Color { + fn from(s: &str) -> Color { + Color::from_hex(s).unwrap_or(Color::BLACK) + } +} +impl From for Color { + fn from(s: String) -> Color { + Color::from(s.as_str()) + } +} + #[derive(Debug, Clone, PartialEq)] pub struct Node { pub id: NodeId, pub header: Header, pub payload: Payload, pub children: Vec, - pub fill: Option, // lab-only paint for SVG snapshots + pub fill: Option, // lab-only paint for SVG snapshots } /// The document store is a **node arena**: `NodeId` IS the slot index, diff --git a/model-v2/a/lab/src/resolve.rs b/model-v2/a/lab/src/resolve.rs index 5d4a2dcd53..e86c919a0a 100644 --- a/model-v2/a/lab/src/resolve.rs +++ b/model-v2/a/lab/src/resolve.rs @@ -15,7 +15,6 @@ use crate::math::{rotated_aabb_size, Affine, RectF}; use crate::measure::measure_text; use crate::model::*; -use std::collections::HashMap; use taffy::prelude::{auto, length, AvailableSpace, TaffyTree}; use taffy::style::{ AlignItems, AlignSelf, Display, FlexDirection, FlexWrap, JustifyContent, Style, @@ -149,9 +148,14 @@ struct Ctx<'a> { out: Resolved, /// Derived-box kinds: union rect of children in node-local space, /// cached once children are committed (children commit exactly once). - union_cache: HashMap, - /// Lens nodes: the folded post-resolution ops transform. - ops_cache: HashMap, + /// `NodeId`-indexed column, not a map: `NodeId` is a dense arena index, and + /// the browser rule is to index dense ids directly and never hash them + /// (cc's property trees are `Vec`-indexed; a `HashMap` is the + /// anti-pattern). Sized to the arena, like the `Resolved` columns. + union_cache: Vec>, + /// Lens nodes: the folded post-resolution ops transform (same dense-index + /// column discipline). + ops_cache: Vec>, } pub fn resolve(doc: &Document, opts: &ResolveOptions) -> Resolved { @@ -159,8 +163,8 @@ pub fn resolve(doc: &Document, opts: &ResolveOptions) -> Resolved { doc, opts: *opts, out: Resolved::with_capacity(doc.capacity()), - union_cache: HashMap::new(), - ops_cache: HashMap::new(), + union_cache: vec![None; doc.capacity()], + ops_cache: vec![None; doc.capacity()], }; let vp = RectF { x: 0.0, @@ -400,10 +404,7 @@ fn hug_size(id: NodeId, cx: &mut Ctx) -> (f32, f32) { let (cw, ch) = extent_of(child_id, None, cx); let is_derived = cx.doc.get(child_id).payload.box_is_derived(); let u = if is_derived { - cx.union_cache - .get(&child_id) - .copied() - .unwrap_or(RectF::EMPTY) + cx.union_cache[child_id as usize].unwrap_or(RectF::EMPTY) } else { RectF { x: 0.0, @@ -490,7 +491,7 @@ fn place_by_bindings(id: NodeId, parent_extent: (f32, f32), cx: &mut Ctx) -> Rec let (w, h) = extent_of(id, Some(parent_extent), cx); let node = cx.doc.get(id); if node.payload.box_is_derived() { - let u = cx.union_cache.get(&id).copied().unwrap_or(RectF::EMPTY); + let u = cx.union_cache[id as usize].unwrap_or(RectF::EMPTY); let ox = match node.header.x { AxisBinding::Pin { anchor: AnchorEdge::Start, @@ -583,8 +584,8 @@ fn place_by_bindings(id: NodeId, parent_extent: (f32, f32), cx: &mut Ctx) -> Rec /// Resolve a derived-box node's children in node-local space (committing /// them), returning the union rect. Cached — children commit exactly once. fn union_of_derived(id: NodeId, cx: &mut Ctx) -> RectF { - if let Some(u) = cx.union_cache.get(&id) { - return *u; + if let Some(u) = cx.union_cache[id as usize] { + return u; } let children: Vec = cx.doc.get(id).children.clone(); let mut union: Option = None; @@ -598,11 +599,7 @@ fn union_of_derived(id: NodeId, cx: &mut Ctx) -> RectF { // Derived children: pins place the ORIGIN; the box = origin + own // union offset (census fix: nested unions must not swallow it). let derived_off = if cx.doc.get(child_id).payload.box_is_derived() { - let u = cx - .union_cache - .get(&child_id) - .copied() - .unwrap_or(RectF::EMPTY); + let u = cx.union_cache[child_id as usize].unwrap_or(RectF::EMPTY); (u.x, u.y) } else { (0.0, 0.0) @@ -642,7 +639,7 @@ fn union_of_derived(id: NodeId, cx: &mut Ctx) -> RectF { } // D-E1 declared: empty/hidden-only derived box = zero rect at origin. let u = union.unwrap_or(RectF::EMPTY); - cx.union_cache.insert(id, u); + cx.union_cache[id as usize] = Some(u); u } @@ -691,13 +688,13 @@ fn commit(id: NodeId, box_rect: RectF, cx: &mut Ctx) { // origin space, so no further correction term exists — which is // exactly why child edits never move siblings (D-2). // Flip shares the pivot (B1) and composes innermost: T·R·F. - let u = cx.union_cache.get(&id).copied().unwrap_or(RectF::EMPTY); + let u = cx.union_cache[id as usize].unwrap_or(RectF::EMPTY); let t = Affine::translate(box_rect.x - u.x, box_rect.y - u.y) .then(&Affine::rotate_deg(theta)) .then(&Affine::flip(fx, fy)); if let Payload::Lens { ops } = &node.payload { let folded = fold_lens_ops(ops, u); - cx.ops_cache.insert(id, folded); + cx.ops_cache[id as usize] = Some(folded); } t } else { @@ -744,11 +741,7 @@ fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), // Derived kinds keep their union dims; under AABB mode // the *post-rotation* union center lands on the slot // center (pivot is the origin, so the center swings). - let u = cx - .union_cache - .get(&child_id) - .copied() - .unwrap_or(RectF::EMPTY); + let u = cx.union_cache[child_id as usize].unwrap_or(RectF::EMPTY); if cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates { let (scx, scy) = slot.center(); // Post-transform union center (R·F — same composition @@ -1087,8 +1080,8 @@ fn compose_world(id: NodeId, parent_world: Affine, cx: &mut Ctx) { cx.out.world[id as usize] = Some(world); // Children of a lens compose through the folded ops: // world = parent ∘ local ∘ eval(ops) (a.md §6 Phase T). - let child_basis = match cx.ops_cache.get(&id) { - Some(ops) => world.then(ops), + let child_basis = match cx.ops_cache[id as usize] { + Some(ops) => world.then(&ops), None => world, }; let children: Vec = cx.doc.get(id).children.clone(); diff --git a/model-v2/a/lab/src/svgout.rs b/model-v2/a/lab/src/svgout.rs index 7ed9f7c118..ee8bdc4edb 100644 --- a/model-v2/a/lab/src/svgout.rs +++ b/model-v2/a/lab/src/svgout.rs @@ -52,7 +52,8 @@ fn paint(doc: &Document, id: NodeId, resolved: &Resolved, out: &mut String) { }; let world = &world; let b = resolved.box_of(id); - let fill = node.fill.as_deref(); + let fill_hex = node.fill.map(|c| c.to_hex()); // numeric → `#RRGGBB` at the SVG boundary + let fill = fill_hex.as_deref(); match &node.payload { Payload::Frame { .. } => { diff --git a/model-v2/a/lab/src/textir.rs b/model-v2/a/lab/src/textir.rs index 6ac7a6da06..987546ebc2 100644 --- a/model-v2/a/lab/src/textir.rs +++ b/model-v2/a/lab/src/textir.rs @@ -142,7 +142,7 @@ pub fn parse(input: &str) -> Result { let mut font_size = 16.0f32; let mut lens_ops: Vec = vec![]; let mut clips = false; - let mut fill: Option = None; + let mut fill: Option = None; for attr in el.attributes() { let attr = attr.map_err(|e| ParseError(format!("attr: {e}")))?; @@ -266,7 +266,7 @@ pub fn parse(input: &str) -> Result { } "size" => font_size = parse_num(&val, "size")?, "ops" => lens_ops = parse_lens_ops(&val)?, - "fill" => fill = Some(val), + "fill" => fill = Some(val.into()), _ => return err(format!("unknown attribute `{key}` on <{tag}>")), } } @@ -615,7 +615,7 @@ fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { } } if let Some(fill) = &node.fill { - push_attr(out, "fill", fill); + push_attr(out, "fill", &fill.to_hex()); } let is_text = matches!(node.payload, Payload::Text { .. }); diff --git a/model-v2/a/spike-canvas/src/main.rs b/model-v2/a/spike-canvas/src/main.rs index 74d64be312..4706cbce0e 100644 --- a/model-v2/a/spike-canvas/src/main.rs +++ b/model-v2/a/spike-canvas/src/main.rs @@ -238,6 +238,10 @@ fn bench() { println!("== full-redraw cost (raster, min of 11); 120fps budget = 8.33 ms =="); let (starter, _) = scene::starter(); run("starter", &starter); + // The realistic nested workload (the live `ANCHOR_SCENE=pages` scene): + // flex-nested cards, not a flat packed grid — a truer per-node paint mix. + run("pages 100", &scene::pages(10, 10).0); + run("pages 400", &scene::pages(20, 20).0); for n in [1_000usize, 10_000, 50_000, 100_000] { run(&format!("packed {n}"), &packed(n)); } diff --git a/model-v2/a/spike-canvas/src/scene.rs b/model-v2/a/spike-canvas/src/scene.rs index 0e78e7cc9a..2dd49956d8 100644 --- a/model-v2/a/spike-canvas/src/scene.rs +++ b/model-v2/a/spike-canvas/src/scene.rs @@ -197,3 +197,203 @@ pub fn starter() -> (Document, NodeId) { (b.build(), artboard) } + +// ── the stress scene: one realistic card, loop-placed into a grid ────────── +// +// A "real-world-ish" document for feeling perf on the live spike (and for the +// `--bench` headless numbers): a content card with genuine nesting depth and +// flex layout, tiled across a large board so the content extends well past the +// viewport (a true pan/zoom test). `starter()` stays the golden scene; this is +// selected only for the live window via `ANCHOR_SCENE` (see `from_env`). + +const PAGE_W: f32 = 240.0; +const PAGE_H: f32 = 320.0; +/// Page content width = PAGE_W − 2·padding; the inner rows/blocks are sized to +/// it explicitly (deterministic — no reliance on cross-axis stretch). +const PAGE_INNER_W: f32 = PAGE_W - 24.0; +const PAGE_GAP: f32 = 40.0; +const BOARD_MARGIN: f32 = 40.0; + +const PALETTE: [&str; 6] = [ + "#4A90D9", "#E2574C", "#57B894", "#8B7BD8", "#E2A23F", "#43B7C6", +]; + +fn flex_row(main: MainAlign, gap: f32) -> Payload { + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Row, + main_align: main, + cross_align: CrossAlign::Center, + gap_main: gap, + ..Default::default() + }, + clips_content: false, + } +} + +/// One card: a flex-column frame holding a header row (avatar · title · menu), +/// a hero block, three body lines, a flex footer (two buttons), and an +/// absolutely-pinned rotated "NEW" badge (DEC-0 visual-only paint). ~13 nodes, +/// nested to the header's avatar/title (page → header → children). +fn page(b: &mut DocBuilder, parent: NodeId, x: f32, y: f32, i: usize) -> NodeId { + let accent = PALETTE[i % PALETTE.len()]; + + let page = b.add( + parent, + at(named(card(PAGE_W, PAGE_H), &format!("page.{i}")), x, y), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Column, + gap_main: 10.0, + padding: EdgeInsets::all(12.0), + ..Default::default() + }, + clips_content: false, + }, + ); + b.node_mut(page).fill = Some("#FFFFFF".into()); + + // header row: avatar (ellipse) · title (text) · menu (rect), space-between. + let header = b.add( + page, + named(card(PAGE_INNER_W, 36.0), "hdr"), + flex_row(MainAlign::SpaceBetween, 8.0), + ); + let avatar = b.add( + header, + card(32.0, 32.0), + Payload::Shape { + desc: ShapeDesc::Ellipse, + }, + ); + b.node_mut(avatar).fill = Some(accent.into()); + let title = b.add( + header, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: format!("Project {i}"), + font_size: 15.0, + }, + ); + b.node_mut(title).fill = Some("#171A1F".into()); + let menu = b.add(header, card(20.0, 20.0), rect()); + b.node_mut(menu).fill = Some("#CED4DA".into()); + + // hero block (image placeholder). + let hero = b.add(page, card(PAGE_INNER_W, 110.0), rect()); + b.node_mut(hero).fill = Some(accent.into()); + + // three body lines (the last one short, left-set — a text paragraph feel). + for k in 0..3 { + let w = if k == 2 { 120.0 } else { PAGE_INNER_W }; + let line = b.add(page, card(w, 10.0), rect()); + b.node_mut(line).fill = Some("#E9ECEF".into()); + } + + // footer: two buttons pushed to the edges. + let footer = b.add( + page, + named(card(PAGE_INNER_W, 30.0), "ftr"), + flex_row(MainAlign::SpaceBetween, 8.0), + ); + let b1 = b.add(footer, card(70.0, 28.0), rect()); + b.node_mut(b1).fill = Some("#F1F3F5".into()); + let b2 = b.add(footer, card(70.0, 28.0), rect()); + b.node_mut(b2).fill = Some(accent.into()); + + // rotated "NEW" badge, absolutely pinned to the top-right corner. Out of + // flex flow, end-pinned, visually rotated — exercises the paint transform. + let mut badge = Header::new(SizeIntent::Auto, SizeIntent::Auto); + badge.flow = Flow::Absolute; + badge.x = AxisBinding::end(10.0); + badge.y = AxisBinding::start(10.0); + badge.rotation = -12.0; + let bg = b.add( + page, + badge, + Payload::Text { + content: "NEW".into(), + font_size: 12.0, + }, + ); + b.node_mut(bg).fill = Some("#E2574C".into()); + + page +} + +/// A `cols × rows` grid of [`page`] cards on one board frame. ~13 nodes/card, +/// e.g. 10×10 = 100 cards ≈ 1.3k nodes on a ~2840×3640 board. +pub fn pages(cols: usize, rows: usize) -> (Document, NodeId) { + let (cols, rows) = (cols.max(1), rows.max(1)); + let mut b = DocBuilder::new(); + + let grid_w = cols as f32 * PAGE_W + (cols - 1) as f32 * PAGE_GAP; + let grid_h = rows as f32 * PAGE_H + (rows - 1) as f32 * PAGE_GAP; + let board = b.add( + 0, + at( + named( + card(grid_w + 2.0 * BOARD_MARGIN, grid_h + 2.0 * BOARD_MARGIN), + ARTBOARD, + ), + 60.0, + 60.0, + ), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + b.node_mut(board).fill = Some("#F7F8F9".into()); + + for i in 0..(cols * rows) { + let (col, row) = (i % cols, i / cols); + let x = BOARD_MARGIN + col as f32 * (PAGE_W + PAGE_GAP); + let y = BOARD_MARGIN + row as f32 * (PAGE_H + PAGE_GAP); + page(&mut b, board, x, y, i); + } + + (b.build(), board) +} + +/// The live-window scene, selected by `ANCHOR_SCENE` (the `--shot` goldens +/// always use `starter`, so they are unaffected): +/// • unset / `starter` → the feel scene +/// • `pages` → 100 cards (10×10) +/// • `pages:N` → ~N cards (squared into a grid) +/// • `pages:CxR` → an explicit `C×R` grid +pub fn from_env() -> (Document, NodeId) { + let spec = std::env::var("ANCHOR_SCENE").unwrap_or_default(); + if spec == "pages" || spec.starts_with("pages:") { + let (cols, rows) = parse_grid(spec.strip_prefix("pages").unwrap_or("")); + let doc = pages(cols, rows); + eprintln!( + "scene: pages {cols}×{rows} = {} cards, {} nodes", + cols * rows, + doc.0.len() + ); + doc + } else { + starter() + } +} + +/// Parse the tail after `pages`: "" → 10×10; ":N" → squared grid; ":CxR". +fn parse_grid(tail: &str) -> (usize, usize) { + let tail = tail.trim_start_matches(':'); + if tail.is_empty() { + return (10, 10); + } + if let Some((c, r)) = tail.split_once('x') { + if let (Ok(c), Ok(r)) = (c.parse::(), r.parse::()) { + return (c, r); + } + } + if let Ok(n) = tail.parse::() { + let cols = (n as f32).sqrt().ceil() as usize; + return (cols.max(1), n.div_ceil(cols.max(1))); + } + (10, 10) +} diff --git a/model-v2/a/spike-canvas/src/shell/app.rs b/model-v2/a/spike-canvas/src/shell/app.rs index b47c433857..f7703bbe2c 100644 --- a/model-v2/a/spike-canvas/src/shell/app.rs +++ b/model-v2/a/spike-canvas/src/shell/app.rs @@ -120,7 +120,7 @@ pub fn run(init: WindowInit) { let egui_painter = egui_glow::Painter::new(glow_context, "", None, false) .expect("failed to create egui_glow painter"); - let (doc, artboard) = scene::starter(); + let (doc, artboard) = scene::from_env(); let mut app = App { doc, artboard, diff --git a/model-v2/engine/DATA-MODEL.md b/model-v2/engine/DATA-MODEL.md new file mode 100644 index 0000000000..7cadb1abdf --- /dev/null +++ b/model-v2/engine/DATA-MODEL.md @@ -0,0 +1,116 @@ +# DATA-MODEL — anchor's storage, aligned to browser prior art + +How the engine lays out its data, decided against how the mature engines +actually do it (Chromium `cc`/Blink, Servo/Stylo, Skia, Flutter — verified in +`docs/wg/research/chromium/**` and the local source clones, not from memory). +Each row carries a validity tag like the ENGINE.md contracts: +**[ALIGNED]** the code already matches the proven shape · **[ADOPTED]** changed +this pass to match · **[SOCKET]** the shape is understood and the seam exists, +the capability is deferred to a named study · **[GAP]** no seam yet. + +This is a decisions doc, not a survey — the pure upstream surveys stay in +`docs/wg/research/`. Grida-side reasoning lives only here. + +## The one convergent law (and the trap) + +Every engine that **retains** a document converges on the cold tier and +_diverges_ on the hot tier: + +- **Cold tier — unanimous.** The retained DOM/render tree is **AoS**, + pointer-linked per-node objects, with rarely-used fields factored into a + lazily-allocated _rare-data_ pouch: Blink `Node.data_`/`ElementRareDataVector`, + Servo `rare_data: Box`, Flutter `RenderObject.parentData`. Edits + and queries are per-node and structural, so identity and pointer-reachability + dominate over iteration locality. +- **Hot tier — divergent, and this is the real invariant.** The perf-critical + derived data is **never walked through the fat cold node** on the hot path; + it is re-laid-out and reached by a stable handle. But the _mechanism_ is a + choice driven by the bottleneck: + - **Chromium `cc`** → integer index into **domain-separated flat vectors** + (`TransformTree`/`ClipTree`/`EffectTree`/`ScrollTree` = `std::vector` + + one parallel `cached_data_` column). Bottleneck = cache-linear iteration. + - **Stylo** → **`Arc`-shared struct-of-groups** (`ComputedValues` = one + `Arc` per group). Bottleneck = redundant recomputation + across similar nodes → _share_, don't flatten. + - **Flutter** → same AoS tree, made cheap by **scoped dirty-propagation** + (relayout / repaint boundaries). + +**The trap: "SOA everywhere" is not the law.** SOA-by-domain is _Chromium's +compositor_ answer, not "the browser" answer. The transferable rule is: keep the +retained scene AoS + rare-data for editing; on the hot path **refuse to touch +the fat node and separate the derived data by access pattern via a handle** — +flat index array _or_ `Arc`-sharing _or_ a relayout boundary, chosen by whether +your cost is iteration or recomputation. (My earlier "the browser shape = hot +SOA" was an overstatement, corrected below.) + +## The six details + +| detail | proven precedent (cited) | anchor now | decision | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Node storage** | cold = AoS + RareData (Blink `Node`/Servo `Node`); hot = `cc` domain-`Vec`s by int index / Stylo `Arc` groups | `Document` = index-arena `Vec>` + `parents`/`generations` columns; `Resolved` = SOA columns by `NodeId` | **[ALIGNED]** — cold AoS-arena (index, not pointers — Servo-ish, _more_ cache-coherent than Blink's GC pointer DOM); hot SOA-by-domain = `cc`'s property-tree shape | +| **Computed tier** | builder-sealed, **immutable**, structurally-shared snapshot, **cached across frames** (Blink `ComputedStyle`/`LayoutResult`; Stylo `Arc`) | `resolve()` builds a value `Resolved` — but **rebuilt every frame**, not shared/cached | **[SOCKET]** immutability is fine; the _persistence_ is incremental-resolve (ENG-1) | +| **Color** | never a string — `f32×4` + color-space tag is canonical (`SkColor4f`, `blink::Color`, Stylo `AbsoluteColor`); packed `u32` is interchange only | ~~`fill: Option`, re-parsed every build~~ → **`model::Color(u32)`**, read straight | **[ADOPTED]** numeric now (packed `u32`); `f32×4`+space = **[SOCKET]** (the color-management gap) | +| **Style sharing** | share **by group** COW + cache **by matched-rule-set**; RAM ∝ distinct group-values, not node count | every node owns a **full `Header`**; nothing shared (100 identical cards = 100 copies) | **[SOCKET]** the memory-scaling move — group-partition + COW, or interning | +| **Caching / incremental** | dirty-scope + one "descendant-dirty" bit up (O(depth) prune); memoize keyed by inputs; **graded** damage descriptor; isolation boundaries; **reset after frame** | **full-resolve every frame**; `DirtyClass` + `damage` exist but unconsumed | **[SOCKET]** the big one (ENG-1/ENG-2). Our `DirtyClass` ≈ the graded descriptor; `damage` ≈ dirty-scope | +| **Index vs hash** | dense engine ids → **direct array index, never hashed**; hash only at the sparse _identity seam_ (`cc` `flat_map`; Stylo rule-source ptr) | ~~`HashMap` in `resolve`~~ → **`Vec>`** indexed | **[ADOPTED]** dense-index columns, no hashing | + +### Landed this pass (both gate-verified, goldens byte-identical) + +1. **Color: `String` → `model::Color(u32)`** (`0xAARRGGBB`). The drawlist reads + the number directly — no per-node heap `String`, no per-build hex parse. The + text-IR and SVG paths convert at the boundary via `from_hex`/`to_hex` + (string as parse-input / serialize-output only — the browser rule). Measured: + `build` −31% at 100k nodes (the parse removal shows at scale). Parse logic is + byte-identical to the old `resolve_color`, so pixels are unchanged (gate: 4/4 + IDENTICAL). +2. **Resolve caches: `HashMap` → `Vec>`** (`union_cache`, + `ops_cache`). `NodeId` is a dense arena index; hashing it is the exact + anti-pattern `cc` avoids. Correctness preserved (gate PASS, replays + deterministic, 121 lab tests). Perf-neutral on the `pages` workload (those + caches serve derived boxes, which `pages` has none of) — this one is + principled alignment, not a measured mover here. + +## Corrections to the record (honesty) + +The grounding pass caught three things I (and one existing doc) had stated +loosely — recorded so the RFC textbook stays honest: + +- **"The browser shape = hot SOA"** → overstated. It is the _Chromium-compositor_ + shape; Servo shares, Flutter bounds. See the law above. +- **`DataRef<>`** (the classic Blink COW wrapper) is **removed** from Blink — + the mechanism is now Oilpan `Member` + a per-group `access_` flag + + `Access()→Copy()`. Don't cite `DataRef`. +- **`NGPhysicalFragment`** — the "NG" prefix was dropped once LayoutNG became the + sole engine; the type is `PhysicalFragment`/`PhysicalBoxFragment`, and the + immutable cacheable unit is `LayoutResult`. (Also: `docs/wg/research/chromium/ +node-data-layout.md`'s "ComputedStyle is reference-counted" is now stale — it + is `GarbageCollected`.) +- **Canonical color is `f32×4` + color-space**, not packed `u32`; `u32` + (`SkColor`) is a lossy interchange/accessor form today. + +## Ordered next (the sockets, by leverage) + +1. **Incremental resolve** (ENG-1) — persistent `Resolved` + dirty-scope + propagation, keyed reuse, graded `DirtyClass` consumption. This is where the + nested-scene resolve cost (the `pages` bottleneck) actually falls. **Note we + should go _past_ `cc` here:** it skips subtree-scoped propagation because web + trees are 100–500 nodes; a canvas is 10k–100k, the regime where `cc`'s own + docs say subtree-scoped propagation "becomes worthwhile." +2. **Style/attribute sharing** — partition `Header` into COW groups (or intern + common values) so 100 identical cards stop costing 100× (the browser + memory-scaling win). Pairs with a `RareData` cold/hot split on `Header`. +3. **Wide-gamut color** — `Color` → `f32×4` + a color-space tag, folded into the + color-management day-1 gap (the audit's item). +4. **Damage paint channel** — `damage::diff` is geometry-only; a fill/opacity + channel is a prerequisite for Win 2 consuming it (already flagged). + +## References + +- Surveys: `docs/wg/research/chromium/{node-data-layout, property-trees, +blink-rendering-pipeline, dirty-flag-management, paint-recording}.md`. +- Verified in clones: `cc/trees/property_tree.h` (dense-`Vec`, int index), + Blink `computed_style_base.h.tmpl` (`Member<>`+`Access()` COW), + `core/layout/layout_result.h` (cached immutable fragment), Stylo + `properties/properties.mako.rs` (struct-of-`Arc`) + `sharing/mod.rs` + (LRU sharing cache), Skia `include/core/SkColor.h` (`SkColor4f`) + + `SkPaint.h` (`fColor4f`). diff --git a/model-v2/engine/README.md b/model-v2/engine/README.md index edd248890e..04a97131f5 100644 --- a/model-v2/engine/README.md +++ b/model-v2/engine/README.md @@ -34,6 +34,10 @@ How the engine proves it is _fast_ (the four measurement axes — automated work software-unmeasurable input→photon limit) is its own doctrine: [`MEASURE.md`](./MEASURE.md). +How the engine _stores_ its data — each memory/data-layout detail decided +against verified browser prior art (`cc`/Blink/Stylo/Skia), validity-tagged +ALIGNED / ADOPTED / SOCKET — is [`DATA-MODEL.md`](./DATA-MODEL.md). + ## Contract → module → guarding test | concern | module | contract | guarding test | diff --git a/model-v2/engine/src/bin/probe.rs b/model-v2/engine/src/bin/probe.rs index 36555e9ebb..ed01c54824 100644 --- a/model-v2/engine/src/bin/probe.rs +++ b/model-v2/engine/src/bin/probe.rs @@ -145,7 +145,7 @@ fn packed(n: usize) -> (Document, Vec, RectF) { } let mut doc = b.build(); for (i, &id) in ids.iter().enumerate() { - doc.get_mut(id).fill = Some(format!("#{}", palette[i % palette.len()])); + doc.get_mut(id).fill = Some((format!("#{}", palette[i % palette.len()])).into()); } let aabb = RectF { x: 0.0, @@ -311,7 +311,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { // deliberately exercises the missing paint-damage channel: // damage::diff sees NO geometry change though the pixels do. let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; - doc.get_mut(target).fill = Some(format!("#{hex}")); + doc.get_mut(target).fill = Some((format!("#{hex}")).into()); } _ => {} }, @@ -325,7 +325,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { } else { let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; for &id in &ids[..k] { - doc.get_mut(id).fill = Some(format!("#{hex}")); + doc.get_mut(id).fill = Some((format!("#{hex}")).into()); } } } diff --git a/model-v2/engine/src/drawlist.rs b/model-v2/engine/src/drawlist.rs index 95e84b1eb7..94cdfa82c6 100644 --- a/model-v2/engine/src/drawlist.rs +++ b/model-v2/engine/src/drawlist.rs @@ -80,17 +80,12 @@ const FRAME_STROKE: Argb = 0xFFC9_CED4; const SHAPE_FILL: Argb = 0xFF4A_90D9; const TEXT_FILL: Argb = 0xFF00_0000; // black -/// Resolve a node's optional `#rrggbb` fill to packed ARGB, or the default. -/// Byte-for-byte the painter's `color()`: opaque, 6-hex only, else fallback. -fn resolve_color(hex: Option<&str>, fallback: Argb) -> Argb { - let Some(h) = hex else { return fallback }; - let h = h.trim_start_matches('#'); - if h.len() == 6 { - if let Ok(v) = u32::from_str_radix(h, 16) { - return 0xFF00_0000 | v; - } - } - fallback +/// A node's fill as packed ARGB, or the per-kind default. The color is a +/// stored number now (`model::Color`), so this is a plain read — no per-build +/// hex parse (the old `resolve_color`), no `String` chase. +#[inline] +fn fill_or(fill: Option, fallback: Argb) -> Argb { + fill.map(|c| c.argb()).unwrap_or(fallback) } /// Project the resolved tier into an ordered primitive stream — the pure @@ -123,7 +118,7 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) kind: ItemKind::RectFill { w: b.w, h: b.h, - argb: resolve_color(node.fill.as_deref(), FRAME_FILL), + argb: fill_or(node.fill, FRAME_FILL), }, }); items.push(Item { @@ -138,7 +133,7 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) }); } Payload::Shape { desc } => { - let argb = resolve_color(node.fill.as_deref(), SHAPE_FILL); + let argb = fill_or(node.fill, SHAPE_FILL); let kind = match desc { ShapeDesc::Rect => ItemKind::RectFill { w: b.w, @@ -173,7 +168,7 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) text: content.clone(), font_size: *font_size, baseline_y: font_size * 0.85, - argb: resolve_color(node.fill.as_deref(), TEXT_FILL), + argb: fill_or(node.fill, TEXT_FILL), }, }); } diff --git a/model-v2/engine/tests/cache.rs b/model-v2/engine/tests/cache.rs index 88eea4e780..c0e99cc860 100644 --- a/model-v2/engine/tests/cache.rs +++ b/model-v2/engine/tests/cache.rs @@ -41,9 +41,9 @@ fn scene() -> Document { let (h3, p3) = mk(360.0, 120.0, 90.0, 50.0, 0.0, ShapeDesc::Ellipse); let e = b.add(0, h3, p3); let mut doc = b.build(); - doc.get_mut(a).fill = Some("#4A90D9".to_string()); - doc.get_mut(c).fill = Some("#E2574C".to_string()); - doc.get_mut(e).fill = Some("#57B894".to_string()); + doc.get_mut(a).fill = Some("#4A90D9".into()); + doc.get_mut(c).fill = Some("#E2574C".into()); + doc.get_mut(e).fill = Some("#57B894".into()); doc } diff --git a/model-v2/engine/tests/drawlist.rs b/model-v2/engine/tests/drawlist.rs index e5f9c74ad9..85576cb40a 100644 --- a/model-v2/engine/tests/drawlist.rs +++ b/model-v2/engine/tests/drawlist.rs @@ -89,7 +89,7 @@ fn scene() -> (Document, NodeId, NodeId) { ); let mut doc = b.build(); - doc.get_mut(f).fill = Some("ff0000".to_string()); + doc.get_mut(f).fill = Some("ff0000".into()); (doc, f, r) } From 0cf5b29b89f7ab3d384202037447aaf1f55d2ab3 Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 11 Jul 2026 08:18:42 +0900 Subject: [PATCH 09/33] docs(format): specify Grida XML Draft 0 --- docs/wg/format/grida-xml-properties.md | 407 +++++++++ docs/wg/format/grida-xml.md | 1110 ++++++++++++++++++++++++ docs/wg/format/index.md | 14 +- 3 files changed, 1525 insertions(+), 6 deletions(-) create mode 100644 docs/wg/format/grida-xml-properties.md create mode 100644 docs/wg/format/grida-xml.md diff --git a/docs/wg/format/grida-xml-properties.md b/docs/wg/format/grida-xml-properties.md new file mode 100644 index 0000000000..6b6aa7e40e --- /dev/null +++ b/docs/wg/format/grida-xml-properties.md @@ -0,0 +1,407 @@ +--- +title: "Grida XML property registry" +description: "Cross-draft inventory of Grida XML elements, attributes, applicability, and unresolved property syntax, grounded in the production scene model." +keywords: + - grida xml + - property registry + - scene language + - xml attributes + - vector graphics + - layout +tags: + - internal + - wg + - format-schema + - canvas + - authoring + - layout + - text +format: md +--- + +# Grida XML property registry + +**Status:** Design inventory; companion to the [Grida XML Draft 0 +RFD](./grida-xml). + +This page is the canonical inventory of XML-facing property names and their +valid targets. It is not, by itself, a grammar extension. Only rows marked +**Draft 0** are accepted by a Draft 0 reader. **Placeholder** and **Design** +rows remain invalid until a later, versioned Grida XML specification defines +their complete syntax and semantics. + +Before this registry, Grida had two related documents but no complete XML +applicability crosswalk: + +- [Grida IR](./grida) inventories the production scene model. +- [Grida XML](./grida-xml) defines the small normative Draft 0 authored + language. + +This registry connects those views without making the XML spelling mirror +implementation field names. + +## Grounding and precedence + +This inventory was reconciled against the canonical scene model and the packed +archive contract. The scene model is the source of truth for representable +scene semantics; archive coverage separately determines what `.grida` can +persist. Differences between those contracts are recorded below rather than +being averaged into a third model. + +Draft 0's repeatable stroke geometry is the one accepted extension beyond that +current production model. It reuses the existing stroke geometry plus `Paints` +pair but requires the production engine and archive to own an ordered list of +those pairs; the seam is recorded explicitly below. + +Grida XML projects that model as an authored language: + +1. XML uses familiar, semantic names when CSS, SVG, HTML, or Flutter already + provide one. +2. When those vocabularies have no equivalent, an established design, motion, + or photo-tool term is preferable to a Grida-only invention. +3. Multi-word names use kebab-case. +4. Element names carry the primary type. The bounded `gradient` family alone + uses `kind` to select one of its structurally shared production variants; a + generic paint or render-node `kind` does not exist. +5. Tree nesting carries parenthood and painter order; `parent`, `children`, + and archive ordering fields are not authored properties. +6. Resolved bounds, glyph runs, vector materializations, caches, and transient + runtime identifiers are derived data, not XML properties. +7. One meaning gets one canonical writer spelling. A compact form may own a + deliberately disjoint canonical domain, as `fill="#RRGGBB"` does for one + ordinary solid; it never combines with the expanded channel. + +### Status key + +| Status | Meaning | +| --------------- | --------------------------------------------------------------------------- | +| **Draft 0** | Normative syntax accepted by the current RFD | +| **Placeholder** | Production concept exists; proposed XML name and target are held for design | +| **Design** | Capability is required or plausible, but even its XML shape is unresolved | +| **Derived** | Runtime, archive, or resolved data that should not be authored directly | + +Placeholder rows are deliberately non-operative. A Draft 0 reader must still +diagnose them as unknown syntax. + +## Element inventory + +The table covers the production node set as well as XML-only source constructs. +“Children” means render-node children, not typed property children such as +`fill`, `stroke`, or gradient `stop` elements. + +| XML element | Production concept | Children | Status | Source and use | +| ----------------- | ------------------------------------- | --------- | --------------- | ------------------------------------------------------------------------- | +| `grida` | Document envelope | One root | **Draft 0** | XML; carries only `version` | +| `container` | Container | Yes | **Draft 0** | Flutter/HTML; boxed composition and layout owner | +| `group` | Group | Yes | **Draft 0** | SVG `g`; derived bounds, no own geometry | +| `rect` | Rectangle | Yes | **Draft 0** | SVG; direct primitive | +| `ellipse` | Ellipse, ring, or arc | Yes | **Draft 0** | SVG base primitive; arc parameters are placeholders | +| `line` | Line | Yes | **Draft 0** | SVG; stroke-only primitive with repeatable stroke geometries | +| `text` | Text span; later, attributed text | No | **Draft 0** | SVG/HTML; nested `span` syntax is not defined | +| `lens` | Source transform wrapper | Yes | **Draft 0** | Grida source construct; resolves through group/transform semantics | +| `image` | Image node outside paint channels | No | **Placeholder** | HTML/SVG; contextual with the existing image-paint element | +| `path` | Raw, render-oriented SVG path | Yes | **Placeholder** | SVG; use `d`, not an implementation-shaped `data` attribute | +| `polygon` | Arbitrary closed polygon | Yes | **Placeholder** | SVG; explicit coordinate list | +| `regular-polygon` | Parametric regular polygon | Yes | **Placeholder** | Explicit name; point-count grammar still needs validation rules | +| `star` | Parametric regular star polygon | Yes | **Placeholder** | Familiar design-tool name; inner radius and point count are parametric | +| `vector` | Editable vector network | Yes | **Design** | No established XML vocabulary for vertices, handles, and filled regions | +| `boolean` | Boolean path operation | Yes | **Design** | Child ownership and live-versus-flattened behavior must be fixed | +| `markdown` | Opaque Markdown embed | No | **Design** | Runtime feature; source escaping, resources, and security need a contract | +| `html` | Opaque HTML/CSS embed | No | **Design** | Runtime feature; embedding HTML inside XML needs an explicit boundary | +| `tray` | Canvas/editor organizational tray | Yes | **Design** | Production node, but not yet justified as a portable authored primitive | +| `shape` | Future custom-shape definition or use | Undecided | **Design** | Reserved by the Draft 0 RFD; no current rendering semantics | +| _none_ | Initial viewport container | Root | **Derived** | The `grida` envelope and render root materialize this host boundary | +| _none_ | Error/import placeholder | No | **Derived** | Diagnostic result, never canonical authored content | + +Attributed text should preferably remain `` with familiar HTML/SVG-like +`` children rather than introduce a second top-level text kind. That is +a direction, not grammar: whitespace, run boundaries, inheritance, and +per-run paint must be specified first. + +## Draft 0 attribute registry + +This is the compact applicability index for syntax already defined by the +[Draft 0 RFD](./grida-xml). The RFD remains normative for value grammar, +defaults, contradictions, and resolution behavior. + +| Attribute | Valid on | Inspiration | Purpose | +| -------------------------- | ------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------- | +| `version` | `grida` | XML | Selects the exact language version; currently exactly `0` | +| `name` | All Draft 0 render elements | HTML/SVG | Human-readable label; not durable identity | +| `x`, `y` | Non-root Draft 0 render elements; omitted for in-flow flex children | SVG/CSS | Local position binding, including start/end/center/span forms | +| `width` | `container`, `rect`, `ellipse`, `line`, `text` | CSS/SVG | Authored width or element-specific `auto` | +| `height` | `container`, `rect`, `ellipse`, `text`; never `line` | CSS/SVG | Authored height or element-specific `auto` | +| `min-width`, `max-width` | `container`, `rect`, `ellipse`, `line`, `text` | CSS | Width constraints | +| `min-height`, `max-height` | `container`, `rect`, `ellipse`, `text`; never `line` | CSS | Height constraints | +| `aspect-ratio` | `rect`, `ellipse` | CSS | Supplies one otherwise unresolved box axis | +| `rotation` | All Draft 0 render elements | Flutter/design tools | Clockwise visual rotation in degrees | +| `flip-x`, `flip-y` | All Draft 0 render elements | Design tools | Native visual reflection | +| `opacity` | All Draft 0 render elements | CSS/SVG | Composites the node and descendants | +| `hidden` | All Draft 0 render elements | HTML | Removes the subtree from layout and painting | +| `fill` | `container`, `rect`, `ellipse`, `text` | SVG | Canonical compact form for one ordinary solid fill | +| `clips` | `container` | Flutter/design tools | Clips descendant content to the container shape | +| `size` | `text` | Flutter | Draft 0 font size; see the `font-size` naming issue below | +| `ops` | `lens` | CSS/SVG | Ordered 2D transform functions | +| `layout` | `container` | CSS | Selects `none` or `flex` | +| `direction` | `container` with `layout="flex"` | CSS | Main-axis direction | +| `wrap` | `container` with `layout="flex"` | CSS | Enables or disables wrapping | +| `gap` | `container` with `layout="flex"` | CSS | Main/cross spacing | +| `padding` | `container` | CSS/Flutter | Insets the content box | +| `main` | `container` with `layout="flex"` | Flutter | Main-axis distribution | +| `cross` | `container` with `layout="flex"` | Flutter | Cross-axis alignment | +| `flow` | Child of a flex container | CSS | Selects in-flow or absolute participation | +| `grow` | In-flow child of a flex container | CSS/Flutter | Main-axis growth factor | +| `align` | In-flow child of a flex container | CSS/Flutter | Per-child cross-axis override | + +## Scene-backed property placeholders + +These tables reserve discussion slots for properties in the canonical scene +model. They do not claim the proposed XML spelling is final. + +### Identity, compositing, transform, and box geometry + +| Candidate XML property | Valid on | Production concept | Inspiration | Status | +| ---------------------- | ---------------------------------------------------------- | ---------------------------------------- | ------------ | --------------- | +| `id` | Every authored render node | Durable user node identity | HTML/SVG | **Design** | +| `locked` | Every authored render node, if persisted | Editor lock metadata | Design tools | **Design** | +| `blend-mode` | Every compositing render node | Layer blend mode, including pass-through | CSS | **Placeholder** | +| `mask-type` | A node that acts as a mask source | Geometry, alpha, or luminance mask | CSS/SVG | **Design** | +| `transform` | Render nodes | Post-layout 2D affine transform | CSS/SVG | **Design** | +| `transform-origin` | Boxed render nodes | Post-layout transform pivot | CSS | **Design** | +| `corner-radius` | `container`, `rect`, `image`, polygonal and boolean shapes | Uniform or per-corner radius | CSS/Flutter | **Placeholder** | +| `corner-smoothing` | `container`, `rect`, `image` | Continuous/smoothed corners | Design tools | **Placeholder** | + +`id` must map to durable authored identity, not the runtime's transient node +handle. `transform` also cannot be admitted until its relationship to native +`rotation`, `flip-x`, `flip-y`, and `lens ops` has one canonical answer. + +### Primitive geometry + +| Candidate XML property | Valid on | Value direction | Inspiration | Status | +| ---------------------- | ------------------------- | ----------------------------------------------- | -------------------- | --------------- | +| `inner-radius` | `ellipse`, `star` | Normalized inner radius; exact domains differ | Design tools | **Placeholder** | +| `start-angle` | `ellipse` arc/ring | Clockwise degrees | Flutter/design tools | **Placeholder** | +| `sweep-angle` | `ellipse` arc/ring | Clockwise angular extent | Flutter | **Placeholder** | +| `points` | `polygon` | SVG coordinate-pair list | SVG | **Placeholder** | +| `point-count` | `regular-polygon`, `star` | Integer count, at least three | Design tools | **Placeholder** | +| `d` | `path` | SVG path-data grammar | SVG | **Placeholder** | +| `fill-rule` | `path` and vector regions | `nonzero` or `evenodd` | SVG/CSS | **Design** | +| `operation` | `boolean` | `union`, `intersection`, `difference`, or `xor` | Design tools | **Design** | +| `marker-start` | `line`, `path`, `vector` | Marker preset or future marker reference | SVG | **Placeholder** | +| `marker-end` | `line`, `path`, `vector` | Marker preset or future marker reference | SVG | **Placeholder** | + +The runtime currently calls the ellipse extent `angle`; `sweep-angle` is the +clearer authored candidate because it states what the number means. Likewise, +raw path data should use SVG's established `d` rather than exposing the +runtime field name `data`. + +### Scene image + +| Candidate XML property | Valid on | Production concept | Inspiration | Status | +| ----------------------- | -------------------------- | -------------------------------------------- | ---------------- | ------------------------------------------------------------ | +| `src` | Scene `image`; image paint | Logical resource identifier | HTML/SVG | **Placeholder** for scene image; **Draft 0** for image paint | +| `fit` | Scene `image`; image paint | `cover`, `contain`, `fill`, or intrinsic fit | Flutter/CSS | **Placeholder** for scene image; **Draft 0** for image paint | +| `object-position` | Scene `image`; image paint | Alignment inside the paint box | CSS | **Design** | +| `image-orientation` | Scene `image`; image paint | Quarter-turn/orientation handling | CSS | **Design** | +| `image-transform` | Image paint | Free affine image placement | SVG/design tools | **Design** | +| `image-repeat` | Image paint | Repeat/tile behavior | CSS | **Design** | +| `exposure` | Image paint | Image color adjustment | Design tools | **Placeholder** | +| `contrast` | Image paint | Image color adjustment | CSS/design tools | **Placeholder** | +| `saturation` | Image paint | Image color adjustment | CSS/design tools | **Placeholder** | +| `temperature`, `tint` | Image paint | Image color adjustment | Photo tools | **Placeholder** | +| `highlights`, `shadows` | Image paint | Tonal adjustment | Photo tools | **Placeholder** | + +Resource declaration, packaging, network policy, intrinsic size, and missing +resource behavior must be shared by scene images and image paints. The same +`src` must not acquire unrelated identity semantics merely because its +`image` appears in a different context. + +## Structural property elements + +Some production properties are ordered or structured values and should remain +XML children rather than become mini-languages inside attributes. + +| Property element | Valid parent | Status | Contract direction | +| -------------------- | ---------------------------------------------- | ----------- | --------------------------------------------------------------------------------- | +| `fill` | `container`, `rect`, `ellipse`, `text` | **Draft 0** | One expanded fill channel; ordered `Paints`, first bottommost | +| `stroke` | `container`, `rect`, `ellipse`, `line`, `text` | **Draft 0** | Repeatable geometry owning ordered `Paints`; empty only with non-default geometry | +| `solid` | `fill` or `stroke` | **Draft 0** | Typed solid paint | +| `gradient` | `fill` or `stroke` | **Draft 0** | Typed gradient family with required `kind` and `stop` children | +| `image` | `fill` or `stroke` | **Draft 0** | Contextual image paint | +| `stop` | `gradient` | **Draft 0** | Ordered offset/color pair | +| `effects` | Effect-capable node | **Design** | Typed effect values; ordering and multiplicity are unresolved | +| `span` | `text` | **Design** | Attributed run with inherited or overridden text properties | +| `network` | `vector` | **Design** | Editable vertices, Bézier segments, loops, regions, and region fills | +| `resources` | Document-level declaration scope | **Design** | Portable logical IDs and packaged/external resource policy | +| `component`, `use` | Document definitions and scene | **Design** | Reusable authored widgets and instances | +| Animation vocabulary | Document, node, or property scope | **Design** | Requires a dedicated timing and addressing RFD | + +### Paint attribute registry + +These paint attributes are already normative in Draft 0. Their semantics are +defined in the [paint channels +section](./grida-xml#paint-channels-and-vocabulary). + +| Attribute | Valid on | Purpose | +| ------------ | ------------------------------------------- | -------------------------------------------------- | +| `visible` | Every typed paint | Retains an inactive paint in its ordered stack | +| `opacity` | Every typed paint; also optional on `stop` | Paint alpha, or stop alpha in `stop` context | +| `blend-mode` | Every typed paint | Per-paint compositing mode | +| `color` | `solid`, `stop` | Solid or stop color | +| `kind` | `gradient` | Required linear, radial, sweep, or diamond variant | +| `from`, `to` | `gradient kind="linear"` | Unit-paint-space endpoints | +| `transform` | `gradient` | Unit-paint-space affine transform | +| `tile-mode` | `gradient kind="linear"` or `kind="radial"` | Sampling outside the ramp | +| `offset` | `stop` | Normalized ramp position | +| `src` | Image paint inside `fill` or `stroke` | Logical image resource identifier | +| `fit` | Image paint inside `fill` or `stroke` | Cover, contain, fill, or intrinsic image placement | + +`opacity` is contextual: on a gradient or image it remains a floating-point +paint property, while on `solid` and `stop` it materializes through color +alpha as described by the RFD's quantization rule. + +## Stroke geometry attribute registry + +Each `stroke` is one geometry with direct typed paint children. An empty paint +list is valid only when at least one geometry value differs from the target's +defaults. These scoped attributes are normative in Draft 0: + +| Attribute | Value | Valid targets | Purpose | +| ------------- | ------------------------------------------ | -------------------------------------- | -------------------------------------------- | +| `width` | non-negative finite number | every `stroke` | Uniform logical-pixel width | +| `align` | `inside`, `center`, or `outside` | every `stroke`; line must be `center` | Placement relative to the original outline | +| `cap` | `butt`, `round`, or `square` | `line` | Open-contour endpoint shape | +| `join` | `miter`, `round`, or `bevel` | `container`, `rect` | Joined-contour corner shape | +| `miter-limit` | positive finite number | `container`, `rect` | Miter-to-bevel cutoff ratio | +| `dash-array` | space-separated non-negative finite values | `container`, `rect`, `ellipse`, `line` | Repeating dash-gap lengths in logical pixels | + +The production model already separates one stroke `Paints` stack from one +stroke geometry. One XML `stroke`, using only attributes supported by its +target, projects that pair directly. A default empty pair canonicalizes to +omission; non-default empty geometry remains explicit. Repeatable `stroke` +elements are an accepted extension to an ordered list of those pairs; the +current production model still needs that multiplicity before it can +materialize more than one without loss. + +### Deferred stroke geometry + +| Candidate XML property | Valid on | Production concept | Status | +| ---------------------- | ------------------------------------------- | -------------------------------------------- | --------------- | +| Per-side width grammar | `container`, `rect`, and future image boxes | Rectangular stroke widths | **Design** | +| Width-profile grammar | Future `vector` | Ordered normalized-position/half-width stops | **Design** | +| `dash-offset` | `stroke` | Phase offset into the dash cycle | **Design** | +| `marker-start` | Open path-like stroke geometry | Endpoint marker preset or reference | **Placeholder** | +| `marker-end` | Open path-like stroke geometry | Endpoint marker preset or reference | **Placeholder** | + +Per-side widths and variable-width profiles need nested or shorthand grammar +of their own; neither should be forced into an opaque comma-separated +attribute merely to make it look CSS-like. + +## Text property placeholders + +Uniform text maps naturally to CSS names. Rich text should use the same names +on future `span` children, with inheritance and override rules defined once. + +| Candidate XML property | Valid on | Production concept | Inspiration | Status | +| --------------------------- | -------------- | ----------------------------------------- | ----------- | --------------- | +| `font-family` | `text`, `span` | Font family | CSS | **Placeholder** | +| `font-size` | `text`, `span` | Font size | CSS/SVG | **Design** | +| `font-weight` | `text`, `span` | Font weight | CSS | **Placeholder** | +| `font-stretch` | `text`, `span` | Font width | CSS | **Placeholder** | +| `font-style` | `text`, `span` | Normal or italic style | CSS | **Placeholder** | +| `font-kerning` | `text`, `span` | Kerning switch | CSS | **Placeholder** | +| `font-optical-sizing` | `text`, `span` | Optical sizing mode | CSS | **Placeholder** | +| `font-feature-settings` | `text`, `span` | OpenType feature-tag values | CSS | **Design** | +| `font-variation-settings` | `text`, `span` | Variable-font axis values | CSS | **Design** | +| `letter-spacing` | `text`, `span` | Letter spacing | CSS | **Placeholder** | +| `word-spacing` | `text`, `span` | Word spacing | CSS | **Placeholder** | +| `line-height` | `text`, `span` | Fixed or proportional line height | CSS | **Placeholder** | +| `text-transform` | `text`, `span` | Case transformation | CSS | **Placeholder** | +| `text-align` | `text` | Horizontal paragraph alignment | CSS | **Placeholder** | +| `text-align-vertical` | Boxed `text` | Top, center, or bottom placement | Flutter | **Placeholder** | +| `max-lines` | `text` | Paragraph line clamp | Flutter | **Placeholder** | +| `text-overflow` | `text` | Clip, ellipsis, or authored marker string | CSS/Flutter | **Design** | +| `text-decoration-line` | `text`, `span` | Underline, overline, or line-through | CSS | **Placeholder** | +| `text-decoration-style` | `text`, `span` | Solid, double, dotted, dashed, or wavy | CSS | **Placeholder** | +| `text-decoration-color` | `text`, `span` | Decoration color override | CSS | **Placeholder** | +| `text-decoration-thickness` | `text`, `span` | Decoration thickness | CSS | **Placeholder** | +| `text-decoration-skip-ink` | `text`, `span` | Glyph-ink skipping | CSS | **Placeholder** | + +Draft 0 currently calls font size `size`. Before typography is expanded, the +RFD must either rename it to the more established `font-size` in a versioned +change or explicitly defend `size` as the sole canonical spelling. Accepting +both indefinitely would violate the one-spelling rule. + +CSS `vertical-align` is intentionally not proposed for paragraph-box vertical +placement: its web meaning concerns inline/baseline alignment. Flutter's +`textAlignVertical` is the closer semantic precedent. + +## Effect placeholders + +The canonical scene model includes one layer blur, one backdrop blur, one +liquid-glass effect, multiple shadows, and multiple noise effects. Candidate +typed names are familiar, but the container grammar is not yet decided. + +| Candidate typed effect | Core properties | Inspiration | Status | +| ---------------------- | ----------------------------------------------------- | ------------------ | ---------- | +| `blur` | `radius`; progressive form also needs start/end radii | CSS/Flutter | **Design** | +| `backdrop-blur` | `radius` or progressive blur parameters | CSS/Flutter | **Design** | +| `drop-shadow` | `x`, `y`, `blur`, `spread`, `color`, `visible` | CSS/SVG | **Design** | +| `inner-shadow` | `x`, `y`, `blur`, `spread`, `color`, `visible` | CSS/design tools | **Design** | +| `noise` | size, density, octaves, seed, coloring, blend mode | Motion/photo tools | **Design** | +| `liquid-glass` | light, refraction, depth, dispersion, blur parameters | Design tools | **Design** | + +An ordered `` list would be ergonomic, but the production model is +not a single ordered heterogeneous list. The XML must not imply reorderable +effect semantics until either the existing slots and lists receive a defined +cross-type order or the engine model deliberately changes. + +## Syntax that needs dedicated design + +These are not ordinary missing attributes. Each needs a focused RFD because +its structure, identity, or evaluation rules cannot be made reliable by +choosing a name alone. + +| Area | Production requirement | Questions the syntax must answer | +| ----------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Editable vector network | Vertices, cubic tangents, segments, loops, filled regions | Stable local IDs or indices; open/closed contours; region fill stacks; validation | +| Variable-width stroke | Ordered `(u, half-width)` samples | Nested stops versus compact list; interpolation; endpoints; base-width interaction | +| Per-side box geometry | Four stroke widths and four elliptical corner radii | CSS shorthand reuse versus explicit children; normalization; mixed units | +| Attributed text | Text plus styled byte ranges and per-run paints | `` nesting; Unicode offsets; whitespace; inheritance; overlapping runs | +| Masks | Geometry, alpha, and luminance mask nodes | Which sibling is the mask; scope; consumption; stacking; clipping interaction | +| Effects | Typed singleton and repeated effect slots | Cross-type order; multiplicity; compositing bounds; visibility and blend metadata | +| Image resources and placement | Logical IDs, hashes, fit, affine placement, tiling | Packaging; base URI; network policy; intrinsic size; missing-resource behavior | +| Boolean operations | Live child geometry and operation enum | Ordered operands; child ownership; nesting; flattening; paint inheritance | +| Transform model | Native rotation, affine transforms, origin, source lens | One canonical authored form; layout-versus-paint transform; decomposition stability | +| Components and reuse | Definitions, instances, overrides, identity | Definition scope; `use`; slots; overrides; cycles; resource and animation binding | +| Animation | Time-varying values and motion-graphics behavior | Addressing, interpolation, timelines, expressions, easing, events, serialization | +| Embedded Markdown/HTML | Opaque render-only content | Escaping, sanitization, fonts/resources, intrinsic sizing, deterministic rendering | +| Durable identity | Stable user IDs distinct from runtime handles | Uniqueness scope; copy/paste; merge; references; agent-generated IDs | + +The vector-network and width-profile grammars are the clearest examples of +where new syntax must be invented. Treating either as a JSON-shaped `data` +attribute would hide structure, weaken diagnostics, and make hand or LLM +authoring less reliable. + +## Current scene-contract seams to reconcile + +This registry does not itself change the canonical scene or archive contracts. +The following seams constrain conformance or require the explicit extension +named by the Draft 0 RFD: + +| Seam | Why it matters to Grida XML | +| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The current scene contract stores one stroke geometry with one ordered `Paints`, while Draft 0 repeats them. | One stroke maps directly; multiple strokes require an ordered list and must not be merged or hidden as duplicate scene nodes. | +| The scene model can hold malformed gradients that Draft 0 deliberately cannot spell. | A writer rejects too few stops, invalid or descending offsets, and coincident linear endpoints instead of repairing, sorting, clamping, or perturbing them. | +| Scene semantics and packed-archive coverage do not yet cover the same node set. | An authored element must not promise `.grida` round-trip until both contracts represent it. | +| Raw-path fill rules and post-layout transform origins are not uniform across scene and archive contracts. | `fill-rule`, `transform`, and `transform-origin` remain deferred until one meaning survives both rendering and persistence. | +| Draft 0 `hidden` removes a subtree from layout and paint. | A materializer must preserve that stronger behavior even when a target model names visibility differently. | +| Solid-paint alpha is stored in color, while Draft 0 exposes common paint `opacity`. | The documented 8-bit quantization is a boundary rule, not a parallel solid-opacity property. | +| Draft 0 uses `size` for text while established style vocabularies use `font-size`. | The language must choose one canonical spelling before expanding typography. | +| Responsive bindings and per-child alignment carry more source intent than a resolved Cartesian scene. | A resolved scene can consume their result but must not be mistaken for a lossless rewrite of the authored source. | +| Names, locks, durable identity, guides, and connections belong to different persistence concerns. | `id`, `name`, and `locked` need explicit authored-versus-editor rules; a root fill already covers visual background without deciding editor-only metadata. | +| `lens` operations and native flips may lower through more general transforms. | Materialization can preserve pixels without promising a lossless inverse decomposition; source round-trip requires an intent-preserving representation. | + +When the language, runtime, and archive disagree, a processor must limit its +claimed subset or make the model change explicit. It must never create a +parallel XML-only property merely to conceal the incompatibility. diff --git a/docs/wg/format/grida-xml.md b/docs/wg/format/grida-xml.md new file mode 100644 index 0000000000..356985e362 --- /dev/null +++ b/docs/wg/format/grida-xml.md @@ -0,0 +1,1110 @@ +--- +title: "Grida XML — authored scene language" +description: "Open RFD for Draft 0 of .grida.xml, an inspectable XML source language for responsive 2D presentations and modern user interfaces." +keywords: + - grida xml + - scene language + - intermediate representation + - 2d graphics + - layout + - text + - paint stacks + - gradients + - image fills + - multiple strokes +tags: + - internal + - wg + - format-schema + - canvas + - authoring + - layout + - text +format: md +--- + +# Grida XML — authored scene language + +**Status:** Open RFD — Draft 0. + +**Authored file suffix:** `.grida.xml` — provisional. + +**Originating request:** [gridaco/grida#957](https://github.com/gridaco/grida/issues/957). + +## Thesis + +Grida XML is an authored, inspectable scene language for dynamic 2D content. +It combines a small primitive-first graphics vocabulary with explicit box, +binding, text-flow, and layout intent. Its target range includes both +responsive presentations and modern user-interface designs. + +The language is XML because a tree is the scene's natural structure, XML is +widely inspectable, and its element/attribute boundary is predictable for both +human authors and language models. Grida XML borrows useful ideas from SVG, +HTML/CSS, and widget trees without inheriting any one of their object models. +It is its own vocabulary, not an SVG profile or an HTML serialization. + +Draft 0 is deliberately small. It establishes the document boundary, the box +and nesting model, a minimal primitive set, ordered typed paint, multiple +stroke geometries, text boxes, free bindings, and flex layout. Later drafts +can add effects, vector geometry, resource declarations, typography, and +reuse facilities without changing the central tree model. + +## Design requirements + +1. **One obvious tree.** Outside explicitly typed property subtrees such as + `fill` and `stroke`, element nesting is scene nesting. Source order is + painter order among sibling nodes, stroke geometries, and paints within a + channel. A node's children live in that node's local coordinate space. +2. **Composition instead of combined node kinds.** Every Draft 0 render + element except `text` can contain children. A rectangle containing text is + written as a `rect` containing a `text` element, not as a special + `ShapeWithText` kind. +3. **A minimal primitive vocabulary.** One primitive has one semantic job. + Equal width and height make an ellipse a circle; there is no separate + `circle` kind. +4. **Intent survives responsiveness.** Bindings, auto sizes, text constraints, + and layout relationships remain authored intent. They are not flattened to + the coordinates produced by one render. +5. **Predictable authoring.** A current-version reader rejects unknown or + contradictory syntax. It never guesses, silently drops, or silently + reinterprets an authored construct. +6. **Inspectable by default.** Canonical names are descriptive, values are + textual, and the file can be read or repaired without proprietary tooling. +7. **Typed paint, not a parallel style language.** Fill channels and typed + paint values project the ordered Grida paint model directly. `solid`, + `gradient`, and `image` are semantic paint elements; structured values + remain structured. Each stroke geometry owns the same ordered paint value, + while repetition is a declared extension to the current production + single-geometry scene model. + +## File identity + +`.grida.xml` identifies the authored XML language defined here. The compound +suffix is provisional, but it is intentionally distinct from `.grida`, the +packed binary scene/archive format. The two files may describe equivalent +scene intent, but they are different representations with different goals: + +| Representation | Primary property | +| -------------- | ------------------------------------------ | +| `.grida.xml` | Authored, inspectable, diffable XML source | +| `.grida` | Packed binary storage and interchange | + +Changing or packing one representation into the other is a conversion. A +processor must not infer Grida XML merely from a `.grida` suffix, nor infer the +binary format from `.grida.xml`. + +## Document model + +A Draft 0 document has this shape: + +```xml + + + + + + +``` + +The XML declaration is optional; when present, it appears once and before all +other content, declares XML `version="1.0"`, may declare only +`encoding="UTF-8"`, and carries no other fields. The `grida` element is +required and is only a document envelope: it has no box, paint, transform, or +layout behavior. It must carry exactly `version="0"` and exactly one element +child. That child must be a `container` and is the document's render root. +Comments and formatting whitespace do not count as render roots. + +Because the render root has no visual parent, it must omit the parent-relative +attributes `x`, `y`, `flow`, `grow`, and `align`. +At resolution, a non-painting viewport parent places the render root's local +origin at viewport `(0, 0)`. A fixed root extent is not scaled to the viewport; +it may be smaller than or overflow that environment. + +Draft 0 defines no XML namespace and no extension namespace. Namespace-based +extensions require a later version rather than being accepted as inert data. +Comments are allowed. CDATA sections, document types, and processing +instructions are not; text uses ordinary escaped XML character data. + +### Core node taxonomy + +| Element | Box source | Children | Meaning | +| ----------- | ------------------------------- | -------- | --------------------------------------------------- | +| `container` | Declared; `auto` may hug | Yes | Boxed composition and the sole flex-layout owner | +| `rect` | Declared | Yes | Parametric rectangle realized in its box | +| `ellipse` | Declared | Yes | Parametric ellipse realized in its box | +| `line` | Declared width; zero-height box | Yes | Horizontal local line, orientable by rotation | +| `text` | Measured under box constraints | No | Unicode text flowed into an auto or fixed text box | +| `group` | Derived from child bounds | Yes | Logical subtree with an explicit local origin | +| `lens` | Derived from child bounds | Yes | Group whose ordered operations affect painting only | + +`text` is the only leaf in Draft 0. Every other node accepts an ordered list +of render-node children, including each primitive element. A paintable parent +paints its fill first, then its children in document order, then its own +strokes. Later siblings paint above earlier siblings. + +`shape` is a reserved element name, not a Draft 0 render element. It is held +for a possible future custom-shape definition. This reservation assigns no +box, paint, child, definition, or reuse behavior. A Draft 0 reader **MUST** +reject ``, including historical ``. The bounded +`kind` discriminator belongs only to the `gradient` paint family; it is not a +render-node attribute. + +The nesting contract is normative. This is a regular rectangle with a text +child: + +```xml + + Continue + +``` + +The text is positioned in the rectangle's local 240 by 64 box. No combined +shape-with-text node exists or is implied. + +## Scalar and coordinate conventions + +- Numbers are finite decimal values with no unit suffix. Geometry numbers are + logical pixels. +- The local origin is the top-left of a boxed node. Positive x points right; + positive y points down. +- Widths and heights are non-negative. Binding offsets may be negative. +- Angles are degrees, increasing clockwise in the y-down coordinate system. +- Boolean values are exactly `true` or `false`. +- Authored colors use `#RGB` or `#RRGGBB`; a canonical writer emits uppercase + `#RRGGBB`. Alpha belongs to `opacity`, not to the color token. Richer color + spaces are future work. + +## Property registry + +The [Grida XML property registry](./grida-xml-properties) is the canonical +cross-draft inventory of element and property names, their valid targets, and +production-backed placeholders. This RFD remains the normative Draft 0 +grammar. A property marked Placeholder or Design in the registry is therefore +still unknown syntax to a Draft 0 reader. + +## Common authored attributes + +Only attributes applicable to a node may appear on that node. + +| Attribute | Value | Default | Contract | +| ------------------------- | ----------------------------------------- | ---------- | ---------------------------------------------------------------- | +| `name` | XML string | none | Human-readable label; it is not durable node identity | +| `x`, `y` | position binding | `0` | Places a free-positioned node in its parent's local box | +| `width`, `height` | finite number or `auto`, where applicable | by element | Declares a fixed extent or requests element-specific measurement | +| `min-width`, `min-height` | non-negative finite number | none | Sets a lower bound on a non-derived box axis | +| `max-width`, `max-height` | non-negative finite number | none | Sets an upper bound on a non-derived box axis | +| `aspect-ratio` | positive finite `width:height` pair | none | Resolves one otherwise under-specified box axis | +| `rotation` | finite number | `0` | Visual rotation about box center; about origin for derived kinds | +| `flip-x`, `flip-y` | `true` or `false` | `false` | Reflects paint horizontally or vertically before rotation | +| `fill` | `#RGB` or `#RRGGBB` | none | Canonical compact form for one ordinary solid fill | +| `opacity` | finite number in `[0, 1]` | `1` | Composites the node and its descendants | +| `hidden` | `true` or `false` | `false` | Removes the subtree from layout and painting | +| `flow` | `in` or `absolute` | `in` | Opts a child into or out of a flex parent's flow | +| `grow` | non-negative finite number | `0` | Flex main-axis growth factor | +| `align` | `start`, `center`, `end`, or `stretch` | none | Per-child flex cross-axis override | + +`width` and `height` are the only Draft 0 spellings. The same rule applies to +`min-width`, `max-width`, `min-height`, `max-height`, and `aspect-ratio`. +Short experimental spellings such as `w`, `h`, `min-w`, and `aspect` are not +Grida XML. Likewise, `container` is the node name; `frame` is not a Grida XML +alias. Historical `` is TextIr syntax, not a Grida XML alias. +A host may support that import dialect separately, but a conforming Draft 0 +writer never emits it and a strict Grida XML reader does not confuse it with +this language. + +Rotation is visual-only intent. It does not change a node's layout box, flex +contribution, or a container's hug size; the resolved visual bounds do include +the rotated paint. Flips have the same visual-only status and use the same +pivot rule as rotation. A node's local paint is reflected first and then +rotated; neither operation writes a matrix or negative extent into source. + +`fill="#fff"` means exactly one visible, fully opaque, normal-blend solid +paint. It is not legacy syntax: it is the canonical compact form for the most +common fill. It cannot coexist with the structured `` channel. `fill` is +valid on `container`, `rect`, `ellipse`, and `text`; it is invalid on `line`, +`group`, and `lens`. + +## Paint channels and vocabulary + +The XML topology separates where paint is applied from what the paint is: + +```text +render node +├─ fill attribute or fill channel → ordered Paint* +└─ stroke geometry* → ordered Paint* + +Paint := solid | gradient(kind) | image +``` + +`fill` is the node's single fill channel. `stroke` is a repeatable stroke +geometry, and each occurrence owns one paint channel. Neither is a render node +or an extra compositing layer. Typed paint children are ordered bottom to top: +the first is painted first, and each later paint composites above the +accumulated result. + +This is the canonical expanded form for a rich fill: + +```xml + + + + + + + + + + + + + Example + +``` + +A node's structural paint children must precede character data and render-node +children. When both channels occur, the optional `fill` element comes first, +followed by zero or more `stroke` elements. `container`, `rect`, `ellipse`, and +`text` accept fills and strokes. `line` accepts strokes only. `group` and +`lens` accept neither. + +The direct paint vocabulary is: + +| Element | Paint value | +| ---------- | -------------------------------------------------------------------------- | +| `solid` | One color with alpha carried by paint opacity | +| `gradient` | A structured stop ramp with `linear`, `radial`, `sweep`, or `diamond` kind | +| `image` | A referenced image placed into the node's paint box | + +The element name carries the paint type. `gradient` is one bounded paint +family whose required `kind` selects the existing gradient variant; this is +not a generic `` design. ``, ``, +``, and the four kind-specific gradient element names are not +aliases. `solid` and `image` are empty elements. `gradient` is non-empty +because its direct children are the structured stop ramp. + +### Fill defaults, emptiness, and canonical shorthand + +Omitting both the `fill` attribute and the `` child element applies the +element's language default: + +| Element | Default fill paint stack | +| ------------------------- | ----------------------------------- | +| `container` | empty | +| `rect`, `ellipse`, `text` | one opaque normal-blend black solid | +| `line`, `group`, `lens` | not fillable | + +`` differs from omission on primitives and text: it suppresses the +default black paint. A reader also accepts a structured one-solid channel, but +there is one canonical writer representation. The writer applies these rules +in order: + +1. If the fill stack equals the element default, omit both forms. +2. If it is exactly one visible, fully opaque, normal-blend solid, emit the + `fill="#RRGGBB"` attribute. +3. For every other non-empty stack, emit one `fill` element with typed paint + children in bottom-to-top order. +4. If the stack is empty but omission would restore a non-empty default, emit + ``. + +The two fill forms are mutually exclusive. A processor must never interpret +the attribute as an implicit bottom paint beneath an expanded channel. + +### Multiple stroke geometries + +One stroke paint stack and multiple stroke geometries are different +capabilities. Multiple paints within one `stroke` share one coverage mask: +they have the same width, alignment, cap, join, and dash pattern. Repeated +`stroke` elements generate independent coverage masks over the same underlying +node outline, and each mask has its own ordered paint stack. + +This distinction solves designs that a single SVG stroke cannot represent +directly: road casings, double borders, concentric keylines, focus rings plus +borders, and decorative inner and outer outlines. SVG or CSS authors commonly +duplicate geometry, nest wrappers, combine `border` and `outline`, introduce +pseudo-elements, or approximate an outline with shadows. Those workarounds +split one semantic path across several scene objects, make geometry edits +drift, and often disagree at joins, dashes, transforms, or hit bounds. Native +multi-stroke keeps one shape, one layout identity, and one editable outline. + +```xml + + + + + + + + + + + + + +``` + +The uniform Draft 0 stroke geometry is: + +| Attribute | Value | Default | Valid targets | +| ------------- | ------------------------------------------ | ---------------------------- | -------------------------------------- | +| `width` | non-negative finite logical pixels | `1` | every `stroke` | +| `align` | `inside`, `center`, or `outside` | `inside`; `center` on `line` | every `stroke`; line must be `center` | +| `cap` | `butt`, `round`, or `square` | `butt` | `line` | +| `join` | `miter`, `round`, or `bevel` | `miter` | `container`, `rect` | +| `miter-limit` | positive finite number | `4` | `container`, `rect` | +| `dash-array` | space-separated non-negative finite values | absent, meaning solid | `container`, `rect`, `ellipse`, `line` | + +When present, `dash-array` contains at least one value and is not all zero. +Odd-length arrays repeat once to make an even dash-gap cycle. An attribute on a +target outside this table is an error rather than dormant state. A `line` is an +open path and accepts only `align="center"`, because inside and outside are not +defined for it. Per-side widths, variable-width profiles, dash offset, and +endpoint markers are later vocabulary. + +Stroke width is distributed relative to the source outline. `center` places +half the width on each side; `inside` places the full width toward the filled +region; `outside` places it away from the filled region. For text, that region +is each shaped glyph's filled outline, including the inverse direction around +counters. Line caps and rectangular joins use the declared `cap`, `join`, and +`miter-limit` values. + +Dash traversal begins at the line's local start point, at a rectangle's +top-left corner moving clockwise, and at an ellipse's rightmost point moving +clockwise. The first dash begins at that origin because Draft 0 has no dash +offset. Each distinct contour restarts the pattern. + +Stroke elements are ordered in painter order: the first stroke is bottommost +and the last is topmost. Paints within each stroke are independently ordered +bottom to top. A node paints its fill stack, then its render-node children, then +all stroke geometries. Thus children remain below their parent's border-like +strokes. Each stroke is derived independently from the same original outline; +a later stroke is not offset from, clipped by, or inherited from an earlier +stroke. + +Omitting all `stroke` elements means no strokes. A stroke normally contains at +least one typed paint child. An empty stroke is valid only when it carries at +least one geometry value that differs from that target's defaults; it preserves +dormant geometry that the current model can distinguish. A default empty +`` is invalid because the model has no presence bit that distinguishes +it from omission. A zero-width or wholly invisible painted stroke likewise +remains authored intent and must not be deleted or reordered by a canonical +writer. + +#### Accepted model extension: stroke geometry multiplicity + +The current production Grida scene model has one ordered stroke `Paints` value +plus one shared stroke geometry per node. One XML `stroke`, when its attributes +apply to the target node, projects that pair directly. Two or more stroke +elements cannot: merging their paint lists would erase geometry, while +duplicating the scene node would erase the one-shape semantic contract. + +Draft 0 therefore deliberately widens this part of the scene contract to an +ordered list of stroke geometries, each owning `Paints`. The language contract +is accepted ahead of that production scene/archive change; it is a declared +required model extension, not permission to retain a permanent XML-only list. +Until a materializer's target model can represent the list, it must reject the +second stroke with an unsupported-model diagnostic; it must not keep only one, +merge them, or create hidden duplicate nodes. + +### Coverage and paint boxes + +Paint coverage and paint coordinates are separate. Coverage determines which +pixels a channel may affect; the paint box supplies the local coordinate space +for gradients and image placement. + +| Target | Fill coverage | Stroke source outline | Paint box | +| ------------------- | ----------------------------------- | ---------------------------------- | --------------------------------------------- | +| `container`, `rect` | rectangular interior | rectangular perimeter | resolved local layout box | +| `ellipse` | elliptical interior | elliptical perimeter | resolved local layout box | +| `line` | none | authored local line segment | resolved segment box with degenerate fallback | +| `text` | shaped glyph interiors, not its box | shaped glyph contours, not its box | resolved local text layout box | + +The paint box is resolved after bindings, flex sizing, constraints, and text +measurement, but before the node's visual rotation or flips. Children do not +change their parent's coverage, outline, or paint box. A zero-length paint-box +axis is replaced for paint-coordinate mapping only by a one-logical-pixel +interval centered on that axis; the node's actual geometry and layout extent +remain zero. For the resulting effective box `(x, y, width, height)`, unit paint +coordinates map through +`translate(x,y) × scale(width,height) × paint-transform`. + +Stroke paints use the same box as fills, so changing stroke width does not +rescale a gradient or image. Paint is evaluated in that box and then clipped by +the individual stroke coverage. A solid extends throughout that coordinate +space. Gradient samples outside the unit box follow the gradient kind's tile or +clamp rule. An image is transparent outside the image rectangle produced by +its `fit`, so an outside-aligned stroke may sample transparency beyond the node +box. + +Strokes do not grow layout bounds. Resolved visual bounds conservatively include +the coverage of effective strokes: a stroke must have positive width and at +least one visible, non-zero-opacity paint. Those bounds may contain transparent +image or gradient samples; exact non-transparent pixel bounds are not required. +Centered and outside-aligned effective strokes may extend the conservative +visual bounds beyond the layout box. + +#### Conformance on degenerate paint boxes + +Draft 0's one-pixel paint-space fallback is required for rich paint on +degenerate geometry. A materializer that lacks this mapping must report the +case as unsupported rather than silently substitute a different gradient or +image mapping. + +### Common paint properties + +Every typed paint accepts the same compositing properties: + +| Attribute | Value | Default | Meaning | +| ------------ | ---------------------------------- | -------- | ------------------------------------------- | +| `visible` | exactly `true` or `false` | `true` | Whether this paint participates in painting | +| `opacity` | finite number in `[0,1]` | `1` | Alpha applied to this paint only | +| `blend-mode` | one of the paint blend modes below | `normal` | How this paint composites over paints below | + +Visibility is stored intent. A false paint remains in the ordered list but +does not draw. Zero opacity likewise has no visual contribution; neither case +authorizes a writer to delete or reorder the paint. + +Paint opacity is distinct from node opacity. Paint opacity affects only that +entry while the node's `opacity` composites the node and its descendants as a +group. Moving a value between these two levels is not a semantic-preserving +normalization. + +Paint blend modes are `normal`, `multiply`, `screen`, `overlay`, `darken`, +`lighten`, `color-dodge`, `color-burn`, `hard-light`, `soft-light`, +`difference`, `exclusion`, `hue`, `saturation`, `color`, and `luminosity`. +`pass-through` is a layer mode, not a paint blend mode. Each paint's blend mode +applies while that paint composites over the accumulated paints below it; it +does not retroactively alter an earlier paint. + +#### Conformance on stack compositing + +Collapsing a paint stack into one shader is not equivalent to the ordered model +for every stack. For example, a bottom `multiply` paint followed by a +translucent normal paint must multiply only the bottom entry; applying +`multiply` to a combined result also changes the later entry. + +Draft 0 follows the `Paints` model: each visible entry composites in order with +its own blend mode against the actual accumulated result. An implementation +may collapse the operations only when it proves the result equivalent. This is +a quarantined renderer incompatibility, not grounds to change paint order, +move blend metadata, or fork the paint model. + +### Solid colors and alpha quantization + +`solid` requires `color`. Its authored `opacity` is represented as an 8-bit +color alpha in the common paint model, so parsing quantizes opacity to the +nearest alpha byte: + +```text +stored opacity = round(authored opacity × 255) / 255 +``` + +When a canonical writer emits a structured solid or stop opacity, it emits that +stored value. For example, authored `0.5` normalizes to `128/255`, approximately +`0.5019608`. This is an intentional projection of the common paint model, not +an independent floating-point solid opacity. A stop's optional `opacity` uses +the same 8-bit quantization for its color alpha. A gradient or image paint's +own opacity remains independent of the colors or pixels it modulates. + +### Structured gradients + +Every `gradient` requires exactly one `kind`, one of `linear`, `radial`, +`sweep`, or `diamond`. These values map one-for-one to the existing Grida paint +variants; the family element does not collapse them into a new runtime type. +The element contains at least two empty `stop` children. It permits comments +and formatting whitespace, but no other element or non-whitespace character +data. A stop requires a finite `offset` in `[0,1]` and a color, and may carry +`opacity`; it does not accept paint-level visibility or blend attributes. Stops +appear in nondecreasing offset order. Equal adjacent offsets are valid and +express a hard transition; a reader must preserve their source order and must +not sort, merge, or deduplicate them. + +The gradient-kind attribute boundary is strict: + +| Attribute | `linear` | `radial` | `sweep` | `diamond` | +| ------------ | -------- | -------- | -------- | --------- | +| `kind` | required | required | required | required | +| `transform` | yes | yes | yes | yes | +| `from`, `to` | yes | no | no | no | +| `tile-mode` | yes | yes | no | no | + +Changing `kind` preserves stops, `transform`, and the common paint properties. +Attributes that do not apply to the new kind must be removed; they never remain +as dormant state. The discriminator edit is localized, but a kind switch with +variant-specific attributes also requires that explicit cleanup, and switching +back does not restore discarded state. + +All gradient geometry is defined in the unit paint box `[0,1] × [0,1]`, with +`(0,0)` at top-left and `(1,1)` at bottom-right. The optional +`transform="a b c d e f"` is a finite affine transform in that unit space, +represented as: + +```text +[ a c e ] +[ b d f ] +``` + +It defaults to identity. The effective paint-box mapping defined above composes +its translation and scale with this transform, including the declared +degenerate-axis fallback, so gradient geometry remains independent of the +node's pixel size. + +A linear gradient additionally carries `from="u v"` and `to="u v"`. They +default to `0 0.5` and `1 0.5`. Each requires exactly two finite values; values +outside `[0,1]` are allowed to place an endpoint outside the paint box, but the +two endpoints must differ. They are first-class linear-kind state, not a +second spelling of the transform. + +The remaining base geometries are fixed before the transform: + +| Type | Unit-space ramp domain | +| ------- | --------------------------------------------------------------------------- | +| radial | center `(0.5,0.5)`, radius `0.5`; `t` grows from center to radius | +| sweep | center `(0.5,0.5)`; `t=0` points right and grows clockwise through one turn | +| diamond | `t = min(1, 2 × (abs(u-0.5) + abs(v-0.5)))`, the Manhattan/L1 distance ramp | + +Linear and radial gradients accept `tile-mode`, one of `clamp`, `repeated`, +`mirror`, or `decal`, defaulting to `clamp`. Sweep and diamond gradients are +clamped and reject that attribute in Draft 0. + +| Tile mode | Samples outside the gradient domain | +| ---------- | ----------------------------------------------------- | +| `clamp` | extend the nearest edge color | +| `repeated` | repeat the ramp in the same direction | +| `mirror` | repeat the ramp, reversing every alternate repetition | +| `decal` | become transparent | + +#### Linear endpoint numeric seam + +The common paint model stores linear endpoints as binary32 centered-normalized +alignments. The Draft 0 boundary evaluates the authored UV conversion +`alignment = 2 × uv - 1` in binary64 and then stores binary32; a canonical +writer computes the inverse in binary64 and verifies that reparsing recovers +the exact binary32 model value. This preserves ordinary arbitrary model values, +including centered-normalized values such as `0.1`, without redefining the +paint model. + +A microscopic subset of finite binary32 alignments too close to centered zero +cannot survive that binary64 UV conversion. The smallest positive binary32 +subnormal is one example: adding its inverse UV delta to `0.5` rounds back to +exactly `0.5`. A canonical writer must reject such an endpoint with a precise +representability error rather than normalize it to center. This is an explicit +format/model compatibility limit, not a restriction on `Paint`; a future +arbitrary-precision source-number boundary may remove it without changing the +paint model or XML vocabulary. + +#### Compatibility quarantine: unconstrained gradient model state + +The current paint data types can hold gradient values that Draft 0 deliberately +rejects: fewer than two stops, out-of-range or descending stop offsets, and +coincident linear endpoints. Those states have no valid Grida XML spelling. A +canonical writer must report the failed invariant and must not invent stops, +sort or clamp offsets, or perturb an endpoint. Complete model-to-XML round-trip +requires these invariants to be established in the common paint model first. + +#### Compatibility quarantine: gradient editing model + +The current paint contract and the +[Gradient Session](../canvas/paint-session/gradient.md) RFD disagree in two +places that are not safe to average: + +- Grida XML and the paint contract store a linear gradient's endpoints + explicitly, while the session RFD says all non-trivial orientation belongs + only in the transform. +- Grida XML and the paint contract define diamond by Manhattan/L1 distance, + while the session RFD describes an L-infinity distance field. + +For Draft 0 files, the definitions in this RFD are normative. A processor must +not silently rewrite explicit endpoints into a transform or substitute the +other diamond metric. The session model remains quarantined at this seam until +the two documents are reconciled together; changing either serialized meaning +requires an explicit compatibility decision. + +### Image paints and resources + +`image` directly inside `fill` or `stroke` is an image paint. It requires a +non-empty `src`, which is an authored resource identifier, and accepts +`fit="cover|contain|fill|none"`. The default is `cover`; `contain` preserves +aspect ratio within the box, `cover` preserves aspect ratio while covering and +cropping, `fill` stretches to both box axes, and `none` retains intrinsic image +size. Draft 0 centers the placed image and defines no image-paint syntax for +orientation, filters, free-transform placement, or tiling. + +`src` materializes as a logical resource ID (RID), preserving the authored +string. Draft 0 does not author content-hash resource references through +`image`; adding another resource-identity form requires explicit syntax rather +than guessing from the string's shape. + +Syntactic parsing preserves `src` and performs no file, network, or decode +operation. Resource resolution belongs to the explicit resolution environment: + +- a relative identifier such as `./noise.png` resolves against the document's + base location; +- an absolute logical resource identifier is looked up as authored; +- the authored string is not rewritten merely because a host found its bytes; +- a missing, unreadable, or undecodable image is a resource-resolution failure, + not malformed XML. + +A strict materializer reports the failing authored identifier and, when one +exists, its resolved location. It must not silently discard the image paint and +claim a complete resolved result. An interactive host may display a diagnostic +placeholder while keeping the unresolved state explicit. + +The meaning of `image` is contextual. Inside `fill` or `stroke` it is always a +paint. Outside those channels it occupies the scene-element namespace, where a +later version may define an image node. Draft 0 defines no scene-image node and +therefore rejects an out-of-context `image` rather than confusing it with a +paint. + +### Inline definitions and future reuse + +Typed paint elements are canonical inside an expanded channel, with the +singleton ordinary-solid `fill` attribute as the compact exception. Draft 0 +defines no reusable paint definition, reference, identifier, or lookup scope. +Paint definitions and references are future work and are not required to +express any current fill or stroke. + +### Considered syntax alternatives + +1. **Direct typed paint children under `fill` and `stroke` — accepted.** Paint + type and painter order remain visible in ordinary XML structure. +2. **`` — accepted.** The four current variants share stops, + transform, visibility, opacity, and blend behavior. The bounded discriminator + localizes a geometry switch while keeping `gradient` a semantic paint type; + incompatible kind-specific attributes still require removal. +3. **Four gradient element names — rejected for Draft 0.** They duplicate the + shared family grammar and make a geometry switch replace the wrapper. A + structurally different future gradient, such as a mesh, should receive its + own semantic element instead of being forced into this family. +4. **A generic paint or repeated fill-layer envelope — deferred unless layer + metadata becomes independent from its source.** Common visibility, opacity, + and blend properties currently belong to the typed paint itself, so + `` would add a level without representing a distinct + model concept. +5. **Plural ``/`` or `` channels — rejected.** + Singular `fill` names the one fill channel, while repeatable `stroke` names + one geometry with its paints. The hierarchy already supplies the target; + repeating it in a generic container costs clarity and tokens. +6. **CSS-like functional attributes — rejected as canonical.** Encoding ramps + and image placement inside attribute strings creates a second nested + grammar, produces poorer diagnostics, and is less reliably authorable by + language models. + +### Position bindings + +`x` binds the horizontal axis and `y` binds the vertical axis. For a boxed or +measured node with parent extent `E` and resolved child extent `s`, the value +forms are: + +| Form | Meaning | Resolved start | +| ---------------- | ----------------------------------- | ------------------------- | +| `n` or `start n` | Offset from the parent's start edge | `n` | +| `end n` | Offset from the parent's end edge | `E - n - s` | +| `center` | Center in the parent | `(E - s) / 2` | +| `center n` | Center, then apply an offset | `(E - s) / 2 + n` | +| `span a b` | Bind both edges | start `a`, extent `E-a-b` | + +The bare number is the canonical spelling for a start binding. `span` owns +the size on its axis, so `width`, `min-width`, and `max-width` must be omitted +when `x` is a span; the corresponding height attributes must be omitted when +`y` is a span. End, center, and span bindings require a resolvable parent +extent; using one against an unresolved auto or derived parent axis is a +resolution error, not an invitation to guess. + +If a resolved span would have negative extent because `a + b > E`, its extent +is clamped to zero at start `a` and the resolver reports that clamp. This is a +valid resolved boundary case, not a negative box and not a source rewrite. + +For `group` and `lens`, bindings place the node's local origin rather than the +top-left of its derived union. Their start, end, and center formulas are `n`, +`E - n`, and `E / 2 + n`, respectively; child-union size `s` is not +subtracted. `span` is invalid on a derived-box node because there is no +authored axis extent for it to own. + +An in-flow child of a flex container is positioned by flex and must omit +`x`/`y`. `flow="absolute"` removes it from flex participation and restores +its bindings against the container's local box. `flow` is valid only on a +child of a flex container; `grow` and `align` are valid only while that child +remains in flow. + +### Size constraints and aspect ratio + +Minimum and maximum constraints apply to `container`, `rect`, `ellipse`, +`line`, and `text`. They are invalid on `group` and `lens`, whose boxes are +derived. Resolution first obtains an axis from its binding, declared intent, +measurement, or layout, then clamps it to the applicable maximum and minimum. +The minimum wins when a minimum exceeds its paired maximum. If constraining a +text width changes its wrapping width, text is measured again before its final +height is chosen. Because a line's height is definitionally zero, +`min-height` and `max-height` are invalid on ``. + +In Draft 0, `aspect-ratio="w:h"` applies only to `rect` and `ellipse`. It +contains two positive numbers and supplies exactly one otherwise +under-specified axis from the other. It never distorts two extents that are +already determined and never invents a scale transform. Using an aspect ratio +where neither axis can provide the other, or redundantly declaring it when +both axes are already supplied, is an error. + +## Box semantics by element + +### Container + +A container is a rectangular composition box. Each of `width` and `height` +accepts either a non-negative number or `auto`. On an auto axis, the local +start edge remains fixed and the extent grows through the greatest positive +child far edge plus padding; auto sizing never shifts the local origin. +Consequently, a child with a sufficiently negative start offset may overflow +the start side rather than moving the container. A fixed value remains +authored intent even when a parent layout gives the node a different resolved +extent. Both dimensions default to `auto`. + +Without `layout="flex"`, children are free-positioned by their bindings. On +an auto axis, free children must be start-bound; an end, center, or span +binding would require the extent it is trying to determine and is therefore a +resolution error. Their bindings resolve against the padding-inset content +box, then add the container's left and top padding; therefore `x="0"` and +`y="0"` start at the padded content origin. + +A container may carry a fill and repeated strokes and may clip descendant +paint with `clips="true"`. Its fill is painted first. Its children are then +painted under the descendant clip, if enabled; after that clip is removed, the +container's strokes are painted last. The default container fill is empty and +the default stroke list is empty. + +### Primitive elements + +Draft 0 recognizes the direct primitive elements `rect`, `ellipse`, and +`line`. They do not accept a `kind` attribute. + +- `rect` and `ellipse` require each axis to be supplied by either a fixed + numeric size or a `span` binding. `aspect-ratio` may supply exactly one + otherwise-unsupplied axis. +- `line` requires either fixed numeric `width` or an x-axis `span`. Its + authored and resolved height is zero, so `height` and a y-axis `span` are + invalid. It is the horizontal segment from local `(0, 0)` to `(width, 0)` + and can be oriented with `rotation`. It has no fill; each authored `stroke` + independently strokes that segment. With no strokes it contributes no + pixels. +- An unresolved `auto` axis is invalid on a primitive because these elements + have no content-based natural size. `aspect-ratio` is invalid on `line`. + +An ellipse whose width equals its height is a circle. A `circle` kind or +element is invalid: it would duplicate rather than extend the primitive +model. + +The primitive and its children share the primitive's local box, but are +otherwise independent. Changing a child does not rewrite the primitive, and +resizing the primitive does not rewrite a child's authored bindings. + +### Text + +The character data directly inside `text` is its content. `text` cannot +contain render elements. It may begin with the structural `fill` property and +zero or more `stroke` properties, which are neither character content nor +render children. Formatting whitespace before and between those leading +properties is ignored. After the final property closes—or immediately after +the opening tag when there is no property—every character is text content, +including whitespace. A canonical writer therefore places the first intended +character immediately after the final property's closing tag, rather than +pretty-printing an unintended newline. Entity references are decoded as XML +character data. + +Text accepts `size`, a positive font size in logical pixels. Draft 0 leaves +font selection to the declared resolution environment; a resolved artifact +records the result of shaping with that environment rather than writing it +back into the source. + +Text is a box, not a point label: + +| Width intent | Height intent | Behavior | +| ------------ | ------------- | -------------------------------------------------------- | +| `auto` | `auto` | Measure natural lines; do not introduce soft wrapping | +| fixed | `auto` | Wrap to the fixed width and measure the resulting height | +| fixed | fixed | Wrap to the fixed width inside the fixed-height text box | +| `auto` | fixed | Measure natural width inside the fixed-height text box | + +Both attributes default to `auto`. A span-resolved width is a fixed wrapping +constraint. Text wraps at the legal break opportunities provided by the +declared text-resolution environment. Explicit line breaks are preserved in +every mode. Draft 0 does not define truncation or ellipsis; fixed height +constrains the box but does not silently alter the text content. Text paint may +overflow that box unless an ancestor container clips it. + +### Group + +A group has no declared width or height and no visual content of its own. Its +box is the union of its children's untransformed local layout boxes. Child +rotation, flips, and lens operations affect visual bounds but do not enlarge +that sizing-tier union. The group's `x` and `y` place the group's local origin, +not the top-left of that union; consequently, a child may extend into negative +local coordinates without moving its siblings. + +### Lens + +A lens has the same derived-box and origin rules as a group. Its `ops` +attribute is an ordered, space-separated list drawn from: + +```text +translate(x,y) +rotate(degrees) +scale(s) +scale(x,y) +skew-x(degrees) +skew-y(degrees) +skew(x-degrees,y-degrees) +matrix(a,b,c,d,e,f) +``` + +Operations compose in source order and affect descendant painting and +resolved visual bounds. They do not change flex contribution, hug sizing, or +sibling placement. A lens is therefore explicit picture-transform intent, +not a place to store a matrix produced by layout. + +## Flex layout + +Only `container` may own flex layout in Draft 0. Its `layout` value is `none` +or `flex`, defaulting to `none`; a canonical writer omits the default. Nesting +children under a primitive element, group, or lens remains free placement; +those elements do not acquire layout behavior merely because they can contain +children. + +`layout="flex"` enables a one- or multi-line, CSS-inspired flow with this +surface: + +| Attribute | Values | Default | +| ----------- | ------------------------------------------------------------------------- | ------- | +| `direction` | `row`, `column` | `row` | +| `wrap` | `true`, `false` | `false` | +| `gap` | one non-negative number, or main/cross numbers | `0` | +| `padding` | one non-negative number, or top/right/bottom/left numbers | `0` | +| `main` | `start`, `center`, `end`, `space-between`, `space-around`, `space-evenly` | `start` | +| `cross` | `start`, `center`, `end`, `stretch` | `start` | + +`padding` applies to both free-positioned and flex containers. The other +attributes in this table are valid only when `layout="flex"` is present. + +In-flow children participate in source order. Their resolved size is their +flex basis. Positive remaining main-axis space is divided in proportion to +`grow`; Draft 0 does not shrink children. `main` distributes remaining space, +while `cross` and a child's `align` place or stretch it on the cross axis. +Container-level `cross="stretch"` stretches only children whose authored +cross size is `auto`; a fixed cross size remains fixed. Child-level +`align="stretch"` is an explicit fill override and stretches even a fixed +cross size. Wrapping forms additional lines when enabled. An absolute child +does not consume a flex slot, gap, or growth share. + +Layout owns resolved placement, never source geometry. Re-resolving at a new +container extent may move or resize children without writing those results +into the document. + +## Canonical example + +This slide exercises flex layout, constrained text, an ellipse used as a +circle, and text nested directly under a primitive element: + +```xml + + + + + + + + + + + + + 01 + + Design responsive 2D content that wraps predictably. + + +``` + +Formatting and attribute order are not semantic. The element names, +`version`, `width`/`height` spellings, value grammars, and child order are. + +## Source intent and resolved output + +The `.grida.xml` document is the authored-intent tier. It stores the facts an +author chose: node kinds, hierarchy, bindings, size intent, text, paint, +layout relationships, and explicit lens operations. + +A resolver combines that source with an explicit environment—viewport, +fonts, and resources—to produce a separate resolved scene. Resolved boxes, +world transforms, measured glyph runs, visual bounds, materialized vector +points, and paint commands belong to that derived output. They are not fields +of Grida XML merely because a renderer can compute them. + +This separation has three consequences: + +1. Reading or rendering a file must not rewrite responsive intent into the + values observed in one environment. +2. A canonical writer serializes source intent, not a resolved snapshot. +3. Tools may expose a materialized view for inspection or editing, but any + source edit must deliberately retarget the appropriate intent rather than + copying every resolved value back into the document. + +## Strict parsing and versioning + +Draft 0 uses strict current-version parsing. A reader either produces the +declared Draft 0 scene or reports a typed failure. It must reject: + +- malformed XML; +- CDATA sections, document types, or processing instructions; +- an envelope other than `grida`; +- a missing version or any version other than exactly `0`; +- no render root, more than one render root, or a root other than `container`; +- unknown elements, attributes, enum values, or lens operations; +- the reserved `` element or `kind` on a render node; +- attributes used on a node kind where they do not apply; +- a typed paint outside `fill` or `stroke`, a `stop` outside `gradient`, a + nested `fill` or `stroke`, a duplicate `fill`, or a structural paint child + placed after text or render children; +- simultaneous use of the `fill` attribute and `fill` element on one node; +- unknown paint tags or paint attributes, a generic `paint` envelope, an + invalid paint blend mode, or a non-boolean paint visibility value; +- a gradient with a missing or unknown `kind`, an attribute not valid for its + kind, fewer than two stops, a stop outside `[0,1]`, descending stop offsets, + coincident linear endpoints, or a malformed gradient affine; +- an empty image `src` or unsupported image fit; +- a fill on `line`, any fill or stroke on `group` or `lens`, an empty stroke + whose geometry equals its target defaults, a stroke attribute invalid for its + target, invalid stroke geometry, a non-centered line stroke, or a malformed + dash array; +- non-finite numbers, negative dimensions, or malformed colors; +- non-positive font sizes or aspect-ratio terms, negative growth, gap, or + padding values, or opacity outside `[0, 1]`; +- render elements inside `text`, or non-whitespace character data inside a + non-text node; +- contradictory geometry such as a fixed width alongside an x-axis span. + +Unsupported future versions must fail as unsupported versions. A Draft 0 +reader must not attempt best-effort interpretation, preserve unknown semantic +fields as inert bags, or drop unknown nodes. Such tolerance would make a file +appear valid while changing its design. + +Diagnostics should identify the offending element and attribute or child, +state the expected grammar, and distinguish syntax, semantic validation, and +resource resolution. For example, a misplaced image should say that image +paints belong directly under `fill` or `stroke`; a gradient without `kind` +should list the four accepted values; a descending stop should identify the +two offsets; an unsupported second stroke should identify the target model's +single-geometry limit; and a missing image file should report its authored +`src` rather than presenting the failure as malformed XML. + +## Conformance + +The normative terms in this section define Draft 0 conformance. + +### Reader + +A conforming reader: + +1. **MUST** enforce the document envelope, version, single-container root, + vocabulary, value grammar, attribute applicability, and child rules in + this RFD. +2. **MUST** preserve node order, text content, and authored intent exactly at + the semantic level, including stroke order, paint order, inactive paints, + equal-offset stops, empty fill stacks where omission changes semantics, and + empty strokes with non-default geometry. +3. **MUST NOT** accept an unknown construct by ignoring or coercing it. +4. **MUST** distinguish a parse failure from a resolution failure. A + well-formed intent document can still fail to resolve in an insufficient + environment. + +### Writer + +A conforming writer: + +1. **MUST** emit `grida version="0"` with exactly one `container` child. +2. **MUST** emit the canonical Draft 0 node and attribute names, including + `container`, `rect`, `ellipse`, `line`, `width`, and `height` rather than + historical aliases. +3. **MUST** apply the canonical fill partition: omit the default, use the + `fill` attribute for one ordinary solid, and use the `fill` element for all + other non-empty stacks or required explicit emptiness. +4. **MUST** normalize a default empty stroke pair to omission, preserve an + empty stroke with non-default geometry, and emit every remaining stroke in + bottom-to-top order with its typed paints in bottom-to-top order. +5. **MUST** emit a gradient's required `kind`; it **MUST NOT** emit the four + kind-specific gradient element names, ``, or a generic `paint` + envelope. +6. **MUST** encode XML text and attribute values correctly and emit only + finite, valid values. +7. **MUST NOT** serialize resolved-only values as if they were source intent. +8. **SHOULD** produce stable, human-readable indentation and stable attribute + ordering, although neither is semantic in Draft 0. + +### Resolver and renderer + +A conforming resolver and renderer: + +1. **MUST** resolve child coordinates in their parent's local space, + including children of primitive elements. +2. **MUST** apply free bindings, text measurement, auto sizing, and flex + ownership according to this RFD, including size constraints and aspect + ratios. +3. **MUST** preserve source order in painting, with the node's fill first, + children in document order, and repeated parent strokes from first to last. +4. **MUST** derive each stroke independently from the node's original outline + and composite that stroke's visible paints in bottom-to-top order. It must + not merge distinct stroke geometries into one paint stack. +5. **MUST** composite every visible paint in its declared bottom-to-top order, + applying each paint's opacity and blend mode to that paint only. +6. **MUST** evaluate gradient geometry in the declared unit paint space and + map it to the resolved paint box without rewriting source coordinates. +7. **MUST** resolve image identifiers through the declared resource + environment and surface missing or undecodable resources as resolution + failures rather than silently dropping their paint layers. +8. **MUST** include every effective stroke in resolved visual bounds without + changing the node's layout box. +9. **MUST** apply rotation and native flips as visual-only transforms and + apply clipping and opacity to the declared subtree. +10. **MUST NOT** mutate the source document as a side effect of resolving or + rendering it. +11. **MUST** make the resolution environment explicit enough that a resolved + result can be attributed to a viewport, font set, and resource set. + +A processor that intentionally supports only part of Draft 0 may describe +itself as a Draft 0 subset, but it cannot claim full Draft 0 conformance and +must not silently discard the unsupported remainder. + +## Deferred requirements + +Draft 0 does not define paths or vector networks, per-side or variable-width +strokes, dash offset, endpoint markers, effects, scene image nodes, resource +declaration or packaging syntax, advanced image placement, image filters, rich +color spaces, rich text runs, grid layout, animation, or durable node identity. +Their eventual addition must preserve the one-tree, local-space, intent-only +model established here. + +Reusable paint definitions and references are also deferred. Inline paint +values remain sufficient and canonical; a later reference system must define +identity, scope, failure behavior, and override semantics without creating a +second canonical spelling for the same inline value. + +Reusable author-defined widgets/components are a required future capability. +A later version must define both component definition and use/instantiation, +including identity, scoping, overrides, and resolution behavior. Draft 0 +defines neither a `component` nor a `use` element; a current-version reader +must reject both rather than inventing premature semantics. + +The reserved `shape` name may support custom-shape definitions in a later +version, but Draft 0 does not decide whether it names a definition or an +instance, where definitions live, how geometry is expressed, or how reuse +works. Adding any such behavior is a versioned language change. + +## Open questions before Draft 1 + +- Which advanced stroke geometries and effects are small enough to become + stable source vocabulary without weakening the typed-paint model? +- How are fonts and external image resources declared or packaged so that + resolution remains portable while relative identifiers remain authorable? +- What durable identity contract supports agent edits and component + instances without turning transient runtime handles into file facts? +- Which resolved inspection form should accompany the intent source without + being mistaken for it? +- Does the compound `.grida.xml` suffix remain the best long-term authored + file identity? diff --git a/docs/wg/format/index.md b/docs/wg/format/index.md index 3445321295..ee388a6111 100644 --- a/docs/wg/format/index.md +++ b/docs/wg/format/index.md @@ -13,12 +13,14 @@ Tracking docs for the Grida IR schema and how external formats map into it. ## Pages -| Page | Description | -| ------------------- | -------------------------------------------------- | -| [Grida IR](./grida) | Canonical IR reference — node types, paint, layout | -| [CSS](./css) | CSS → Grida IR property mapping and TODO tracker | -| [HTML](./html) | HTML element → Grida IR node mapping | -| [SVG](./svg) | SVG → usvg → Grida IR mapping and TODO tracker | +| Page | Description | +| ---------------------------------------------- | ---------------------------------------------------------- | +| [Grida IR](./grida) | Canonical IR reference — node types, paint, layout | +| [Grida XML](./grida-xml) | Open RFD for the authored, inspectable `.grida.xml` source | +| [Grida XML properties](./grida-xml-properties) | XML property names, applicability, and design placeholders | +| [CSS](./css) | CSS → Grida IR property mapping and TODO tracker | +| [HTML](./html) | HTML element → Grida IR node mapping | +| [SVG](./svg) | SVG → usvg → Grida IR mapping and TODO tracker | ## How to use these docs From 6e3e7c4a8027220d0b6d45a77e36ea2064d0b38a Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 11 Jul 2026 08:19:36 +0900 Subject: [PATCH 10/33] feat(model-v2): ingest and render Grida XML --- model-v2/README.md | 16 +- model-v2/a/LIMITS.md | 6 +- model-v2/a/MODEL.md | 13 +- model-v2/a/e3-text-ir/grammar.md | 22 +- model-v2/a/lab/README.md | 58 +- model-v2/a/lab/src/bin/e1.rs | 5 +- model-v2/a/lab/src/grida_xml.rs | 210 ++ model-v2/a/lab/src/lib.rs | 1 + model-v2/a/lab/src/measure.rs | 191 +- model-v2/a/lab/src/model.rs | 693 +++++- model-v2/a/lab/src/pick.rs | 31 +- model-v2/a/lab/src/resolve.rs | 249 +- model-v2/a/lab/src/svgout.rs | 190 +- model-v2/a/lab/src/textir.rs | 2100 ++++++++++++++++- model-v2/a/lab/tests/arena_pick.rs | 34 + model-v2/a/lab/tests/composition.rs | 142 ++ model-v2/a/lab/tests/draft0_resolution.rs | 374 +++ model-v2/a/lab/tests/grida_xml_paints.rs | 551 +++++ model-v2/a/lab/tests/grida_xml_properties.rs | 363 +++ model-v2/a/lab/tests/grida_xml_strokes.rs | 340 +++ model-v2/a/lab/tests/grida_xml_suite.rs | 727 ++++++ model-v2/a/lab/tests/layout.rs | 83 + model-v2/a/lab/tests/mm_laws.rs | 3 +- model-v2/a/lab/tests/svgout_paints.rs | 79 + model-v2/a/spike-canvas/src/paint.rs | 6 +- model-v2/a/spike-canvas/src/scene.rs | 62 +- model-v2/engine/DATA-MODEL.md | 54 +- .../rig/fixtures/nested-rects.grida.xml | 7 + model-v2/engine/src/bin/gate.rs | 2 +- model-v2/engine/src/bin/grida_xml_render.rs | 305 +++ model-v2/engine/src/bin/probe.rs | 8 +- model-v2/engine/src/bin/probe_gpu.rs | 2 +- model-v2/engine/src/drawlist.rs | 337 ++- model-v2/engine/src/paint.rs | 722 +++++- model-v2/engine/tests/cache.rs | 8 +- model-v2/engine/tests/drawlist.rs | 215 +- model-v2/engine/tests/grida_xml.rs | 106 + model-v2/engine/tests/paints.rs | 256 ++ model-v2/engine/tests/strokes.rs | 351 +++ model-v2/engine/tests/text.rs | 152 ++ model-v2/models/a.md | 14 +- 41 files changed, 8577 insertions(+), 511 deletions(-) create mode 100644 model-v2/a/lab/src/grida_xml.rs create mode 100644 model-v2/a/lab/tests/composition.rs create mode 100644 model-v2/a/lab/tests/draft0_resolution.rs create mode 100644 model-v2/a/lab/tests/grida_xml_paints.rs create mode 100644 model-v2/a/lab/tests/grida_xml_properties.rs create mode 100644 model-v2/a/lab/tests/grida_xml_strokes.rs create mode 100644 model-v2/a/lab/tests/grida_xml_suite.rs create mode 100644 model-v2/a/lab/tests/svgout_paints.rs create mode 100644 model-v2/engine/rig/fixtures/nested-rects.grida.xml create mode 100644 model-v2/engine/src/bin/grida_xml_render.rs create mode 100644 model-v2/engine/tests/grida_xml.rs create mode 100644 model-v2/engine/tests/paints.rs create mode 100644 model-v2/engine/tests/strokes.rs create mode 100644 model-v2/engine/tests/text.rs diff --git a/model-v2/README.md b/model-v2/README.md index d8a20ab9c2..fb8f27b0d7 100644 --- a/model-v2/README.md +++ b/model-v2/README.md @@ -16,7 +16,7 @@ after the model lands; they are out of scope here. ## Run ```sh -# the lab (the model's single source of truth) — 114 tests +# the lab (the model's single source of truth) — full conformance suite cd model-v2/a/lab && cargo test # the interactive spike (native skia window on the model) @@ -40,12 +40,12 @@ them — problems first, then candidates, then spec. ## Phase discipline -| phase | artifact | status | -| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1. Problems & harnesses | `problems.md`, `harnesses.md`, `study.md` | stable draft | -| 2. Candidate models | `paradigm.md`, `axes.md`, `models/*`, `finale.md`, `triage.md` | **DECIDED — `anchor`** (+5 triage amendments) | -| 3. Spec | normative doc + `grida.fbs` draft | **experiments RUN, model PROVEN** — E1–E10 complete with verdicts; **DEC-0 decided: VISUAL-ONLY rotation (the CSS framing), CSS-pure sizing** ([`a/dec0-visual-only.md`](./a/dec0-visual-only.md)); flips built (E-A14, cross-zero resize); 114-test lab; native interactive spike ([`a/spike-canvas/`](./a/spike-canvas/)); open calls parked in [`a/DECISIONS.md`](./a/DECISIONS.md). Remaining: fold deltas into a normative rewrite of `models/a.md` + WG graduation | -| 4. Runtime | `crates/grida` implementation | **day-1 engine skeleton BUILT** — [`engine/`](./engine) (`anchor-engine`): the `resolve → drawlist → paint` pipeline + query/journal/replay/damage sockets, spike re-hosted onto it, gate green (shots byte-identical, replay deterministic, budgets baselined); contracts in [`a/ENGINE.md`](./a/ENGINE.md). Migration into `crates/grida` follows; the spike's [`TEXTBOOK.md`](./a/spike-canvas/TEXTBOOK.md) is the reference | +| phase | artifact | status | +| ----------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Problems & harnesses | `problems.md`, `harnesses.md`, `study.md` | stable draft | +| 2. Candidate models | `paradigm.md`, `axes.md`, `models/*`, `finale.md`, `triage.md` | **DECIDED — `anchor`** (+5 triage amendments) | +| 3. Spec | normative doc + `grida.fbs` draft | **experiments RUN, model PROVEN** — E1–E10 complete with verdicts; **DEC-0 decided: VISUAL-ONLY rotation (the CSS framing), CSS-pure sizing** ([`a/dec0-visual-only.md`](./a/dec0-visual-only.md)); flips built (E-A14, cross-zero resize); conformance lab; native interactive spike ([`a/spike-canvas/`](./a/spike-canvas/)); open calls parked in [`a/DECISIONS.md`](./a/DECISIONS.md). Remaining: fold deltas into a normative rewrite of `models/a.md` + WG graduation | +| 4. Runtime | `crates/grida` implementation | **day-1 engine skeleton BUILT** — [`engine/`](./engine) (`anchor-engine`): the `resolve → drawlist → paint` pipeline + query/journal/replay/damage sockets, spike re-hosted onto it, gate green (shots byte-identical, replay deterministic, budgets baselined); contracts in [`a/ENGINE.md`](./a/ENGINE.md). Migration into `crates/grida` follows; the spike's [`TEXTBOOK.md`](./a/spike-canvas/TEXTBOOK.md) is the reference | Ground rules: @@ -99,7 +99,7 @@ Ground rules: ([`a/DECISIONS.md`](./a/DECISIONS.md)), the DEC-0 normative rules, the ship-readiness census ([`a/LIMITS.md`](./a/LIMITS.md)), the peer-compat matrix, the phase-4 engine layer programs with day-1 contracts - ([`a/ENGINE.md`](./a/ENGINE.md)), the 114-test Rust lab + ([`a/ENGINE.md`](./a/ENGINE.md)), the Rust conformance lab ([`a/lab/`](./a/lab/)), and the native interactive spike ([`a/spike-canvas/`](./a/spike-canvas/)). - [`models/`](./models/) — concrete candidate models, one file each, diff --git a/model-v2/a/LIMITS.md b/model-v2/a/LIMITS.md index 1feccf5889..035693445a 100644 --- a/model-v2/a/LIMITS.md +++ b/model-v2/a/LIMITS.md @@ -20,7 +20,7 @@ limitation (not built / can't hold as stated). | kind | box | realization | rotation | flip | in flow | as parent | quarantine | notes | | ------ | ------------- | --------------------------------- | ----------------- | -------------------------------- | -------------- | ----------- | ---------- | ------------------------------- | | frame | ✅ | ✅ children/flex | ✅ rigid (🎬) | 📜 (boxed path, F-1 class) | ✅ | ✅ | — | | -| shape | ✅ | ✅ parametric | ✅ | ✅ (F-1/2/4 + 🎬) | ✅ | n/a | — | rect/ellipse/line only in lab | +| shape | ✅ | ✅ parametric + free children | ✅ | ✅ (F-1/2/4 + 🎬) | ✅ | ✅ | — | rect/ellipse/line only in lab | | text | ✅ | ✅ flowed (lab metric) | ✅ (🎬) | 📜 mirrors uniformly (DEC-9 sub) | ✅ | n/a | — | real shaping = the big ⛔ below | | vector | ⛔ not in lab | 📜 mapped (E-A9, write side open) | 📜 | 📜 flip bits, points untouched | 📜 | n/a | — | B2 lock set before phase 3 | | image | ⛔ not in lab | 📜 fitted (engine ships BoxFit) | 📜 | 📜 paint mirrors (DEC-9) | 📜 | n/a | — | low risk: paint seam exists | @@ -100,7 +100,7 @@ math that doesn't exist) · auto-width basis in definite flex (wraps-at-available vs Chromium's max-content — same deviation family as the Taffy guards) · grow on the measured axis (spec example (c) is currently a no-op and its annotation is wrong — fix the example) · -`max_lines: 0` semantics + max_lines→natural-size rule · font_size +`max_lines: 0` semantics + max*lines→natural-size rule · font_size domain (reject ≤0) · whitespace-only non-monotonicity · giant-word ink escapes the world AABB (hit/cull miss — declare ink-bounds ⊃ box). Transform: ~~flip×rotation composition order~~ (CLOSED E-A14: `T·R·F`, @@ -108,7 +108,7 @@ innermost, tested) · flip on text (Figma mirrors; DEC-9 sub-decision, uniform recommended) · flip on lens (outside-ops vs inside-ops) · negative-determinant lens Matrix = second home for mirror (extend the E-A8 lint) · winding read source -(rotation reads the stored scalar — it _is_ intent; resolves the law-7 / +(rotation reads the stored scalar — it \_is* intent; resolves the law-7 / R-E2 / H5 three-way) · "spec-reserved" 3D ops need a declared v1 runtime posture (render-skip + preserve ≠ silent drop) · singular resolved matrices (CSS: not displayed — adopt?) · resolved-tier diff --git a/model-v2/a/MODEL.md b/model-v2/a/MODEL.md index 263474e8f8..b08d0cecff 100644 --- a/model-v2/a/MODEL.md +++ b/model-v2/a/MODEL.md @@ -27,7 +27,7 @@ kind answers two **independent** questions: | kind | box | content realization | | -------- | --------------------------------------------------------------- | ------------------------------------------------------------------ | | `frame` | declared (Auto = hug) | children — bindings or flex | -| `shape` | declared | **parametric**: descriptor = f(box); no points exist | +| `shape` | declared | **parametric**: descriptor = f(box), then children in local space | | `vector` | declared (Auto = reference bounds) | **mapped**: points in a reference rect, scaled `box/ref` at render | | `text` | measured under intent constraints | **flowed**: re-wraps at the imposed box | | `image` | declared | **fitted**: paint re-covers per fit mode | @@ -36,6 +36,11 @@ kind answers two **independent** questions: | `bool` | derived (**op-result** bounds — D-5; subtract-to-empty ⇒ empty) | operands in origin space | | `lens` | derived | children ∘ ops — **paint only** | +`shape` here names the internal parametric payload family, not an authored +element. Draft 0 Grida XML lowers ``, ``, and `` into +this family; the frozen E3 TextIr dialect alone spells it +``. + Why this shape is right, per the evidence: - _Parametric_ is Figma's own answer for primitives (star = count + @@ -51,6 +56,12 @@ Why this shape is right, per the evidence: - _Flowed_ and _fitted_ are the standard measure-function and BoxFit seams, already shipping. +Child ownership is orthogonal to box source. A shape may own ordered, +free-positioned children in its declared local box: the primitive paints first, +then its descendants. Those descendants never measure or resize the shape and +never change its contribution to parent layout. Text therefore composes under a +shape as an ordinary child; there is no combined shape-with-text kind. + ## The laws 1. **Intent only in the file.** Derivable ⇒ not encodable. The resolved diff --git a/model-v2/a/e3-text-ir/grammar.md b/model-v2/a/e3-text-ir/grammar.md index 6ad2aac8d2..6e2721a34b 100644 --- a/model-v2/a/e3-text-ir/grammar.md +++ b/model-v2/a/e3-text-ir/grammar.md @@ -1,5 +1,10 @@ # The anchor text IR — pocket grammar & resolution semantics +> Historical note: this freezes the E3 TextIr element and attribute vocabulary. +> `` and `` are not Draft 0 `.grida.xml` syntax. Text +> measurement below includes the shared resolver's whitespace and explicit-line +> correction. + An anchor document is XML. The root element is a ``. Every element is a node; nesting is the scene tree. This document is **complete**: an agent holding only this file can compute the resolved geometry of any @@ -45,14 +50,19 @@ changes where the node paints (its world AABB). ### 1. Text measurement (exact, deterministic) -- character advance = `0.6 × size`; every character counts (spaces too) +- character advance = `0.6 × size`; every non-newline character counts + (spaces too) - line height = `1.2 × size` -- unconstrained (auto width): one line — `w = chars × 0.6 × size`, - `h = 1.2 × size` +- authored `\n` starts a new line, including a final empty line after a + trailing newline +- unconstrained (auto width): no soft wrapping — `w` is the widest explicit + line's character count times `0.6 × size`; `h = lines × 1.2 × size` - constrained to width `W` (fixed or stretched): greedy word wrap: - max chars per line = `floor(W / (0.6 × size))`; words (split on single - spaces) are packed left to right; a joining space costs 1 char; a word - never breaks. `w = widest line's chars × 0.6 × size`, + max chars per line = `floor(W / (0.6 × size))`; each explicit line wraps + independently at ASCII-space runs; authored leading, repeated, and trailing + spaces retain their advances unless a separator run would become trailing + ink at a soft wrap, in which case that run is dropped. A word never breaks. + `w = widest line's chars × 0.6 × size`, `h = lines × 1.2 × size`. (Reported w may be smaller than W only when width is `auto`; a fixed or stretched width reports that width.) diff --git a/model-v2/a/lab/README.md b/model-v2/a/lab/README.md index b2deb9fd16..0d3af85cac 100644 --- a/model-v2/a/lab/README.md +++ b/model-v2/a/lab/README.md @@ -7,7 +7,7 @@ The standalone proving crate for the `anchor` model `crates/` happens at phase 4 only. ```sh -cargo test # 56 conformance-derived tests (MM/G/R/L/T/D/M suites) +cargo test # conformance + format-contract suites cargo run --bin e1 # E1 sweep → ../e1-rotation-in-flow/ artifacts cargo run --bin e3 -- truth # E3 ground truth lines cargo run --bin e3 -- score f # score a prediction file @@ -25,10 +25,66 @@ Map: intrinsic passes) - `src/ops.rs` — gesture ops with typed errors + write-count doctrine - `src/textir.rs` — the agent text IR parser + canonical printer (E3) +- `src/grida_xml.rs` — strict Draft 0 `.grida.xml` parser/writer boundary - `src/svgout.rs` — SVG snapshots of resolved documents - `src/math.rs` / `src/measure.rs` — affine + deterministic text metric - `tests/` — the conformance-derived suites; `tests/common/mod.rs` helpers +## Draft 0 `.grida.xml` + +`grida_xml::parse(&str)` is a pure source boundary. It requires +`` with exactly one authored `` render root. The +envelope is not a node: parsing preserves the model's implicit +viewport-spanning document root and attaches the authored root below it. +`grida_xml::print(&Document)` is fallible and self-validating, so it cannot +silently omit root state, serialize a forest, or emit source the strict parser +rejects. + +The reader and writer implement the current +[Grida XML Draft 0 RFD](https://grida.co/docs/wg/format/grida-xml) vocabulary. +Parsing is strict: retired experiment spellings, unknown properties, invalid +target/property combinations, and model states that cannot round-trip fail +with contextual errors instead of being ignored or repaired. + +The proof subset has one node vocabulary: ``, direct ``, +``, and `` primitives, `width`/`height`, +`min-width`/`max-width`/`min-height`/`max-height`, and `aspect-ratio`. +`` is reserved and `kind` is not a render-node attribute. Historical +``, ``, `w`/`h`, `min-w`/`max-w`/`min-h`/`max-h`, and +`aspect` remain available only through the unwrapped `textir` surface; direct +primitive tags are not accepted there. +The proof parser also requires non-negative `grow`, `gap`, and `padding`; +historical `textir` retains its experiment-era numeric behavior. + +Fill-bearing nodes carry one ordered `Paints` value matching the production +paint contract. `fill="#fff"` is the canonical ordinary singleton-solid form; +singular `` owns every richer stack and explicit emptiness. Typed +``, ``, and `` +children are bottom-to-top painter order. Gradient stops remain structured; +image `src` remains a logical RID and parsing performs no path I/O. Per-paint +`visible`, `opacity`, and `blend-mode` lower directly to model state. Solid and +stop opacity intentionally quantize into RGBA8 alpha. + +Paintable nodes also carry ordered `Vec` state. Each stroke owns its +own existing `Paints` plus width, alignment, cap, join, miter, and dash +geometry. Repeated strokes therefore remain independent and lossless; lines +are stroke-only and receive no implicit ink. Default-empty stroke state +normalizes to omission, while non-default empty geometry survives round-trip. +This `Vec` is the lab implementation of Draft 0's accepted model +extension. The production scene/archive model still needs the corresponding +multiplicity change before it can claim the same round-trip surface. + +Historical E3 TextIr remains a separate singleton-opaque-solid dialect with no +stroke syntax. +`textir::try_print` reports richer state it cannot represent; the compatibility +`textir::print` wrapper never silently narrows that state. + +A boxed ``, ``, or `` may own free-positioned children in +its local box. This is composition only: it gains no flex attributes, +descendants do not feed back into the primitive's declared box or parent layout +contribution, and `` remains a leaf. Filesystem I/O, resources, and +rasterization remain host concerns. + Known lab simplifications (declared in the REPORT's lose column): children as ordered `Vec` (no fractional index), per-container Taffy trees (engine uses one tree), no incremental invalidation, no diff --git a/model-v2/a/lab/src/bin/e1.rs b/model-v2/a/lab/src/bin/e1.rs index 75882368a3..3eb8999262 100644 --- a/model-v2/a/lab/src/bin/e1.rs +++ b/model-v2/a/lab/src/bin/e1.rs @@ -66,7 +66,7 @@ fn scene(fixed_w: Option) -> Scene { desc: ShapeDesc::Rect, }, ); - b.node_mut(c).fill = Some((*color).into()); + b.node_mut(c).fills = Paints::solid((*color).into()); let _ = i; cards.push(c); } @@ -196,7 +196,8 @@ fn snapshot(theta: f32) -> String { width: 640.0, height: 200.0, }, - ); + ) + .expect("E1 snapshot uses only empty or singleton-solid paint stacks"); let y = 10 + i * 200; let _ = writeln!(svg, r#""#); let _ = writeln!( diff --git a/model-v2/a/lab/src/grida_xml.rs b/model-v2/a/lab/src/grida_xml.rs new file mode 100644 index 0000000000..a85e9c742a --- /dev/null +++ b/model-v2/a/lab/src/grida_xml.rs @@ -0,0 +1,210 @@ +//! Draft 0 `.grida.xml` source contract. +//! +//! The format envelope is structural and never becomes a paint node. Parsing +//! preserves the model's implicit viewport-spanning document root and attaches +//! the envelope's exactly-one authored node beneath it as the render root. +//! Files use one vocabulary: `` with direct +//! ``, ``, and `` primitive tags. `` and `kind` are +//! not aliases. Historical `` and `` +//! input belong exclusively to [`crate::textir`]. + +use crate::model::{DocBuilder, Document, NodeId}; +use crate::textir; +use std::collections::BTreeSet; +use std::fmt::Write as _; + +pub const VERSION: &str = "0"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError(pub String); + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "grida_xml: {}", self.0) + } +} + +impl std::error::Error for ParseError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrintError { + /// Omitting the implicit root is lossless only while it remains the exact + /// viewport-spanning, non-authored root defined by the model. + NonCanonicalDocumentRoot, + /// Draft 0 has one render entry point, neither zero nor a forest. + RenderRootCount { found: usize }, + /// The authored render root is always a container/frame payload. + RenderRootMustBeContainer { found: &'static str }, + /// The child list and the arena's parent column disagree. + InvalidRenderRootParent, + /// The model value cannot be represented by strict Draft 0 source without + /// losing or contradicting state. + InvalidDocument(String), +} + +impl std::fmt::Display for PrintError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PrintError::NonCanonicalDocumentRoot => { + write!( + f, + "grida_xml: document root is not the canonical viewport root" + ) + } + PrintError::RenderRootCount { found } => write!( + f, + "grida_xml: expected exactly one render root, found {found}" + ), + PrintError::RenderRootMustBeContainer { found } => write!( + f, + "grida_xml: render root must be a container, found {found}" + ), + PrintError::InvalidRenderRootParent => { + write!( + f, + "grida_xml: render root is not parented by the document root" + ) + } + PrintError::InvalidDocument(message) => { + write!(f, "grida_xml: document is not representable: {message}") + } + } + } +} + +impl std::error::Error for PrintError {} + +/// Parse a complete Draft 0 document. This is a pure string-to-model boundary; +/// hosts own paths, files, resources, and rasterization. +pub fn parse(input: &str) -> Result { + textir::parse_grida_xml(input).map_err(|error| ParseError(error.0)) +} + +/// Compare source semantics, not arena allocation. Node ids, empty/tombstoned +/// slots, and generations are storage artifacts; every live node must still be +/// reachable exactly once through an ordered child edge whose parent column +/// agrees. +fn semantic_document_eq(a: &Document, b: &Document) -> bool { + if a.parent_of(a.root).is_some() || b.parent_of(b.root).is_some() { + return false; + } + let mut a_seen = BTreeSet::new(); + let mut b_seen = BTreeSet::new(); + semantic_node_eq(a, a.root, &mut a_seen, b, b.root, &mut b_seen) + && a_seen.len() == a.len() + && b_seen.len() == b.len() +} + +fn semantic_node_eq( + a: &Document, + a_id: NodeId, + a_seen: &mut BTreeSet, + b: &Document, + b_id: NodeId, + b_seen: &mut BTreeSet, +) -> bool { + if !a_seen.insert(a_id) || !b_seen.insert(b_id) { + return false; + } + let (Some(a_node), Some(b_node)) = (a.get_opt(a_id), b.get_opt(b_id)) else { + return false; + }; + let canonical_strokes = |node: &crate::model::Node| { + node.strokes + .iter() + .filter(|stroke| { + !(stroke.paints.is_empty() && stroke.geometry_is_default_for(&node.payload)) + }) + .cloned() + .map(|mut stroke| { + if let Some(values) = &mut stroke.dash_array { + if values.len() % 2 == 1 { + let repeated = values.clone(); + values.extend(repeated); + } + } + stroke + }) + .collect::>() + }; + if a_node.header != b_node.header + || a_node.payload != b_node.payload + || a_node.fills != b_node.fills + || canonical_strokes(a_node) != canonical_strokes(b_node) + || a_node.children.len() != b_node.children.len() + { + return false; + } + a_node + .children + .iter() + .zip(&b_node.children) + .all(|(&a_child, &b_child)| { + a.parent_of(a_child) == Some(a_id) + && b.parent_of(b_child) == Some(b_id) + && semantic_node_eq(a, a_child, a_seen, b, b_child, b_seen) + }) +} + +/// Print a normalized Draft 0 document. +/// +/// Unlike the historical text-IR printer, this can fail: the envelope's one +/// render-root rule and the implicit viewport root make silently serializing a +/// forest or a mutated document root lossy. +pub fn print(doc: &Document) -> Result { + if !semantic_document_eq(doc, doc) { + return Err(PrintError::InvalidDocument( + "scene tree contains a dead, duplicate, cyclic, unreachable, or mis-parented node" + .into(), + )); + } + + let expected_doc = DocBuilder::new().build(); + let expected = expected_doc.get(expected_doc.root); + let root = doc + .get_opt(doc.root) + .ok_or_else(|| PrintError::InvalidDocument("document root is not live".into()))?; + + let canonical_root = root.header == expected.header + && root.payload == expected.payload + && root.fills == expected.fills + && root.strokes == expected.strokes + && doc.parent_of(doc.root).is_none(); + if !canonical_root { + return Err(PrintError::NonCanonicalDocumentRoot); + } + if root.children.len() != 1 { + return Err(PrintError::RenderRootCount { + found: root.children.len(), + }); + } + + let render_root = root.children[0]; + if doc.parent_of(render_root) != Some(doc.root) { + return Err(PrintError::InvalidRenderRootParent); + } + let render_root_node = doc + .get_opt(render_root) + .ok_or_else(|| PrintError::InvalidDocument("render root is not live".into()))?; + if !matches!( + render_root_node.payload, + crate::model::Payload::Frame { .. } + ) { + return Err(PrintError::RenderRootMustBeContainer { + found: render_root_node.payload.kind_name(), + }); + } + + let mut out = String::new(); + let _ = writeln!(out, ""); + textir::print_grida_xml_render_root(doc, render_root, 1, &mut out) + .map_err(PrintError::InvalidDocument)?; + let _ = writeln!(out, ""); + let reparsed = parse(&out).map_err(|error| PrintError::InvalidDocument(error.0))?; + if !semantic_document_eq(doc, &reparsed) { + return Err(PrintError::InvalidDocument( + "canonical source does not round-trip semantically".into(), + )); + } + Ok(out) +} diff --git a/model-v2/a/lab/src/lib.rs b/model-v2/a/lab/src/lib.rs index 5138de42d5..90415340c0 100644 --- a/model-v2/a/lab/src/lib.rs +++ b/model-v2/a/lab/src/lib.rs @@ -8,6 +8,7 @@ //! //! Standalone by design; promoted into `crates/` only at phase 4. +pub mod grida_xml; pub mod math; pub mod measure; pub mod model; diff --git a/model-v2/a/lab/src/measure.rs b/model-v2/a/lab/src/measure.rs index b333ff79de..dd8a1c7ca0 100644 --- a/model-v2/a/lab/src/measure.rs +++ b/model-v2/a/lab/src/measure.rs @@ -4,9 +4,11 @@ //! layout-imposed extents), not typography. The metric is intentionally //! trivial so humans and LLMs can compute it by hand (E3 probe): //! -//! - every character advances `0.6 × font_size` +//! - every non-newline character advances `0.6 × font_size` //! - line height is `1.2 × font_size` -//! - greedy word wrap on ASCII spaces; a word never breaks internally +//! - explicit `\n` line breaks are preserved +//! - greedy word wrap on ASCII spaces within each explicit line; a word never +//! breaks internally //! (a word wider than the constraint overflows on its own line) //! - trailing spaces on a wrapped line are dropped @@ -17,40 +19,97 @@ pub fn char_width(font_size: f32) -> f32 { CHAR_W * font_size } -/// Returns (width, height) of `content` at `font_size`, wrapped at -/// `max_width` when given (Fixed width ⇒ wrap constraint; Auto ⇒ single line). -pub fn measure_text(content: &str, font_size: f32, max_width: Option) -> (f32, f32) { - let cw = char_width(font_size); - let lh = LINE_H * font_size; - if content.is_empty() { - return (0.0, lh); +/// Floor a logical width to whole character advances, tolerating one ULP of +/// division error. A width produced by this same model as `n * char_width` +/// must admit those `n` characters when passed back as a constraint. Nudging +/// the non-negative quotient by exactly one representable step repairs that +/// closed round-trip without admitting a genuinely smaller quotient. +fn max_chars_for_width(max_width: f32, cw: f32) -> usize { + if cw <= 0.0 { + return usize::MAX; } - match max_width { - None => (content.chars().count() as f32 * cw, lh), - Some(maxw) => { - let max_chars = if cw > 0.0 { - (maxw / cw).floor().max(0.0) as usize + let quotient = (max_width / cw).max(0.0); + let tolerant = if quotient.is_finite() { + f32::from_bits(quotient.to_bits() + 1) + } else { + quotient + }; + tolerant.floor() as usize +} + +/// Lay out text into visual lines using the lab's deterministic metric. +/// +/// Explicit line breaks always create a new entry, including a final empty +/// entry for trailing `\n`. Under a width constraint, ASCII-space runs stay +/// authored while the following word fits. When that word soft-wraps, only +/// the pending separator run is dropped so it cannot become trailing ink on +/// the previous line. Words are never split internally. +pub fn layout_text_lines(content: &str, font_size: f32, max_width: Option) -> Vec { + let Some(max_width) = max_width else { + return content.split('\n').map(str::to_owned).collect(); + }; + let cw = char_width(font_size); + let max_chars = max_chars_for_width(max_width, cw); + + let mut lines = Vec::new(); + for explicit_line in content.split('\n') { + let mut chars = explicit_line.chars().peekable(); + let mut current = String::new(); + let mut current_len = 0usize; + let mut pending_spaces = String::new(); + let mut pending_len = 0usize; + + while chars.peek().is_some() { + while chars.peek().is_some_and(|ch| *ch == ' ') { + pending_spaces.push(chars.next().expect("peeked space")); + pending_len += 1; + } + if chars.peek().is_none() { + break; + } + + let mut word = String::new(); + let mut word_len = 0usize; + while chars.peek().is_some_and(|ch| *ch != ' ') { + word.push(chars.next().expect("peeked word character")); + word_len += 1; + } + + let candidate_len = current_len + pending_len + word_len; + if current_len > 0 && candidate_len > max_chars { + lines.push(std::mem::take(&mut current)); + current = word; + current_len = word_len; } else { - usize::MAX - }; - let mut lines: Vec = vec![]; // char count per line - let mut current = 0usize; - for word in content.split(' ') { - let wlen = word.chars().count(); - if current == 0 { - current = wlen; - } else if current + 1 + wlen <= max_chars { - current += 1 + wlen; - } else { - lines.push(current); - current = wlen; - } + current.push_str(&pending_spaces); + current.push_str(&word); + current_len = candidate_len; } - lines.push(current); - let widest = lines.iter().copied().max().unwrap_or(0); - (widest as f32 * cw, lines.len() as f32 * lh) + pending_spaces.clear(); + pending_len = 0; } + + // No soft wrap was requested after these spaces, so they remain + // authored content (including lines made entirely of spaces). + current.push_str(&pending_spaces); + lines.push(current); } + lines +} + +/// Returns (width, height) of `content` at `font_size`, wrapped at +/// `max_width` when given (Fixed width ⇒ soft-wrap constraint; Auto ⇒ only +/// authored line breaks). +pub fn measure_text(content: &str, font_size: f32, max_width: Option) -> (f32, f32) { + let cw = char_width(font_size); + let lh = LINE_H * font_size; + let lines = layout_text_lines(content, font_size, max_width); + let widest = lines + .iter() + .map(|line| line.chars().count()) + .max() + .unwrap_or(0); + (widest as f32 * cw, lines.len() as f32 * lh) } #[cfg(test)] @@ -73,4 +132,74 @@ mod tests { assert_eq!(w, 30.0); // "aa bb" = 5 chars × 6 assert_eq!(h, 24.0); // 2 lines × 12 } + + #[test] + fn preserves_explicit_and_trailing_line_breaks() { + let (w, h) = measure_text("a\nbb\n", 10.0, None); + assert_eq!(w, 12.0); + assert_eq!(h, 36.0); + } + + #[test] + fn wraps_each_explicit_line_independently() { + // First explicit line wraps in two, then `x`, then the trailing empty + // line: four measured lines total. + let (w, h) = measure_text("aa bb cc\nx\n", 10.0, Some(30.0)); + assert_eq!(w, 30.0); + assert_eq!(h, 48.0); + } + + #[test] + fn constrained_layout_preserves_leading_and_repeated_spaces() { + assert_eq!( + layout_text_lines(" a", 10.0, Some(60.0)), + vec![" a"], + "leading spaces retain their advances" + ); + assert_eq!( + layout_text_lines("a b", 10.0, Some(24.0)), + vec!["a b"], + "repeated spaces remain when the following word fits" + ); + assert_eq!(measure_text(" a", 10.0, Some(60.0)), (18.0, 12.0)); + assert_eq!(measure_text("a b", 10.0, Some(24.0)), (24.0, 12.0)); + } + + #[test] + fn soft_wrap_drops_only_the_separator_that_would_trail_the_line() { + assert_eq!( + layout_text_lines("aa bb", 10.0, Some(24.0)), + vec!["aa", "bb"] + ); + assert_eq!(measure_text("aa bb", 10.0, Some(24.0)), (12.0, 24.0)); + + assert_eq!( + layout_text_lines("a ", 10.0, Some(60.0)), + vec!["a "], + "authored trailing spaces survive when no soft wrap occurs" + ); + assert_eq!(measure_text("a ", 10.0, Some(60.0)), (18.0, 12.0)); + } + + #[test] + fn model_produced_natural_width_does_not_invent_a_soft_wrap() { + // 15 × 0.6 is representable as 9, but 9 / 0.6 rounds one ULP below + // 15. The space makes that lost character observable as a false wrap. + let content = "aaaaaaa aaaaaaa"; + let font_size = 1.0; + let (natural_width, _) = measure_text(content, font_size, None); + assert!(natural_width / char_width(font_size) < 15.0); + assert_eq!( + layout_text_lines(content, font_size, Some(natural_width)), + vec![content], + "feeding a model-produced natural width back is closed" + ); + + let genuinely_smaller = f32::from_bits(natural_width.to_bits() - 1); + assert_eq!( + layout_text_lines(content, font_size, Some(genuinely_smaller)), + vec!["aaaaaaa", "aaaaaaa"], + "the immediately smaller authored width still floors" + ); + } } diff --git a/model-v2/a/lab/src/model.rs b/model-v2/a/lab/src/model.rs index 39374d0bbc..3107af15b0 100644 --- a/model-v2/a/lab/src/model.rs +++ b/model-v2/a/lab/src/model.rs @@ -7,9 +7,10 @@ //! Lab simplifications, all declared: //! - node ids are `u32`; children are an ordered `Vec` on the parent //! (fractional-index ordering is not under test here); -//! - `SurfaceStyle` is reduced to an optional fill color used only by the -//! SVG snapshot renderer. +//! - `SurfaceStyle` carries ordered fills and ordered stroke applications; +//! corners and effects remain outside the lab subset. +use crate::math::Affine; use std::collections::BTreeMap; pub type NodeId = u32; @@ -241,6 +242,17 @@ impl Payload { pub fn box_is_derived(&self) -> bool { matches!(self, Payload::Group | Payload::Lens { .. }) } + /// Whether this payload establishes a child coordinate space. + /// + /// Frames may additionally lay children out; shapes own free-positioned + /// children without changing their declared box. Groups/lenses derive + /// their boxes from children. Text remains a measured leaf. + pub fn accepts_children(&self) -> bool { + matches!( + self, + Payload::Frame { .. } | Payload::Shape { .. } | Payload::Group | Payload::Lens { .. } + ) + } pub fn kind_name(&self) -> &'static str { match self { Payload::Frame { .. } => "frame", @@ -252,7 +264,7 @@ impl Payload { } } -/// A packed opaque color, `0xAARRGGBB` — the numeric paint value. Browsers +/// A packed RGBA8 color, `0xAARRGGBB` — the numeric paint value. Browsers /// never store a resolved color as a string (a CSS string is a parse *input* /// and a serialize *output* only — verified across Blink/Stylo/Skia); the text /// IR and SVG boundaries convert via `from_hex`/`to_hex`, and everything in @@ -265,35 +277,71 @@ pub struct Color(pub u32); impl Color { pub const BLACK: Color = Color(0xFF00_0000); + pub const TRANSPARENT: Color = Color(0x0000_0000); /// Packed `0xAARRGGBB`. pub fn argb(self) -> u32 { self.0 } - /// Parse `#rrggbb` (forced opaque, byte-identical to the painter's old - /// `resolve_color`). `None` on any other form — the caller keeps its own - /// per-kind fallback. + /// Parse the frozen E3/TextIr color spelling: exactly six hexadecimal + /// digits after removing any leading `#` characters. pub fn from_hex(s: &str) -> Option { let h = s.trim_start_matches('#'); - if h.len() == 6 { - if let Ok(v) = u32::from_str_radix(h, 16) { - return Some(Color(0xFF00_0000 | v)); - } + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; } - None + let rgb = u32::from_str_radix(h, 16).ok()?; + Some(Color(0xFF00_0000 | rgb)) + } + + /// Parse the Draft 0 `.grida.xml` color spelling: `#rgb` or `#rrggbb`, + /// both opaque. Alpha is authored separately and folded into this RGBA8 + /// value by the XML boundary. + pub fn from_grida_hex(s: &str) -> Option { + let h = s.strip_prefix('#')?; + let rgb = match h.len() { + 3 => { + let mut expanded = String::with_capacity(6); + for ch in h.chars() { + if !ch.is_ascii_hexdigit() { + return None; + } + expanded.push(ch); + expanded.push(ch); + } + u32::from_str_radix(&expanded, 16).ok()? + } + 6 if h.bytes().all(|b| b.is_ascii_hexdigit()) => u32::from_str_radix(h, 16).ok()?, + _ => return None, + }; + Some(Color(0xFF00_0000 | rgb)) } - /// Serialize as `#RRGGBB` (the IR/SVG boundary; alpha dropped — lab paint - /// is opaque). + /// Serialize RGB as canonical uppercase `#RRGGBB`. Alpha is serialized as + /// the paint/stop `opacity` property. pub fn to_hex(self) -> String { format!("#{:06X}", self.0 & 0x00FF_FFFF) } + + pub fn alpha(self) -> u8 { + (self.0 >> 24) as u8 + } + + pub fn opacity(self) -> f32 { + self.alpha() as f32 / 255.0 + } + + /// Replace alpha with the nearest RGBA8 value for a normalized opacity. + pub fn with_opacity(self, opacity: f32) -> Color { + let alpha = (opacity.clamp(0.0, 1.0) * 255.0).round() as u32; + Color((alpha << 24) | (self.0 & 0x00FF_FFFF)) + } } -/// Ergonomic authoring + the IR/SVG parse boundary: `"#4A90D9".into()`. A -/// malformed literal falls back to opaque black (no exercised path hits it — -/// every fill is a valid `#rrggbb`; the gate proves pixel-identity). +/// Frozen E3/TextIr-compatible ergonomic authoring: `"#4A90D9".into()` or +/// `"4A90D9".into()`. A malformed literal falls back to opaque black. Draft 0 +/// `.grida.xml` deliberately parses through [`Color::from_grida_hex`] instead. impl From<&str> for Color { fn from(s: &str) -> Color { Color::from_hex(s).unwrap_or(Color::BLACK) @@ -305,13 +353,593 @@ impl From for Color { } } +/// Blend functions available to individual paints. `PassThrough` is a layer +/// mode and deliberately does not appear here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BlendMode { + #[default] + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +impl BlendMode { + pub fn as_str(self) -> &'static str { + match self { + BlendMode::Normal => "normal", + BlendMode::Multiply => "multiply", + BlendMode::Screen => "screen", + BlendMode::Overlay => "overlay", + BlendMode::Darken => "darken", + BlendMode::Lighten => "lighten", + BlendMode::ColorDodge => "color-dodge", + BlendMode::ColorBurn => "color-burn", + BlendMode::HardLight => "hard-light", + BlendMode::SoftLight => "soft-light", + BlendMode::Difference => "difference", + BlendMode::Exclusion => "exclusion", + BlendMode::Hue => "hue", + BlendMode::Saturation => "saturation", + BlendMode::Color => "color", + BlendMode::Luminosity => "luminosity", + } + } +} + +/// Centered normalized alignment. `(-1,-1)` is top-left, `(0,0)` center, +/// `(1,1)` bottom-right. XML gradient points use UV and convert at the edge. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Alignment(pub f32, pub f32); + +impl Alignment { + pub const CENTER: Alignment = Alignment(0.0, 0.0); + pub const CENTER_LEFT: Alignment = Alignment(-1.0, 0.0); + pub const CENTER_RIGHT: Alignment = Alignment(1.0, 0.0); + + pub fn from_uv(u: f32, v: f32) -> Alignment { + Alignment(u * 2.0 - 1.0, v * 2.0 - 1.0) + } + + /// Lower textual UV coordinates without first quantizing them to `f32`. + /// The model remains `f32`; the extra boundary precision preserves model + /// values such as `0.1` that an intermediate `f32` UV would skip. + pub fn from_uv_f64(u: f64, v: f64) -> Alignment { + Alignment((u * 2.0 - 1.0) as f32, (v * 2.0 - 1.0) as f32) + } + + /// Return a canonical UV pair only when lowering it through + /// [`Alignment::from_uv_f64`] reproduces this model value exactly (modulo + /// signed zero, which model equality does not distinguish). + /// + /// Most finite `f32` alignments, including arbitrary ordinary values and + /// the finite extremes, are representable. The narrow interval between + /// zero and the f64 values adjacent to UV `0.5` is not: f64 quantization + /// itself skips those model values. The exhaustive binary search keeps + /// this boundary honest rather than emitting source that changes state. + pub fn try_to_uv(self) -> Option<(f64, f64)> { + fn lower(uv: f64) -> f32 { + (uv * 2.0 - 1.0) as f32 + } + + fn ordered(value: f64) -> u64 { + let bits = value.to_bits(); + if bits & 0x8000_0000_0000_0000 == 0 { + bits ^ 0x8000_0000_0000_0000 + } else { + !bits + } + } + + fn from_ordered(value: u64) -> f64 { + let bits = if value & 0x8000_0000_0000_0000 == 0 { + !value + } else { + value ^ 0x8000_0000_0000_0000 + }; + f64::from_bits(bits) + } + + fn component(value: f32) -> Option { + if !value.is_finite() { + return None; + } + + let ideal = (f64::from(value) + 1.0) * 0.5; + if lower(ideal) == value { + return Some(ideal); + } + + let mut lo = ordered(-f64::MAX); + let mut hi = ordered(f64::MAX); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if lower(from_ordered(mid)) < value { + lo = mid + 1; + } else { + hi = mid; + } + } + let uv = from_ordered(lo); + (lower(uv) == value).then_some(uv) + } + + Some((component(self.0)?, component(self.1)?)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TileMode { + #[default] + Clamp, + Repeated, + Mirror, + Decal, +} + +impl TileMode { + pub fn as_str(self) -> &'static str { + match self { + TileMode::Clamp => "clamp", + TileMode::Repeated => "repeated", + TileMode::Mirror => "mirror", + TileMode::Decal => "decal", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoxFit { + Contain, + Cover, + Fill, + None, +} + +impl BoxFit { + pub fn as_str(self) -> &'static str { + match self { + BoxFit::Contain => "contain", + BoxFit::Cover => "cover", + BoxFit::Fill => "fill", + BoxFit::None => "none", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SolidPaint { + pub active: bool, + pub color: Color, + pub blend_mode: BlendMode, +} + +impl SolidPaint { + pub fn new(color: Color) -> SolidPaint { + SolidPaint { + active: true, + color, + blend_mode: BlendMode::Normal, + } + } + + /// Solid opacity is the color alpha; it is intentionally not a second + /// floating-point field. + pub fn opacity(&self) -> f32 { + self.color.opacity() + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GradientStop { + pub offset: f32, + pub color: Color, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinearGradientPaint { + pub active: bool, + pub xy1: Alignment, + pub xy2: Alignment, + pub tile_mode: TileMode, + pub transform: Affine, + pub stops: Vec, + pub opacity: f32, + pub blend_mode: BlendMode, +} + +impl Default for LinearGradientPaint { + fn default() -> Self { + LinearGradientPaint { + active: true, + xy1: Alignment::CENTER_LEFT, + xy2: Alignment::CENTER_RIGHT, + tile_mode: TileMode::Clamp, + transform: Affine::IDENTITY, + stops: vec![], + opacity: 1.0, + blend_mode: BlendMode::Normal, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RadialGradientPaint { + pub active: bool, + pub transform: Affine, + pub stops: Vec, + pub opacity: f32, + pub blend_mode: BlendMode, + pub tile_mode: TileMode, +} + +impl Default for RadialGradientPaint { + fn default() -> Self { + RadialGradientPaint { + active: true, + transform: Affine::IDENTITY, + stops: vec![], + opacity: 1.0, + blend_mode: BlendMode::Normal, + tile_mode: TileMode::Clamp, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SweepGradientPaint { + pub active: bool, + pub transform: Affine, + pub stops: Vec, + pub opacity: f32, + pub blend_mode: BlendMode, +} + +impl Default for SweepGradientPaint { + fn default() -> Self { + SweepGradientPaint { + active: true, + transform: Affine::IDENTITY, + stops: vec![], + opacity: 1.0, + blend_mode: BlendMode::Normal, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DiamondGradientPaint { + pub active: bool, + pub transform: Affine, + pub stops: Vec, + pub opacity: f32, + pub blend_mode: BlendMode, +} + +impl Default for DiamondGradientPaint { + fn default() -> Self { + DiamondGradientPaint { + active: true, + transform: Affine::IDENTITY, + stops: vec![], + opacity: 1.0, + blend_mode: BlendMode::Normal, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResourceRef { + Hash(String), + Rid(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ImageRepeat { + RepeatX, + RepeatY, + #[default] + Repeat, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ImageTile { + pub scale: f32, + pub repeat: ImageRepeat, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ImagePaintFit { + Fit(BoxFit), + Transform(Affine), + Tile(ImageTile), +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct ImageFilters { + pub exposure: f32, + pub contrast: f32, + pub saturation: f32, + pub temperature: f32, + pub tint: f32, + pub highlights: f32, + pub shadows: f32, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ImagePaint { + pub active: bool, + pub image: ResourceRef, + pub quarter_turns: u8, + pub alignment: Alignment, + pub fit: ImagePaintFit, + pub opacity: f32, + pub blend_mode: BlendMode, + pub filters: ImageFilters, +} + +impl ImagePaint { + pub fn from_rid(rid: impl Into) -> ImagePaint { + ImagePaint { + active: true, + image: ResourceRef::Rid(rid.into()), + quarter_turns: 0, + alignment: Alignment::CENTER, + fit: ImagePaintFit::Fit(BoxFit::Cover), + opacity: 1.0, + blend_mode: BlendMode::Normal, + filters: ImageFilters::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Paint { + Solid(SolidPaint), + LinearGradient(LinearGradientPaint), + RadialGradient(RadialGradientPaint), + SweepGradient(SweepGradientPaint), + DiamondGradient(DiamondGradientPaint), + Image(ImagePaint), +} + +impl Paint { + pub fn active(&self) -> bool { + match self { + Paint::Solid(paint) => paint.active, + Paint::LinearGradient(paint) => paint.active, + Paint::RadialGradient(paint) => paint.active, + Paint::SweepGradient(paint) => paint.active, + Paint::DiamondGradient(paint) => paint.active, + Paint::Image(paint) => paint.active, + } + } + + pub fn opacity(&self) -> f32 { + match self { + Paint::Solid(paint) => paint.opacity(), + Paint::LinearGradient(paint) => paint.opacity, + Paint::RadialGradient(paint) => paint.opacity, + Paint::SweepGradient(paint) => paint.opacity, + Paint::DiamondGradient(paint) => paint.opacity, + Paint::Image(paint) => paint.opacity, + } + } + + pub fn blend_mode(&self) -> BlendMode { + match self { + Paint::Solid(paint) => paint.blend_mode, + Paint::LinearGradient(paint) => paint.blend_mode, + Paint::RadialGradient(paint) => paint.blend_mode, + Paint::SweepGradient(paint) => paint.blend_mode, + Paint::DiamondGradient(paint) => paint.blend_mode, + Paint::Image(paint) => paint.blend_mode, + } + } + + pub fn visible(&self) -> bool { + self.active() && self.opacity() != 0.0 + } +} + +/// Ordered paint stack. Entry 0 is painted first (bottommost); each later +/// entry composites above it. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Paints { + paints: Vec, +} + +/// Placement of a uniform stroke relative to the source outline. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StrokeAlign { + #[default] + Inside, + Center, + Outside, +} + +impl StrokeAlign { + pub fn as_str(self) -> &'static str { + match self { + StrokeAlign::Inside => "inside", + StrokeAlign::Center => "center", + StrokeAlign::Outside => "outside", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StrokeCap { + #[default] + Butt, + Round, + Square, +} + +impl StrokeCap { + pub fn as_str(self) -> &'static str { + match self { + StrokeCap::Butt => "butt", + StrokeCap::Round => "round", + StrokeCap::Square => "square", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StrokeJoin { + #[default] + Miter, + Round, + Bevel, +} + +impl StrokeJoin { + pub fn as_str(self) -> &'static str { + match self { + StrokeJoin::Miter => "miter", + StrokeJoin::Round => "round", + StrokeJoin::Bevel => "bevel", + } + } +} + +/// One independently covered stroke application. The paint stack remains the +/// existing ordered [`Paints`] model: entry zero is bottommost within this +/// geometry, and repeated `Stroke` values are themselves painted in list +/// order. +#[derive(Debug, Clone, PartialEq)] +pub struct Stroke { + pub paints: Paints, + pub width: f32, + pub align: StrokeAlign, + pub cap: StrokeCap, + pub join: StrokeJoin, + pub miter_limit: f32, + pub dash_array: Option>, +} + +impl Stroke { + /// Language/model defaults depend only on whether the source outline is + /// open. A line has no meaningful inside/outside and therefore centers + /// its default stroke; every closed Draft 0 outline defaults inside. + pub fn default_for(payload: &Payload) -> Option { + let align = match payload { + Payload::Shape { + desc: ShapeDesc::Line, + } => StrokeAlign::Center, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse, + } + | Payload::Text { .. } => StrokeAlign::Inside, + Payload::Group | Payload::Lens { .. } => return None, + }; + Some(Stroke { + paints: Paints::default(), + width: 1.0, + align, + cap: StrokeCap::Butt, + join: StrokeJoin::Miter, + miter_limit: 4.0, + dash_array: None, + }) + } + + pub fn geometry_is_default_for(&self, payload: &Payload) -> bool { + Self::default_for(payload).is_some_and(|default| { + self.width == default.width + && self.align == default.align + && self.cap == default.cap + && self.join == default.join + && self.miter_limit == default.miter_limit + && self.dash_array == default.dash_array + }) + } + + pub fn visible(&self) -> bool { + self.width > 0.0 && self.paints.iter().any(Paint::visible) + } +} + +impl Paints { + pub fn new(paints: impl IntoIterator) -> Paints { + Paints { + paints: paints.into_iter().collect(), + } + } + + pub fn solid(color: Color) -> Paints { + Paints::new([Paint::Solid(SolidPaint::new(color))]) + } + + pub fn is_empty(&self) -> bool { + self.paints.is_empty() + } + + pub fn len(&self) -> usize { + self.paints.len() + } + + pub fn as_slice(&self) -> &[Paint] { + &self.paints + } + + pub fn as_mut_slice(&mut self) -> &mut [Paint] { + &mut self.paints + } + + pub fn iter(&self) -> std::slice::Iter<'_, Paint> { + self.paints.iter() + } + + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Paint> { + self.paints.iter_mut() + } + + /// Appending places the new paint on top of the existing stack. + pub fn push(&mut self, paint: Paint) { + self.paints.push(paint); + } +} + +impl std::ops::Deref for Paints { + type Target = [Paint]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +impl IntoIterator for Paints { + type Item = Paint; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.paints.into_iter() + } +} + #[derive(Debug, Clone, PartialEq)] pub struct Node { pub id: NodeId, pub header: Header, pub payload: Payload, pub children: Vec, - pub fill: Option, // lab-only paint for SVG snapshots + pub fills: Paints, + pub strokes: Vec, } /// The document store is a **node arena**: `NodeId` IS the slot index, @@ -408,6 +1036,12 @@ impl Document { /// Structural insert: registers the node at `node.id` and attaches it /// as the last child of `parent`. pub fn add_child(&mut self, parent: NodeId, node: Node) -> NodeId { + let parent_payload = &self.get(parent).payload; + assert!( + parent_payload.accepts_children(), + "{} does not accept children", + parent_payload.kind_name() + ); let id = node.id; self.ensure_slot(id); debug_assert!(self.slots[id as usize].is_none(), "slot occupied"); @@ -453,6 +1087,12 @@ impl Document { remove: usize, insert: Vec, ) { + let parent_payload = &self.get(parent).payload; + assert!( + parent_payload.accepts_children(), + "{} does not accept children", + parent_payload.kind_name() + ); for &c in &insert { self.parents[c as usize] = Some(parent); } @@ -464,6 +1104,13 @@ impl Document { /// Build the arena from an id-keyed map (ids become slot indices); /// parent links derive from the children lists once, at construction. pub fn from_map(nodes: BTreeMap, root: NodeId) -> Document { + for node in nodes.values() { + assert!( + node.children.is_empty() || node.payload.accepts_children(), + "{} does not accept children", + node.payload.kind_name() + ); + } let cap = nodes.keys().max().map(|m| *m as usize + 1).unwrap_or(0); let mut slots: Vec> = Vec::with_capacity(cap); slots.resize_with(cap, || None); @@ -515,7 +1162,8 @@ impl DocBuilder { clips_content: false, }, children: vec![], - fill: None, + fills: Paints::default(), + strokes: vec![], }, ); DocBuilder { @@ -526,6 +1174,12 @@ impl DocBuilder { } pub fn add(&mut self, parent: NodeId, header: Header, payload: Payload) -> NodeId { + let parent_payload = &self.nodes.get(&parent).expect("parent must exist").payload; + assert!( + parent_payload.accepts_children(), + "{} does not accept children", + parent_payload.kind_name() + ); let id = self.next; self.next += 1; self.nodes.insert( @@ -535,7 +1189,8 @@ impl DocBuilder { header, payload, children: vec![], - fill: None, + fills: Paints::default(), + strokes: vec![], }, ); self.nodes.get_mut(&parent).unwrap().children.push(id); diff --git a/model-v2/a/lab/src/pick.rs b/model-v2/a/lab/src/pick.rs index aba3a1fcc8..3fcba5a6f1 100644 --- a/model-v2/a/lab/src/pick.rs +++ b/model-v2/a/lab/src/pick.rs @@ -19,14 +19,33 @@ pub fn pick(doc: &Document, resolved: &Resolved, x: f32, y: f32) -> Option Option { - if r.world_opt(id).is_none() { - return None; // hidden subtree - } + r.world_opt(id)?; // hidden subtree let node = doc.get(id); // Children first, topmost-first (paint order = document order). - for &c in node.children.iter().rev() { - if let Some(hit) = hit_subtree(doc, r, c, p) { - return Some(hit); + // A container clip scopes descendants only: outside it, skip the child + // traversal but still test the container's own fill/strokes below. The + // inverse world transform makes this exact for rotated/flipped containers + // and naturally enforces every clip encountered along the ancestor walk. + let children_visible = if matches!( + &node.payload, + Payload::Frame { + clips_content: true, + .. + } + ) { + r.world_of(id).invert().is_some_and(|inverse| { + let (lx, ly) = inverse.apply(p); + let b = r.box_of(id); + lx >= 0.0 && lx <= b.w && ly >= 0.0 && ly <= b.h + }) + } else { + true + }; + if children_visible { + for &c in node.children.iter().rev() { + if let Some(hit) = hit_subtree(doc, r, c, p) { + return Some(hit); + } } } // Own ink: derived kinds have none (their bounds come from children). diff --git a/model-v2/a/lab/src/resolve.rs b/model-v2/a/lab/src/resolve.rs index e86c919a0a..f33c92f88e 100644 --- a/model-v2/a/lab/src/resolve.rs +++ b/model-v2/a/lab/src/resolve.rs @@ -215,11 +215,14 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 let mut w = span_w; let mut h = span_h; + let text_auto_height = h.is_none() + && matches!(node.header.height, SizeIntent::Auto) + && matches!(&node.payload, Payload::Text { .. }); if w.is_none() { w = intent_extent_x(id, cx); } - if h.is_none() { + if h.is_none() && !text_auto_height { // A span-resolved width IS a wrap constraint (census finding: the // canonical Span{0,0} fill must re-wrap like Fixed/stretched widths). h = intent_extent_y(id, span_w, cx); @@ -236,6 +239,7 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 } } + let width_before_clamp = w; let mut wv = w.unwrap_or_else(|| { cx.out.reports.push(Report::ErrorByRule { node: id, @@ -244,6 +248,36 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 }); 0.0 }); + // Width constraints precede auto-height text measurement: changing the + // final wrapping width must change the measured line count in the same + // resolution pass. + wv = clamp_axis( + id, + "width", + wv, + node.header.min_width, + node.header.max_width, + cx, + ); + if text_auto_height { + let node = cx.doc.get(id); + let Payload::Text { content, font_size } = &node.payload else { + unreachable!("text_auto_height is set only for text") + }; + let wrap_width = if span_w.is_some() + || matches!(node.header.width, SizeIntent::Fixed(_)) + || width_before_clamp.is_some_and(|before| wv < before) + { + Some(wv) + } else { + // Auto width with no narrowing constraint keeps its no-soft-wrap + // contract; passing the computed natural width back through a + // floating-point floor can otherwise invent a wrap. + None + }; + h = Some(measure_text(content, *font_size, wrap_width).1); + } + let mut hv = h.unwrap_or_else(|| { cx.out.reports.push(Report::ErrorByRule { node: id, @@ -253,15 +287,8 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 0.0 }); - // min/max clamp last; min beats max (G-4 declared rule). - wv = clamp_axis( - id, - "width", - wv, - node.header.min_width, - node.header.max_width, - cx, - ); + // Height clamps apply after any width-driven text re-measure. Min beats + // max on both axes (G-4 declared rule). hv = clamp_axis( id, "height", @@ -709,28 +736,47 @@ fn commit(id: NodeId, box_rect: RectF, cx: &mut Ctx) { cx.out.box_in_parent[id as usize] = Some(box_rect); cx.out.local[id as usize] = Some(local); - // Recurse into frame children (group/lens children were committed by - // union_of_derived; leaves have none). - if let Payload::Frame { layout, .. } = &cx.doc.get(id).payload { - let layout = *layout; - layout_frame_children(id, layout, (box_rect.w, box_rect.h), cx); + // Child ownership is orthogonal to box source. Frames can lay children + // out; boxed shapes establish a free-positioned local coordinate space; + // group/lens children were already committed while deriving their union. + // Text is the only implemented leaf kind. + enum ChildProgram { + Frame(LayoutBehavior), + Free, + Derived, + Leaf, + } + let child_program = match &cx.doc.get(id).payload { + Payload::Frame { layout, .. } => ChildProgram::Frame(*layout), + Payload::Shape { .. } => ChildProgram::Free, + Payload::Group | Payload::Lens { .. } => ChildProgram::Derived, + Payload::Text { .. } => ChildProgram::Leaf, + }; + match child_program { + ChildProgram::Frame(layout) => { + layout_frame_children(id, layout, (box_rect.w, box_rect.h), cx) + } + ChildProgram::Free => { + layout_free_children(id, (box_rect.w, box_rect.h), EdgeInsets::default(), cx) + } + ChildProgram::Derived => {} + ChildProgram::Leaf => { + if !cx.doc.get(id).children.is_empty() { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field: "children", + rule: "text is a leaf payload", + }); + } + } } } fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), cx: &mut Ctx) { - let children: Vec = cx.doc.get(id).children.clone(); match layout.mode { - LayoutMode::None => { - for child_id in children { - if !cx.doc.get(child_id).header.active { - continue; - } - report_flow_fields_inert(child_id, cx); - let b = place_by_bindings(child_id, extent, cx); - commit(child_id, b, cx); - } - } + LayoutMode::None => layout_free_children(id, extent, layout.padding, cx), LayoutMode::Flex => { + let children: Vec = cx.doc.get(id).children.clone(); let out = flex_layout(id, layout, (Some(extent.0), Some(extent.1)), cx); for (child_id, slot, basis) in out.slots { let child = cx.doc.get(child_id); @@ -793,6 +839,29 @@ fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), } } +/// Resolve children against a local content box without a layout algorithm. +/// Free frames inset that box by padding; shapes pass zero padding, so their +/// children remain in the shape's direct local coordinates. A shape's children +/// may paint outside it, but never change its declared box or parent layout +/// contribution — the box remains the only negotiation surface (MODEL law 3). +fn layout_free_children(id: NodeId, extent: (f32, f32), padding: EdgeInsets, cx: &mut Ctx) { + let content_extent = ( + (extent.0 - padding.left - padding.right).max(0.0), + (extent.1 - padding.top - padding.bottom).max(0.0), + ); + let children: Vec = cx.doc.get(id).children.clone(); + for child_id in children { + if !cx.doc.get(child_id).header.active { + continue; + } + report_flow_fields_inert(child_id, cx); + let mut b = place_by_bindings(child_id, content_extent, cx); + b.x += padding.left; + b.y += padding.top; + commit(child_id, b, cx); + } +} + /// §8: x/y and grow/self_align are inert outside their contexts — report /// non-default values so tests can assert the matrix is enforced. fn report_flow_fields_inert(id: NodeId, cx: &mut Ctx) { @@ -927,6 +996,15 @@ fn flex_layout( SizeIntent::Fixed(v) => style.size.height = length(v), SizeIntent::Auto => style.size.height = auto(), } + // Per-child stretch is an explicit cross-axis fill override, even + // when text authored a fixed cross size. Container-level stretch + // still applies only to the Auto values left above. + if child.header.self_align == SelfAlign::Stretch { + match layout.direction { + Direction::Row => style.size.height = auto(), + Direction::Column => style.size.width = auto(), + } + } basis = (0.0, 0.0); // unused for θ=0 slots tree.new_leaf_with_context(style, TextCtx { content, font_size }) .unwrap() @@ -935,9 +1013,31 @@ fn flex_layout( basis = b; style.size.width = length(b.0); style.size.height = length(b.1); - // Stretch must be able to override the cross-axis basis (§2.2: - // cross-axis fill := self_align Stretch). - if child.header.self_align == SelfAlign::Stretch { + // An explicit per-child stretch overrides even a fixed cross size. + // Container-level stretch applies only when the authored cross + // size remains Auto; retaining the resolved basis as Definite here + // would erase that authored intent before Taffy sees it. + let authored_cross_is_auto = match layout.direction { + Direction::Row => matches!(child.header.height, SizeIntent::Auto), + Direction::Column => matches!(child.header.width, SizeIntent::Auto), + }; + // A line's vertical extent is geometry, not a flex-owned box + // axis. Even explicit self-stretch cannot turn its locked zero + // height into a non-degenerate paint box in a row container. + let line_height_is_locked = layout.direction == Direction::Row + && matches!( + &child.payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ); + let stretches_cross = !line_height_is_locked + && (child.header.self_align == SelfAlign::Stretch + || (!child.payload.box_is_derived() + && child.header.self_align == SelfAlign::Auto + && layout.cross_align == CrossAlign::Stretch + && authored_cross_is_auto)); + if stretches_cross { match layout.direction { Direction::Row => style.size.height = auto(), Direction::Column => style.size.width = auto(), @@ -1092,18 +1192,68 @@ fn compose_world(id: NodeId, parent_world: Affine, cx: &mut Ctx) { } } +/// Maximum outward coverage contributed by authored strokes in node-local +/// coordinates. Layout remains based on the source box; only visual bounds +/// read this expansion. Open line strokes are centered by definition, so their +/// cap/join details are conservatively covered by half the width on every side. +fn effective_stroke_outset(node: &Node) -> f32 { + let is_line = matches!( + &node.payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ); + node.strokes + .iter() + .filter(|stroke| stroke.visible()) + .map(|stroke| { + if is_line { + stroke.width / 2.0 + } else { + let base = match stroke.align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => stroke.width / 2.0, + StrokeAlign::Outside => stroke.width, + }; + if matches!(node.payload, Payload::Text { .. }) && stroke.join == StrokeJoin::Miter + { + base * stroke.miter_limit + } else { + base + } + } + }) + .fold(0.0, f32::max) +} + +fn intersect_aabbs(a: RectF, b: RectF) -> Option { + let x0 = a.x.max(b.x); + let y0 = a.y.max(b.y); + let x1 = (a.x + a.w).min(b.x + b.w); + let y1 = (a.y + a.h).min(b.y + b.h); + if x1 < x0 || y1 < y0 { + None + } else { + Some(RectF { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + }) + } +} + fn compute_world_aabb(id: NodeId, cx: &mut Ctx) -> Option { let node = cx.doc.get(id); - if cx.out.world[id as usize].is_none() { - return None; // hidden subtree - } + cx.out.world[id as usize]?; // hidden subtree let world = cx.out.world[id as usize].unwrap(); let own_box = cx.out.box_in_parent[id as usize].unwrap(); + let stroke_outset = effective_stroke_outset(node); let local_rect = RectF { - x: 0.0, - y: 0.0, - w: own_box.w, - h: own_box.h, + x: -stroke_outset, + y: -stroke_outset, + w: own_box.w + stroke_outset * 2.0, + h: own_box.h + stroke_outset * 2.0, }; let mut aabb = match node.payload { @@ -1111,9 +1261,34 @@ fn compute_world_aabb(id: NodeId, cx: &mut Ctx) -> Option { Payload::Group | Payload::Lens { .. } => None, _ => Some(local_rect.transformed_aabb(&world)), }; + // The painter clips descendants in the container's transformed local + // rectangle, but not the container's own fill or strokes. Intersect child + // contributions with that clip's world AABB so ancestor bounds remain a + // conservative over-approximation of pixels that can actually survive. + // A rotated clip remains conservative here; exact polygon clipping is a + // pick/narrowphase responsibility. + let child_clip = matches!( + &node.payload, + Payload::Frame { + clips_content: true, + .. + } + ) + .then(|| { + RectF { + x: 0.0, + y: 0.0, + w: own_box.w, + h: own_box.h, + } + .transformed_aabb(&world) + }); let children: Vec = node.children.clone(); for c in children { - if let Some(child_aabb) = compute_world_aabb(c, cx) { + let child_aabb = compute_world_aabb(c, cx).and_then(|bounds| { + child_clip.map_or(Some(bounds), |clip| intersect_aabbs(bounds, clip)) + }); + if let Some(child_aabb) = child_aabb { aabb = Some(match aabb { None => child_aabb, Some(a) => a.union(&child_aabb), diff --git a/model-v2/a/lab/src/svgout.rs b/model-v2/a/lab/src/svgout.rs index ee8bdc4edb..42967fbb38 100644 --- a/model-v2/a/lab/src/svgout.rs +++ b/model-v2/a/lab/src/svgout.rs @@ -16,7 +16,77 @@ pub struct SvgOptions { pub height: f32, } -pub fn render(doc: &Document, resolved: &Resolved, opts: &SvgOptions) -> String { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SvgError(pub String); + +impl std::fmt::Display for SvgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "svgout: {}", self.0) + } +} + +impl std::error::Error for SvgError {} + +#[derive(Debug, Clone, PartialEq)] +enum SvgFill { + None, + Solid { color: Color, active: bool }, +} + +impl SvgFill { + fn attributes(&self, property: &str) -> String { + match self { + SvgFill::None | SvgFill::Solid { active: false, .. } => { + format!(r#"{property}="none""#) + } + SvgFill::Solid { + color, + active: true, + } => { + let opacity = color.opacity(); + if opacity == 1.0 { + format!(r#"{property}="{}""#, color.to_hex()) + } else { + format!( + r#"{property}="{}" {property}-opacity="{opacity}""#, + color.to_hex() + ) + } + } + } + } +} + +fn svg_fill(node: &Node) -> Result { + if !node.strokes.is_empty() { + return Err(SvgError(format!( + "node {} uses authored strokes the SVG snapshot subset cannot represent", + node.id + ))); + } + match node.fills.as_slice() { + [] => Ok(SvgFill::None), + [Paint::Solid(solid)] if solid.blend_mode == BlendMode::Normal => Ok(SvgFill::Solid { + color: solid.color, + active: solid.active, + }), + [Paint::Solid(_)] => Err(SvgError(format!( + "node {} uses a solid blend mode the SVG snapshot subset cannot represent", + node.id + ))), + [_] => Err(SvgError(format!( + "node {} uses a rich paint the SVG snapshot subset cannot represent", + node.id + ))), + paints => Err(SvgError(format!( + "node {} has {} paints; the SVG snapshot subset supports at most one solid", + node.id, + paints.len() + ))), + } +} + +pub fn render(doc: &Document, resolved: &Resolved, opts: &SvgOptions) -> Result { let mut out = String::new(); let _ = writeln!( out, @@ -24,7 +94,7 @@ pub fn render(doc: &Document, resolved: &Resolved, opts: &SvgOptions) -> String w = opts.width, h = opts.height ); - paint(doc, doc.root, resolved, &mut out); + paint(doc, doc.root, resolved, &mut out)?; if opts.show_aabb { for (i, slot) in resolved.world_aabb.iter().enumerate() { let (id, aabb) = match slot { @@ -42,75 +112,98 @@ pub fn render(doc: &Document, resolved: &Resolved, opts: &SvgOptions) -> String } } let _ = writeln!(out, ""); - out + Ok(out) } -fn paint(doc: &Document, id: NodeId, resolved: &Resolved, out: &mut String) { +fn paint( + doc: &Document, + id: NodeId, + resolved: &Resolved, + out: &mut String, +) -> Result<(), SvgError> { let node = doc.get(id); let Some(world) = resolved.world_opt(id) else { - return; // hidden + return Ok(()); // hidden }; let world = &world; let b = resolved.box_of(id); - let fill_hex = node.fill.map(|c| c.to_hex()); // numeric → `#RRGGBB` at the SVG boundary - let fill = fill_hex.as_deref(); + if node.header.opacity != 1.0 { + return Err(SvgError(format!( + "node {} uses subtree opacity the SVG snapshot subset cannot represent", + node.id + ))); + } + if matches!( + node.payload, + Payload::Frame { + clips_content: true, + .. + } + ) { + return Err(SvgError(format!( + "node {} clips content, which the SVG snapshot subset cannot represent", + node.id + ))); + } + let fill = svg_fill(node)?; match &node.payload { Payload::Frame { .. } => { - let f = fill.unwrap_or("#f6f6f6"); + let fill = fill.attributes("fill"); let _ = writeln!( out, - r##" "##, + r#" "#, b.w, b.h, mat(world), - f + fill ); } - Payload::Shape { desc } => { - let f = fill.unwrap_or("#4a90d9"); - match desc { - ShapeDesc::Rect => { - let _ = writeln!( - out, - r#" "#, - b.w, - b.h, - mat(world), - f - ); - } - ShapeDesc::Ellipse => { - let _ = writeln!( - out, - r#" "#, - b.w / 2.0, - b.h / 2.0, - b.w / 2.0, - b.h / 2.0, - mat(world), - f - ); - } - ShapeDesc::Line => { - let _ = writeln!( - out, - r#" "#, - b.w, - mat(world), - f - ); - } + Payload::Shape { desc } => match desc { + ShapeDesc::Rect => { + let fill = fill.attributes("fill"); + let _ = writeln!( + out, + r#" "#, + b.w, + b.h, + mat(world), + fill + ); } - } + ShapeDesc::Ellipse => { + let fill = fill.attributes("fill"); + let _ = writeln!( + out, + r#" "#, + b.w / 2.0, + b.h / 2.0, + b.w / 2.0, + b.h / 2.0, + mat(world), + fill + ); + } + ShapeDesc::Line => { + let stroke = fill.attributes("stroke"); + let _ = writeln!( + out, + r#" "#, + b.w, + mat(world), + stroke + ); + } + }, Payload::Text { content, font_size } => { + let fill = fill.attributes("fill"); let _ = writeln!( out, - r#" {}"#, + r#" {}"#, font_size, mat(world), font_size, - fill.unwrap_or("#222"), + fill, content .replace('&', "&") .replace('<', "<") @@ -121,6 +214,7 @@ fn paint(doc: &Document, id: NodeId, resolved: &Resolved, out: &mut String) { } for c in &node.children { - paint(doc, *c, resolved, out); + paint(doc, *c, resolved, out)?; } + Ok(()) } diff --git a/model-v2/a/lab/src/textir.rs b/model-v2/a/lab/src/textir.rs index 987546ebc2..23d7c098bb 100644 --- a/model-v2/a/lab/src/textir.rs +++ b/model-v2/a/lab/src/textir.rs @@ -8,7 +8,8 @@ //! a binary-format concern; recorded as an E3 finding). use crate::model::*; -use quick_xml::events::Event; +use quick_xml::events::attributes::Attributes; +use quick_xml::events::{BytesDecl, BytesStart, Event}; use quick_xml::Reader; use std::collections::BTreeMap; use std::fmt::Write as _; @@ -37,13 +38,24 @@ fn parse_num(s: &str, what: &str) -> Result { Ok(v) } +fn parse_num_f64(s: &str, what: &str) -> Result { + let v: f64 = s + .trim() + .parse() + .map_err(|_| ParseError(format!("bad number `{s}` in {what}")))?; + if !v.is_finite() { + return err(format!("non-finite number `{s}` in {what} (N-2)")); + } + Ok(v) +} + fn parse_binding(s: &str, what: &str) -> Result { let parts: Vec<&str> = s.split_whitespace().collect(); match parts.as_slice() { [n] if n .chars() .next() - .map(|c| c.is_ascii_digit() || c == '-' || c == '.') + .map(|c| c.is_ascii_digit() || c == '+' || c == '-' || c == '.') == Some(true) => { Ok(AxisBinding::start(parse_num(n, what)?)) @@ -60,70 +72,784 @@ fn parse_binding(s: &str, what: &str) -> Result { } } -fn parse_size(s: &str, what: &str) -> Result { +fn parse_bool(s: &str, what: &str, strict: bool) -> Result { + if !strict { + return Ok(s == "true"); + } + match s { + "true" => Ok(true), + "false" => Ok(false), + _ => err(format!("{what} must be exactly `true` or `false`")), + } +} + +fn parse_size(s: &str, what: &str, strict: bool) -> Result { if s.trim() == "auto" { Ok(SizeIntent::Auto) } else { - Ok(SizeIntent::Fixed(parse_num(s, what)?)) + let value = parse_num(s, what)?; + if strict && value < 0.0 { + return err(format!("{what} must be non-negative")); + } + Ok(SizeIntent::Fixed(value)) + } +} + +fn parse_non_negative(s: &str, what: &str, strict: bool) -> Result { + let value = parse_num(s, what)?; + if strict && value < 0.0 { + return err(format!("{what} must be non-negative")); + } + Ok(value) +} + +fn parse_positive(s: &str, what: &str, strict: bool) -> Result { + let value = parse_num(s, what)?; + if strict && value <= 0.0 { + return err(format!("{what} must be greater than zero")); } + Ok(value) } -fn parse_lens_ops(s: &str) -> Result, ParseError> { +fn parse_lens_ops(s: &str, strict: bool) -> Result, ParseError> { + if strict { + let mut ops = vec![]; + let mut rest = s.trim(); + while !rest.is_empty() { + let open = rest + .find('(') + .ok_or_else(|| ParseError(format!("bad lens op `{rest}`")))?; + let close = rest[open + 1..] + .find(')') + .map(|index| open + 1 + index) + .ok_or_else(|| ParseError(format!("unclosed lens op `{rest}`")))?; + let raw = &rest[..close]; + ops.push(parse_lens_op(raw, true)?); + + let after = &rest[close + 1..]; + if after.is_empty() { + break; + } + if !after.chars().next().is_some_and(char::is_whitespace) { + return err("lens ops must be separated by whitespace"); + } + rest = after.trim_start(); + } + return Ok(ops); + } + let mut ops = vec![]; for raw in s.split(')') { let raw = raw.trim().trim_start_matches(',').trim(); if raw.is_empty() { continue; } - let (name, args) = raw - .split_once('(') - .ok_or_else(|| ParseError(format!("bad lens op `{raw}`")))?; - let nums: Vec = args - .split(',') - .filter(|a| !a.trim().is_empty()) - .map(|a| parse_num(a, "lens ops")) - .collect::>()?; - let op = match (name.trim(), nums.as_slice()) { - ("translate", [x, y]) => LensOp::Translate { x: *x, y: *y }, - ("rotate", [d]) => LensOp::Rotate { deg: *d }, - ("scale", [s]) => LensOp::Scale { x: *s, y: *s }, - ("scale", [x, y]) => LensOp::Scale { x: *x, y: *y }, - ("skew-x", [d]) => LensOp::Skew { - x_deg: *d, - y_deg: 0.0, - }, - ("skew-y", [d]) => LensOp::Skew { - x_deg: 0.0, - y_deg: *d, - }, - ("skew", [x, y]) => LensOp::Skew { - x_deg: *x, - y_deg: *y, - }, - ("matrix", [a, b, c, d, e, f]) => LensOp::Matrix { - m: [*a, *b, *c, *d, *e, *f], - }, - _ => return err(format!("bad lens op `{raw}`")), - }; - ops.push(op); + ops.push(parse_lens_op(raw, false)?); } Ok(ops) } +fn parse_lens_op(raw: &str, strict: bool) -> Result { + let (name, args) = raw + .split_once('(') + .ok_or_else(|| ParseError(format!("bad lens op `{raw}`")))?; + let arg_parts: Vec<&str> = args.split(',').collect(); + if strict && arg_parts.iter().any(|arg| arg.trim().is_empty()) { + return err(format!("empty argument in lens op `{raw}`")); + } + let nums: Vec = arg_parts + .into_iter() + .filter(|a| !a.trim().is_empty()) + .map(|a| parse_num(a, "lens ops")) + .collect::>()?; + match (name.trim(), nums.as_slice()) { + ("translate", [x, y]) => Ok(LensOp::Translate { x: *x, y: *y }), + ("rotate", [d]) => Ok(LensOp::Rotate { deg: *d }), + ("scale", [s]) => Ok(LensOp::Scale { x: *s, y: *s }), + ("scale", [x, y]) => Ok(LensOp::Scale { x: *x, y: *y }), + ("skew-x", [d]) => Ok(LensOp::Skew { + x_deg: *d, + y_deg: 0.0, + }), + ("skew-y", [d]) => Ok(LensOp::Skew { + x_deg: 0.0, + y_deg: *d, + }), + ("skew", [x, y]) => Ok(LensOp::Skew { + x_deg: *x, + y_deg: *y, + }), + ("matrix", [a, b, c, d, e, f]) => Ok(LensOp::Matrix { + m: [*a, *b, *c, *d, *e, *f], + }), + _ => err(format!("bad lens op `{raw}`")), + } +} + +fn validate_grida_xml_declaration(decl: &BytesDecl<'_>) -> Result<(), ParseError> { + let raw = std::str::from_utf8(decl.as_ref()) + .map_err(|_| ParseError("XML declaration must be UTF-8".into()))?; + let mut fields = vec![]; + for attr in Attributes::new(raw, 3) { + let attr = attr.map_err(|error| ParseError(format!("XML declaration: {error}")))?; + let key = String::from_utf8_lossy(attr.key.as_ref()).to_string(); + let value = attr + .unescape_value() + .map_err(|error| ParseError(format!("XML declaration: {error}")))? + .to_string(); + fields.push((key, value)); + } + + let valid = match fields.as_slice() { + [(version_key, version)] => version_key == "version" && version == "1.0", + [(version_key, version), (encoding_key, encoding)] => { + version_key == "version" + && version == "1.0" + && encoding_key == "encoding" + && encoding.eq_ignore_ascii_case("UTF-8") + } + _ => false, + }; + if !valid { + return err( + "XML declaration must be version=\"1.0\" with optional encoding=\"UTF-8\" only", + ); + } + Ok(()) +} + +fn collect_attributes(el: &BytesStart<'_>) -> Result, ParseError> { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + let mut attributes = BTreeMap::new(); + for attr in el.attributes() { + let attr = attr.map_err(|error| ParseError(format!("attr on <{tag}>: {error}")))?; + let key = String::from_utf8_lossy(attr.key.as_ref()).to_string(); + let value = attr + .unescape_value() + .map_err(|error| ParseError(format!("attr value on <{tag}>: {error}")))? + .to_string(); + if attributes.insert(key.clone(), value).is_some() { + return err(format!("duplicate `{key}` on <{tag}>")); + } + } + Ok(attributes) +} + +fn take_required( + attributes: &mut BTreeMap, + key: &str, + tag: &str, +) -> Result { + attributes + .remove(key) + .ok_or_else(|| ParseError(format!("<{tag}> requires `{key}`"))) +} + +fn reject_unknown_attribute( + attributes: &BTreeMap, + tag: &str, +) -> Result<(), ParseError> { + if let Some(key) = attributes.keys().next() { + return err(format!("unknown attribute `{key}` on <{tag}>")); + } + Ok(()) +} + +fn parse_color(s: &str, what: &str) -> Result { + Color::from_grida_hex(s).ok_or_else(|| ParseError(format!("{what} must be #RGB or #RRGGBB"))) +} + +fn parse_opacity(s: &str, what: &str) -> Result { + let opacity = parse_num(s, what)?; + if !(0.0..=1.0).contains(&opacity) { + return err(format!("{what} must be between 0 and 1 inclusive")); + } + Ok(opacity) +} + +fn parse_pair_f64(s: &str, what: &str) -> Result<(f64, f64), ParseError> { + let parts: Vec<_> = s.split_whitespace().collect(); + let [x, y] = parts.as_slice() else { + return err(format!("{what} requires exactly two numbers")); + }; + Ok((parse_num_f64(x, what)?, parse_num_f64(y, what)?)) +} + +fn parse_affine(s: &str, what: &str) -> Result { + let parts: Vec<_> = s.split_whitespace().collect(); + let [a, b, c, d, e, f] = parts.as_slice() else { + return err(format!("{what} requires exactly six numbers")); + }; + Ok(crate::math::Affine { + a: parse_num(a, what)?, + b: parse_num(b, what)?, + c: parse_num(c, what)?, + d: parse_num(d, what)?, + e: parse_num(e, what)?, + f: parse_num(f, what)?, + }) +} + +fn parse_blend_mode(s: &str) -> Result { + match s { + "normal" => Ok(BlendMode::Normal), + "multiply" => Ok(BlendMode::Multiply), + "screen" => Ok(BlendMode::Screen), + "overlay" => Ok(BlendMode::Overlay), + "darken" => Ok(BlendMode::Darken), + "lighten" => Ok(BlendMode::Lighten), + "color-dodge" => Ok(BlendMode::ColorDodge), + "color-burn" => Ok(BlendMode::ColorBurn), + "hard-light" => Ok(BlendMode::HardLight), + "soft-light" => Ok(BlendMode::SoftLight), + "difference" => Ok(BlendMode::Difference), + "exclusion" => Ok(BlendMode::Exclusion), + "hue" => Ok(BlendMode::Hue), + "saturation" => Ok(BlendMode::Saturation), + "color" => Ok(BlendMode::Color), + "luminosity" => Ok(BlendMode::Luminosity), + "pass-through" => err("pass-through is a layer mode, not a paint blend-mode"), + _ => err(format!("unknown paint blend-mode `{s}`")), + } +} + +fn parse_tile_mode(s: &str) -> Result { + match s { + "clamp" => Ok(TileMode::Clamp), + "repeated" => Ok(TileMode::Repeated), + "mirror" => Ok(TileMode::Mirror), + "decal" => Ok(TileMode::Decal), + _ => err(format!("bad tile-mode `{s}`")), + } +} + +fn parse_box_fit(s: &str) -> Result { + match s { + "contain" => Ok(BoxFit::Contain), + "cover" => Ok(BoxFit::Cover), + "fill" => Ok(BoxFit::Fill), + "none" => Ok(BoxFit::None), + _ => err(format!("bad image fit `{s}`")), + } +} + +#[derive(Debug, Clone, Copy)] +struct PaintCommon { + active: bool, + opacity: f32, + blend_mode: BlendMode, +} + +fn take_paint_common( + attributes: &mut BTreeMap, + tag: &str, +) -> Result { + let active = match attributes.remove("visible") { + Some(value) => parse_bool(&value, &format!("visible on <{tag}>"), true)?, + None => true, + }; + let opacity = match attributes.remove("opacity") { + Some(value) => parse_opacity(&value, &format!("opacity on <{tag}>"))?, + None => 1.0, + }; + let blend_mode = match attributes.remove("blend-mode") { + Some(value) => parse_blend_mode(&value)?, + None => BlendMode::Normal, + }; + Ok(PaintCommon { + active, + opacity, + blend_mode, + }) +} + +fn parse_solid(mut attributes: BTreeMap) -> Result { + let color = parse_color( + &take_required(&mut attributes, "color", "solid")?, + "solid color", + )?; + let common = take_paint_common(&mut attributes, "solid")?; + reject_unknown_attribute(&attributes, "solid")?; + Ok(Paint::Solid(SolidPaint { + active: common.active, + color: color.with_opacity(common.opacity), + blend_mode: common.blend_mode, + })) +} + +fn parse_image_paint(mut attributes: BTreeMap) -> Result { + let src = take_required(&mut attributes, "src", "image")?; + if src.trim().is_empty() { + return err("image src must not be empty"); + } + let fit = match attributes.remove("fit") { + Some(value) => parse_box_fit(&value)?, + None => BoxFit::Cover, + }; + let common = take_paint_common(&mut attributes, "image")?; + reject_unknown_attribute(&attributes, "image")?; + let mut image = ImagePaint::from_rid(src); + image.fit = ImagePaintFit::Fit(fit); + image.active = common.active; + image.opacity = common.opacity; + image.blend_mode = common.blend_mode; + Ok(Paint::Image(image)) +} + +fn parse_stop(mut attributes: BTreeMap) -> Result { + let offset = parse_num( + &take_required(&mut attributes, "offset", "stop")?, + "stop offset", + )?; + if !(0.0..=1.0).contains(&offset) { + return err("stop offset must be between 0 and 1 inclusive"); + } + let color = parse_color( + &take_required(&mut attributes, "color", "stop")?, + "stop color", + )?; + let opacity = match attributes.remove("opacity") { + Some(value) => parse_opacity(&value, "stop opacity")?, + None => 1.0, + }; + reject_unknown_attribute(&attributes, "stop")?; + Ok(GradientStop { + offset, + color: color.with_opacity(opacity), + }) +} + +fn parse_gradient_stops( + reader: &mut Reader<&[u8]>, + gradient_tag: &str, +) -> Result, ParseError> { + let mut stops: Vec = vec![]; + loop { + match reader.read_event() { + Err(error) => return err(format!("xml in <{gradient_tag}>: {error}")), + Ok(Event::Eof) => return err(format!("unclosed <{gradient_tag}>")), + Ok(Event::Empty(el)) => { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + if tag != "stop" { + return err(format!( + "<{gradient_tag}> may contain only empty elements, found <{tag}>" + )); + } + let stop = parse_stop(collect_attributes(&el)?)?; + if let Some(previous) = stops.last() { + if stop.offset < previous.offset { + return err(format!( + "gradient stop offsets must be nondecreasing ({} follows {})", + stop.offset, previous.offset + )); + } + } + stops.push(stop); + } + Ok(Event::Start(el)) => { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + if tag == "stop" { + return err(" must be empty; use "); + } + return err(format!( + "<{gradient_tag}> may contain only empty elements, found <{tag}>" + )); + } + Ok(Event::End(el)) => { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + if tag != gradient_tag { + return err(format!("mismatched end tag in <{gradient_tag}>")); + } + if stops.len() < 2 { + return err(format!("<{gradient_tag}> requires at least two stops")); + } + return Ok(stops); + } + Ok(Event::Text(text)) => { + let text = text + .unescape() + .map_err(|error| ParseError(format!("text in <{gradient_tag}>: {error}")))?; + if !text.trim().is_empty() { + return err(format!( + "character content is not allowed in <{gradient_tag}>" + )); + } + } + Ok(Event::Comment(_)) => {} + Ok(Event::CData(_) | Event::Decl(_) | Event::PI(_) | Event::DocType(_)) => { + return err(format!("unsupported XML event in <{gradient_tag}>")); + } + } + } +} + +fn parse_gradient( + mut attributes: BTreeMap, + stops: Vec, +) -> Result { + let kind = take_required(&mut attributes, "kind", "gradient")?; + let common = take_paint_common(&mut attributes, "gradient")?; + let transform = match attributes.remove("transform") { + Some(value) => parse_affine(&value, "transform on ")?, + None => crate::math::Affine::IDENTITY, + }; + + let paint = match kind.as_str() { + "linear" => { + let from = match attributes.remove("from") { + Some(value) => parse_pair_f64(&value, "from on ")?, + None => (0.0, 0.5), + }; + let to = match attributes.remove("to") { + Some(value) => parse_pair_f64(&value, "to on ")?, + None => (1.0, 0.5), + }; + let xy1 = Alignment::from_uv_f64(from.0, from.1); + let xy2 = Alignment::from_uv_f64(to.0, to.1); + if !xy1.0.is_finite() || !xy1.1.is_finite() || !xy2.0.is_finite() || !xy2.1.is_finite() + { + return err("linear gradient endpoints overflow after lowering to model alignment"); + } + if xy1 == xy2 { + return err( + "linear gradient from and to must differ after lowering to model alignment", + ); + } + let tile_mode = match attributes.remove("tile-mode") { + Some(value) => parse_tile_mode(&value)?, + None => TileMode::Clamp, + }; + Paint::LinearGradient(LinearGradientPaint { + active: common.active, + xy1, + xy2, + tile_mode, + transform, + stops, + opacity: common.opacity, + blend_mode: common.blend_mode, + }) + } + "radial" => { + let tile_mode = match attributes.remove("tile-mode") { + Some(value) => parse_tile_mode(&value)?, + None => TileMode::Clamp, + }; + Paint::RadialGradient(RadialGradientPaint { + active: common.active, + transform, + stops, + opacity: common.opacity, + blend_mode: common.blend_mode, + tile_mode, + }) + } + "sweep" => Paint::SweepGradient(SweepGradientPaint { + active: common.active, + transform, + stops, + opacity: common.opacity, + blend_mode: common.blend_mode, + }), + "diamond" => Paint::DiamondGradient(DiamondGradientPaint { + active: common.active, + transform, + stops, + opacity: common.opacity, + blend_mode: common.blend_mode, + }), + _ => { + return err(format!( + "gradient kind must be `linear`, `radial`, `sweep`, or `diamond`, found `{kind}`" + )); + } + }; + reject_unknown_attribute(&attributes, "gradient")?; + Ok(paint) +} + +fn is_typed_paint_tag(tag: &str) -> bool { + matches!(tag, "solid" | "gradient" | "image") +} + +fn is_legacy_gradient_tag(tag: &str) -> bool { + matches!( + tag, + "linear-gradient" | "radial-gradient" | "sweep-gradient" | "diamond-gradient" + ) +} + +fn parse_paint_channel( + reader: &mut Reader<&[u8]>, + channel_tag: &str, +) -> Result { + let mut paints = Paints::default(); + loop { + match reader.read_event() { + Err(error) => return err(format!("xml in <{channel_tag}>: {error}")), + Ok(Event::Eof) => return err(format!("unclosed <{channel_tag}>")), + Ok(Event::Empty(el)) => { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + let attributes = collect_attributes(&el)?; + let paint = match tag.as_str() { + "solid" => parse_solid(attributes)?, + "image" => parse_image_paint(attributes)?, + "gradient" => { + return err(" requires at least two stops"); + } + "fill" | "stroke" => { + return err(format!("nested <{tag}> is not allowed")); + } + _ if is_legacy_gradient_tag(&tag) => { + return err("kind-specific gradient tags are not Draft 0; use "); + } + _ => { + return err(format!("unknown paint element <{tag}> in <{channel_tag}>")); + } + }; + paints.push(paint); + } + Ok(Event::Start(el)) => { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + let attributes = collect_attributes(&el)?; + let paint = match tag.as_str() { + "gradient" => { + let stops = parse_gradient_stops(reader, "gradient")?; + parse_gradient(attributes, stops)? + } + "solid" | "image" => { + return err(format!("<{tag}> must be empty; use <{tag} .../>")); + } + "fill" | "stroke" => { + return err(format!("nested <{tag}> is not allowed")); + } + _ if is_legacy_gradient_tag(&tag) => { + return err("kind-specific gradient tags are not Draft 0; use "); + } + _ => { + return err(format!("unknown paint element <{tag}> in <{channel_tag}>")); + } + }; + paints.push(paint); + } + Ok(Event::End(el)) => { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + if tag != channel_tag { + return err(format!("mismatched end tag in <{channel_tag}>")); + } + return Ok(paints); + } + Ok(Event::Text(text)) => { + let text = text + .unescape() + .map_err(|error| ParseError(format!("text in <{channel_tag}>: {error}")))?; + if !text.trim().is_empty() { + return err(format!( + "character content is not allowed in <{channel_tag}>" + )); + } + } + Ok(Event::Comment(_)) => {} + Ok(Event::CData(_) | Event::Decl(_) | Event::PI(_) | Event::DocType(_)) => { + return err(format!("unsupported XML event in <{channel_tag}>")); + } + } + } +} + +fn grida_xml_default_fills(payload: &Payload) -> Paints { + match payload { + Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse, + } + | Payload::Text { .. } => Paints::solid(Color::BLACK), + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Line, + } + | Payload::Group + | Payload::Lens { .. } => Paints::default(), + } +} + +fn parse_stroke_align(value: &str) -> Result { + match value { + "inside" => Ok(StrokeAlign::Inside), + "center" => Ok(StrokeAlign::Center), + "outside" => Ok(StrokeAlign::Outside), + _ => err(format!( + "stroke align must be `inside`, `center`, or `outside`, found `{value}`" + )), + } +} + +fn parse_stroke_cap(value: &str) -> Result { + match value { + "butt" => Ok(StrokeCap::Butt), + "round" => Ok(StrokeCap::Round), + "square" => Ok(StrokeCap::Square), + _ => err(format!( + "stroke cap must be `butt`, `round`, or `square`, found `{value}`" + )), + } +} + +fn parse_stroke_join(value: &str) -> Result { + match value { + "miter" => Ok(StrokeJoin::Miter), + "round" => Ok(StrokeJoin::Round), + "bevel" => Ok(StrokeJoin::Bevel), + _ => err(format!( + "stroke join must be `miter`, `round`, or `bevel`, found `{value}`" + )), + } +} + +fn parse_dash_array(value: &str) -> Result, ParseError> { + let mut values: Vec = value + .split_whitespace() + .map(|part| parse_non_negative(part, "dash-array", true)) + .collect::>()?; + if values.is_empty() { + return err("dash-array requires at least one non-negative number"); + } + if values.iter().all(|value| *value == 0.0) { + return err("dash-array must not be all zero"); + } + if values.len() % 2 == 1 { + let repeated = values.clone(); + values.extend(repeated); + } + Ok(values) +} + +fn parse_stroke( + mut attributes: BTreeMap, + payload: &Payload, + paints: Paints, +) -> Result { + let mut stroke = Stroke::default_for(payload).ok_or_else(|| { + ParseError(format!( + " is not valid on <{}>", + payload.kind_name() + )) + })?; + stroke.paints = paints; + + if let Some(value) = attributes.remove("width") { + stroke.width = parse_non_negative(&value, "stroke width", true)?; + } + if let Some(value) = attributes.remove("align") { + stroke.align = parse_stroke_align(&value)?; + } + + let is_line = matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ); + let is_rect = matches!( + payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect + } + ); + let supports_dash = matches!( + payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse | ShapeDesc::Line + } + ); + + if let Some(value) = attributes.remove("cap") { + if !is_line { + return err("stroke attribute `cap` is valid only on "); + } + stroke.cap = parse_stroke_cap(&value)?; + } + if let Some(value) = attributes.remove("join") { + if !is_rect { + return err("stroke attribute `join` is valid only on and "); + } + stroke.join = parse_stroke_join(&value)?; + } + if let Some(value) = attributes.remove("miter-limit") { + if !is_rect { + return err("stroke attribute `miter-limit` is valid only on and "); + } + stroke.miter_limit = parse_positive(&value, "stroke miter-limit", true)?; + } + if let Some(value) = attributes.remove("dash-array") { + if !supports_dash { + return err("stroke attribute `dash-array` is not valid on "); + } + stroke.dash_array = Some(parse_dash_array(&value)?); + } + if is_line && stroke.align != StrokeAlign::Center { + return err("a stroke must use align=\"center\""); + } + reject_unknown_attribute(&attributes, "stroke")?; + Ok(stroke) +} + struct Pending { id: NodeId, + tag: String, text_content: String, is_text: bool, + allows_children: bool, + fill_seen: bool, + stroke_seen: bool, + legacy_fill_seen: bool, + content_started: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Syntax { + TextIr, + GridaXml, } pub fn parse(input: &str) -> Result { + parse_with_syntax(input, Syntax::TextIr) +} + +/// Shared parser core for the first-class `.grida.xml` surface. Kept +/// crate-private so the historical [`parse`] contract remains exactly the +/// experiment grammar while `grida_xml` owns its public error vocabulary. +pub(crate) fn parse_grida_xml(input: &str) -> Result { + parse_with_syntax(input, Syntax::GridaXml) +} + +fn parse_with_syntax(input: &str, syntax: Syntax) -> Result { let mut reader = Reader::from_str(input); - reader.config_mut().trim_text(true); + // Draft 0 preserves authored whitespace inside . Historical TextIr + // keeps its experiment-era trimming behavior for compatibility. + reader.config_mut().trim_text(syntax == Syntax::TextIr); let mut nodes: BTreeMap = BTreeMap::new(); let mut stack: Vec = vec![]; let mut next_id: NodeId = 0; let mut root: Option = None; + let mut render_root: Option = None; + let mut envelope_open = false; + let mut envelope_closed = false; + let mut declaration_seen = false; + let mut pre_declaration_content = false; + + if syntax == Syntax::GridaXml { + // `.grida.xml`'s envelope is structural, while the model's root is + // the canonical viewport-spanning frame. The one authored render + // root is attached beneath it rather than replacing it. + let root_doc = DocBuilder::new().build(); + let root_id = root_doc.root; + nodes.insert(root_id, root_doc.get(root_id).clone()); + next_id = 1; + root = Some(root_id); + } loop { match reader.read_event() { @@ -136,13 +862,204 @@ pub fn parse(input: &str) -> Result { _ => unreachable!(), }; let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + + if syntax == Syntax::GridaXml && tag == "grida" { + if is_empty { + return err(" must contain exactly one render root"); + } + if envelope_open || envelope_closed || !stack.is_empty() { + return err(" must be the single document envelope"); + } + let mut version: Option = None; + for attr in el.attributes() { + let attr = attr.map_err(|e| ParseError(format!("attr: {e}")))?; + let key = String::from_utf8_lossy(attr.key.as_ref()).to_string(); + let val = attr + .unescape_value() + .map_err(|e| ParseError(format!("attr value: {e}")))? + .to_string(); + if key != "version" { + return err(format!("unknown attribute `{key}` on ")); + } + if version.replace(val).is_some() { + return err("duplicate `version` on "); + } + } + match version.as_deref() { + Some("0") => {} + Some(v) => return err(format!("unsupported version `{v}`")), + None => return err(" requires version=\"0\""), + } + envelope_open = true; + continue; + } + + if syntax == Syntax::GridaXml && (!envelope_open || envelope_closed) { + return err(format!("<{tag}> must be inside ")); + } + if syntax == Syntax::GridaXml && matches!(tag.as_str(), "fill" | "stroke") { + let attributes = collect_attributes(&el)?; + let parent = stack.last().ok_or_else(|| { + ParseError(format!( + "<{tag}> must be a direct child of a paintable node" + )) + })?; + if parent.content_started { + return err(format!( + "<{tag}> must appear before content or scene children in <{}>", + parent.tag + )); + } + let parent_id = parent.id; + let parent_tag = parent.tag.clone(); + let payload = nodes.get(&parent_id).unwrap().payload.clone(); + + // Whitespace before or between leading text properties is + // formatting, not character content. The same buffer is + // retained when no later property appears, preserving all + // authored text after the final property. + if parent.is_text { + debug_assert!(parent.text_content.trim().is_empty()); + stack.last_mut().unwrap().text_content.clear(); + } + + match tag.as_str() { + "fill" => { + let parent = stack.last().expect("parent remains open"); + if parent.fill_seen { + return err(format!("duplicate on <{}>", parent.tag)); + } + if parent.stroke_seen { + return err(format!( + " must precede elements in <{}>", + parent.tag + )); + } + if parent.legacy_fill_seen { + return err(format!( + "<{}> cannot use both the `fill` attribute and ", + parent.tag + )); + } + if matches!( + payload, + Payload::Group + | Payload::Lens { .. } + | Payload::Shape { + desc: ShapeDesc::Line + } + ) { + return err(format!(" is not valid on <{parent_tag}>")); + } + reject_unknown_attribute(&attributes, "fill")?; + let paints = if is_empty { + Paints::default() + } else { + parse_paint_channel(&mut reader, "fill")? + }; + nodes.get_mut(&parent_id).unwrap().fills = paints; + stack.last_mut().unwrap().fill_seen = true; + } + "stroke" => { + if matches!(payload, Payload::Group | Payload::Lens { .. }) { + return err(format!(" is not valid on <{parent_tag}>")); + } + let paints = if is_empty { + Paints::default() + } else { + parse_paint_channel(&mut reader, "stroke")? + }; + let stroke = parse_stroke(attributes, &payload, paints)?; + if stroke.paints.is_empty() && stroke.geometry_is_default_for(&payload) + { + return err(format!( + "default empty (empty stroke) on <{parent_tag}> is indistinguishable from omission" + )); + } + nodes.get_mut(&parent_id).unwrap().strokes.push(stroke); + stack.last_mut().unwrap().stroke_seen = true; + } + _ => unreachable!(), + } + continue; + } + if syntax == Syntax::GridaXml && matches!(tag.as_str(), "fills" | "strokes") { + return err(format!( + "plural <{tag}> is not Draft 0; use singular <{}>", + if tag == "fills" { "fill" } else { "stroke" } + )); + } + if syntax == Syntax::GridaXml && is_legacy_gradient_tag(&tag) { + return err( + "kind-specific gradient tags are not Draft 0; use ", + ); + } + if syntax == Syntax::GridaXml && is_typed_paint_tag(&tag) { + if tag == "image" { + return err( + "scene is not supported in Draft 0; place directly inside or for an image paint", + ); + } + return err(format!( + "<{tag}> is a paint and must be a direct child of or " + )); + } + if syntax == Syntax::GridaXml && tag == "stop" { + return err(" must be a direct child of a gradient paint"); + } + if syntax == Syntax::GridaXml && tag == "frame" { + return err(" belongs to historical textir; use "); + } + if syntax == Syntax::GridaXml && tag == "shape" { + return err(" is reserved in Draft 0; use , , or "); + } + let is_authored_root = + syntax == Syntax::GridaXml && stack.is_empty() && render_root.is_none(); + if is_authored_root && tag != "container" { + return err(format!( + "the authored render root must be , found <{tag}>" + )); + } + if stack.last().is_some_and(|parent| !parent.allows_children) { + let parent = stack.last().expect("checked above"); + return err(format!( + "<{}> cannot contain child elements; use for local composition", + parent.tag + )); + } + if let Some(parent) = stack.last_mut() { + parent.content_started = true; + } + + let (node_tag, direct_shape_kind) = match (syntax, tag.as_str()) { + (Syntax::GridaXml, "container") => ("frame", None), + (Syntax::GridaXml, "rect") => ("shape", Some(ShapeDesc::Rect)), + (Syntax::GridaXml, "ellipse") => ("shape", Some(ShapeDesc::Ellipse)), + (Syntax::GridaXml, "line") => ("shape", Some(ShapeDesc::Line)), + _ => (tag.as_str(), None), + }; let mut header = Header::new(SizeIntent::Auto, SizeIntent::Auto); let mut layout = LayoutBehavior::default(); - let mut shape_kind: Option = None; + let mut shape_kind: Option = direct_shape_kind; let mut font_size = 16.0f32; let mut lens_ops: Vec = vec![]; let mut clips = false; - let mut fill: Option = None; + let mut legacy_fill: Option = None; + let mut width_seen = false; + let mut height_seen = false; + let mut x_seen = false; + let mut y_seen = false; + let mut flow_seen = false; + let mut grow_seen = false; + let mut align_seen = false; + let mut box_constraint_seen = false; + let mut aspect_seen = false; + let mut frame_only_attr: Option = None; + let mut flex_only_attr: Option = None; + let mut shape_only_attr: Option = None; + let mut text_only_attr: Option = None; + let mut lens_only_attr: Option = None; + let strict = syntax == Syntax::GridaXml; for attr in el.attributes() { let attr = attr.map_err(|e| ParseError(format!("attr: {e}")))?; @@ -153,33 +1070,120 @@ pub fn parse(input: &str) -> Result { .to_string(); match key.as_str() { "name" => header.name = Some(val), - "x" => header.x = parse_binding(&val, "x")?, - "y" => header.y = parse_binding(&val, "y")?, - "w" => header.width = parse_size(&val, "w")?, - "h" => header.height = parse_size(&val, "h")?, - "min-w" => header.min_width = Some(parse_num(&val, "min-w")?), - "max-w" => header.max_width = Some(parse_num(&val, "max-w")?), - "min-h" => header.min_height = Some(parse_num(&val, "min-h")?), - "max-h" => header.max_height = Some(parse_num(&val, "max-h")?), - "aspect" => { + "x" => { + if is_authored_root { + return err("the authored root cannot declare `x`"); + } + x_seen = true; + header.x = parse_binding(&val, "x")?; + } + "y" => { + if is_authored_root { + return err("the authored root cannot declare `y`"); + } + y_seen = true; + header.y = parse_binding(&val, "y")?; + } + "w" if syntax == Syntax::TextIr => { + if width_seen { + return err(format!("duplicate width attribute on <{tag}>")); + } + width_seen = true; + header.width = parse_size(&val, "width", false)?; + } + "width" if syntax == Syntax::GridaXml => { + if width_seen { + return err(format!("duplicate width attribute on <{tag}>")); + } + width_seen = true; + header.width = parse_size(&val, "width", true)?; + } + "h" if syntax == Syntax::TextIr => { + if height_seen { + return err(format!("duplicate height attribute on <{tag}>")); + } + height_seen = true; + header.height = parse_size(&val, "height", false)?; + } + "height" if syntax == Syntax::GridaXml => { + if height_seen { + return err(format!("duplicate height attribute on <{tag}>")); + } + height_seen = true; + header.height = parse_size(&val, "height", true)?; + } + "min-w" if syntax == Syntax::TextIr => { + header.min_width = Some(parse_non_negative(&val, "min-w", false)?) + } + "min-width" if syntax == Syntax::GridaXml => { + box_constraint_seen = true; + header.min_width = Some(parse_non_negative(&val, "min-width", true)?) + } + "max-w" if syntax == Syntax::TextIr => { + header.max_width = Some(parse_non_negative(&val, "max-w", false)?) + } + "max-width" if syntax == Syntax::GridaXml => { + box_constraint_seen = true; + header.max_width = Some(parse_non_negative(&val, "max-width", true)?) + } + "min-h" if syntax == Syntax::TextIr => { + header.min_height = Some(parse_non_negative(&val, "min-h", false)?) + } + "min-height" if syntax == Syntax::GridaXml => { + box_constraint_seen = true; + header.min_height = Some(parse_non_negative(&val, "min-height", true)?) + } + "max-h" if syntax == Syntax::TextIr => { + header.max_height = Some(parse_non_negative(&val, "max-h", false)?) + } + "max-height" if syntax == Syntax::GridaXml => { + box_constraint_seen = true; + header.max_height = Some(parse_non_negative(&val, "max-height", true)?) + } + "aspect" if syntax == Syntax::TextIr => { let (a, b) = val .split_once(':') .ok_or_else(|| ParseError("aspect needs `w:h`".into()))?; header.aspect_ratio = Some((parse_num(a, "aspect")?, parse_num(b, "aspect")?)); } + "aspect-ratio" if syntax == Syntax::GridaXml => { + box_constraint_seen = true; + aspect_seen = true; + let (a, b) = val + .split_once(':') + .ok_or_else(|| ParseError("aspect-ratio needs `w:h`".into()))?; + header.aspect_ratio = Some(( + parse_positive(a, "aspect-ratio", true)?, + parse_positive(b, "aspect-ratio", true)?, + )); + } "rotation" => header.rotation = parse_num(&val, "rotation")?, - "flip-x" => header.flip_x = val == "true", - "flip-y" => header.flip_y = val == "true", + "flip-x" => header.flip_x = parse_bool(&val, "flip-x", strict)?, + "flip-y" => header.flip_y = parse_bool(&val, "flip-y", strict)?, "flow" => { + if is_authored_root { + return err("the authored root cannot declare `flow`"); + } + flow_seen = true; header.flow = match val.as_str() { "absolute" => Flow::Absolute, "in" => Flow::InFlow, _ => return err(format!("bad flow `{val}`")), } } - "grow" => header.grow = parse_num(&val, "grow")?, + "grow" => { + if is_authored_root { + return err("the authored root cannot declare `grow`"); + } + grow_seen = true; + header.grow = parse_non_negative(&val, "grow", strict)?; + } "align" => { + if is_authored_root { + return err("the authored root cannot declare `align`"); + } + align_seen = true; header.self_align = match val.as_str() { "start" => SelfAlign::Start, "center" => SelfAlign::Center, @@ -188,9 +1192,16 @@ pub fn parse(input: &str) -> Result { _ => return err(format!("bad align `{val}`")), } } - "opacity" => header.opacity = parse_num(&val, "opacity")?, - "hidden" => header.active = val != "true", + "opacity" => { + let opacity = parse_num(&val, "opacity")?; + if strict && !(0.0..=1.0).contains(&opacity) { + return err("opacity must be between 0 and 1 inclusive"); + } + header.opacity = opacity; + } + "hidden" => header.active = !parse_bool(&val, "hidden", strict)?, "layout" => { + frame_only_attr.get_or_insert_with(|| key.clone()); layout.mode = match val.as_str() { "flex" => LayoutMode::Flex, "none" => LayoutMode::None, @@ -198,14 +1209,22 @@ pub fn parse(input: &str) -> Result { } } "direction" => { + frame_only_attr.get_or_insert_with(|| key.clone()); + flex_only_attr.get_or_insert_with(|| key.clone()); layout.direction = match val.as_str() { "row" => Direction::Row, "column" => Direction::Column, _ => return err(format!("bad direction `{val}`")), } } - "wrap" => layout.wrap = val == "true", + "wrap" => { + frame_only_attr.get_or_insert_with(|| key.clone()); + flex_only_attr.get_or_insert_with(|| key.clone()); + layout.wrap = parse_bool(&val, "wrap", strict)?; + } "main" => { + frame_only_attr.get_or_insert_with(|| key.clone()); + flex_only_attr.get_or_insert_with(|| key.clone()); layout.main_align = match val.as_str() { "start" => MainAlign::Start, "center" => MainAlign::Center, @@ -217,6 +1236,8 @@ pub fn parse(input: &str) -> Result { } } "cross" => { + frame_only_attr.get_or_insert_with(|| key.clone()); + flex_only_attr.get_or_insert_with(|| key.clone()); layout.cross_align = match val.as_str() { "start" => CrossAlign::Start, "center" => CrossAlign::Center, @@ -226,23 +1247,26 @@ pub fn parse(input: &str) -> Result { } } "gap" => { + frame_only_attr.get_or_insert_with(|| key.clone()); + flex_only_attr.get_or_insert_with(|| key.clone()); let parts: Vec<&str> = val.split_whitespace().collect(); match parts.as_slice() { [g] => { - layout.gap_main = parse_num(g, "gap")?; + layout.gap_main = parse_non_negative(g, "gap", strict)?; layout.gap_cross = layout.gap_main; } [m, c] => { - layout.gap_main = parse_num(m, "gap")?; - layout.gap_cross = parse_num(c, "gap")?; + layout.gap_main = parse_non_negative(m, "gap", strict)?; + layout.gap_cross = parse_non_negative(c, "gap", strict)?; } _ => return err("gap takes 1 or 2 numbers"), } } "padding" => { + frame_only_attr.get_or_insert_with(|| key.clone()); let nums: Vec = val .split_whitespace() - .map(|p| parse_num(p, "padding")) + .map(|p| parse_non_negative(p, "padding", strict)) .collect::>()?; layout.padding = match nums.as_slice() { [a] => EdgeInsets::all(*a), @@ -255,8 +1279,12 @@ pub fn parse(input: &str) -> Result { _ => return err("padding takes 1 or 4 numbers"), }; } - "clips" => clips = val == "true", - "kind" => { + "clips" => { + frame_only_attr.get_or_insert_with(|| key.clone()); + clips = parse_bool(&val, "clips", strict)?; + } + "kind" if syntax == Syntax::TextIr => { + shape_only_attr.get_or_insert_with(|| key.clone()); shape_kind = Some(match val.as_str() { "rect" => ShapeDesc::Rect, "ellipse" => ShapeDesc::Ellipse, @@ -264,14 +1292,157 @@ pub fn parse(input: &str) -> Result { _ => return err(format!("bad shape kind `{val}`")), }) } - "size" => font_size = parse_num(&val, "size")?, - "ops" => lens_ops = parse_lens_ops(&val)?, - "fill" => fill = Some(val.into()), + "size" => { + text_only_attr.get_or_insert_with(|| key.clone()); + font_size = parse_positive(&val, "size", strict)?; + } + "ops" => { + lens_only_attr.get_or_insert_with(|| key.clone()); + lens_ops = parse_lens_ops(&val, strict)?; + } + "fill" => { + let fillable = matches!(node_tag, "frame" | "text") + || matches!(shape_kind, Some(ShapeDesc::Rect | ShapeDesc::Ellipse)); + if strict && !fillable { + return err(format!("fill is not valid on <{tag}>")); + } + if legacy_fill.is_some() { + return err(format!("duplicate `fill` on <{tag}>")); + } + if strict { + legacy_fill = Some(parse_color(&val, "fill")?); + } else { + legacy_fill = Some(val.into()); + } + } _ => return err(format!("unknown attribute `{key}` on <{tag}>")), } } - let payload = match tag.as_str() { + if syntax == Syntax::GridaXml { + let parent_is_flex = stack.last().is_some_and(|parent| { + matches!( + &nodes.get(&parent.id).expect("open parent exists").payload, + Payload::Frame { layout, .. } if layout.mode == LayoutMode::Flex + ) + }); + if flow_seen && !parent_is_flex { + return err("flow is only valid on a child of a flex container"); + } + if parent_is_flex && header.flow == Flow::InFlow && (x_seen || y_seen) { + return err("x/y are not valid on an in-flow child of a flex container"); + } + if (!parent_is_flex || header.flow != Flow::InFlow) && (grow_seen || align_seen) + { + return err( + "grow/align are only valid on an in-flow child of a flex container", + ); + } + if node_tag != "frame" { + if let Some(attr) = frame_only_attr { + return err(format!("attribute `{attr}` is only valid on ")); + } + } + if node_tag != "shape" { + if let Some(attr) = shape_only_attr { + return err(format!("attribute `{attr}` is only valid on ")); + } + } + if node_tag != "text" { + if let Some(attr) = text_only_attr { + return err(format!("attribute `{attr}` is only valid on ")); + } + } + if node_tag != "lens" { + if let Some(attr) = lens_only_attr { + return err(format!("attribute `{attr}` is only valid on ")); + } + } + if layout.mode != LayoutMode::Flex { + if let Some(attr) = flex_only_attr { + return err(format!("attribute `{attr}` requires layout=\"flex\"")); + } + } + if matches!(node_tag, "group" | "lens") + && (width_seen || height_seen || box_constraint_seen) + { + return err(format!( + "<{tag}> has a derived box and cannot declare size constraints" + )); + } + if matches!(node_tag, "group" | "lens") + && (matches!(header.x, AxisBinding::Span { .. }) + || matches!(header.y, AxisBinding::Span { .. })) + { + return err(format!( + "<{tag}> has a derived origin and cannot use Span bindings" + )); + } + if aspect_seen && node_tag != "shape" { + return err("aspect-ratio is only valid on and "); + } + if matches!(header.x, AxisBinding::Span { .. }) + && (width_seen || header.min_width.is_some() || header.max_width.is_some()) + { + return err( + "a span x binding cannot also declare width/min-width/max-width", + ); + } + if matches!(header.y, AxisBinding::Span { .. }) + && (height_seen + || header.min_height.is_some() + || header.max_height.is_some()) + { + return err( + "a span y binding cannot also declare height/min-height/max-height", + ); + } + if node_tag == "shape" { + match shape_kind { + Some(ShapeDesc::Rect | ShapeDesc::Ellipse) => { + let width_supplied = matches!( + (header.width, header.x), + (SizeIntent::Fixed(_), _) | (_, AxisBinding::Span { .. }) + ); + let height_supplied = matches!( + (header.height, header.y), + (SizeIntent::Fixed(_), _) | (_, AxisBinding::Span { .. }) + ); + let valid = matches!( + (width_supplied, height_supplied, aspect_seen), + (true, true, false) | (true, false, true) | (false, true, true) + ); + if !valid { + return err(" and require both axes supplied by a fixed size or Span, or exactly one supplied axis plus aspect-ratio"); + } + } + Some(ShapeDesc::Line) => { + let width_supplied = matches!( + (header.width, header.x), + (SizeIntent::Fixed(_), _) | (_, AxisBinding::Span { .. }) + ); + if !width_supplied { + return err(" requires a fixed width or x Span"); + } + if matches!(header.y, AxisBinding::Span { .. }) { + return err(" must not declare a y Span"); + } + if height_seen { + return err(" must not declare height"); + } + if header.min_height.is_some() || header.max_height.is_some() { + return err(" must not declare min-height/max-height"); + } + if header.aspect_ratio.is_some() { + return err(" must not declare aspect-ratio"); + } + } + None => {} + } + } + } + + let payload = match node_tag { "frame" => { // Kind defaults (§4): frame sizes are Fixed-required; // the IR treats missing w/h as auto (hug) which is @@ -300,42 +1471,110 @@ pub fn parse(input: &str) -> Result { let id = next_id; next_id += 1; + let legacy_fill_seen = legacy_fill.is_some(); + let fills = match legacy_fill { + Some(color) => Paints::solid(color), + None if syntax == Syntax::GridaXml => grida_xml_default_fills(&payload), + None => Paints::default(), + }; let node = Node { id, header, payload, children: vec![], - fill, + fills, + strokes: vec![], }; nodes.insert(id, node); if let Some(parent) = stack.last() { let pid = parent.id; nodes.get_mut(&pid).unwrap().children.push(id); + } else if syntax == Syntax::GridaXml { + if render_root.is_some() { + return err("multiple render roots in "); + } + let root_id = root.expect("grida.xml root initialized"); + nodes.get_mut(&root_id).unwrap().children.push(id); + render_root = Some(id); } else if root.is_none() { root = Some(id); } else { return err("multiple root elements"); } - let is_text = tag == "text"; + let is_text = node_tag == "text"; + let allows_children = matches!(node_tag, "frame" | "shape" | "group" | "lens"); stack.push(Pending { id, + tag, text_content: String::new(), is_text, + allows_children, + fill_seen: false, + stroke_seen: false, + legacy_fill_seen, + content_started: false, }); // Self-closing (`Event::Empty`) has no matching End event. if is_empty { - finish(&mut stack, &mut nodes)?; + finish(&mut stack, &mut nodes, None)?; } } Ok(Event::Text(t)) => { + let txt = t.unescape().map_err(|e| ParseError(format!("text: {e}")))?; + if syntax == Syntax::GridaXml && !declaration_seen && !txt.is_empty() { + pre_declaration_content = true; + } if let Some(p) = stack.last_mut() { - let txt = t.unescape().map_err(|e| ParseError(format!("text: {e}")))?; + if syntax == Syntax::GridaXml && !p.is_text && !txt.trim().is_empty() { + return err(format!("character content is not allowed in <{}>", p.tag)); + } + if p.is_text && !txt.trim().is_empty() { + p.content_started = true; + } p.text_content.push_str(&txt); + } else if syntax == Syntax::GridaXml && !txt.trim().is_empty() { + return err("character content is not allowed outside the document envelope"); + } + } + Ok(Event::End(e)) => { + let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + if syntax == Syntax::GridaXml && tag == "grida" { + if !envelope_open || envelope_closed { + return err("unbalanced "); + } + if !stack.is_empty() { + return err(" closed before its render root"); + } + if render_root.is_none() { + return err(" must contain exactly one render root"); + } + envelope_open = false; + envelope_closed = true; + continue; } + finish(&mut stack, &mut nodes, Some(&tag))?; } - Ok(Event::End(_)) => { - finish(&mut stack, &mut nodes)?; + Ok(Event::Decl(decl)) if syntax == Syntax::GridaXml => { + if declaration_seen { + return err("duplicate XML declaration"); + } + if pre_declaration_content || envelope_open || envelope_closed { + return err("XML declaration must be the first document event"); + } + validate_grida_xml_declaration(&decl)?; + declaration_seen = true; + } + Ok(Event::Comment(_)) if syntax == Syntax::GridaXml => { + if !declaration_seen { + pre_declaration_content = true; + } + } + Ok(Event::CData(_)) if syntax == Syntax::GridaXml => { + return err("CDATA is not supported; use escaped text content"); + } + Ok(Event::PI(_) | Event::DocType(_)) if syntax == Syntax::GridaXml => { + return err("processing instructions and doctypes are not supported"); } Ok(_) => {} } @@ -344,12 +1583,37 @@ pub fn parse(input: &str) -> Result { if !stack.is_empty() { return err("unclosed elements"); } + if syntax == Syntax::GridaXml { + if envelope_open { + return err("unclosed envelope"); + } + if !envelope_closed { + return err("missing envelope"); + } + if render_root.is_none() { + return err(" must contain exactly one render root"); + } + } let root = root.ok_or_else(|| ParseError("empty document".into()))?; // The scene root spans the viewport unless bindings were given. Ok(Document::from_map(nodes, root)) } -fn finish(stack: &mut Vec, nodes: &mut BTreeMap) -> Result<(), ParseError> { +fn finish( + stack: &mut Vec, + nodes: &mut BTreeMap, + end_tag: Option<&str>, +) -> Result<(), ParseError> { + if let Some(end_tag) = end_tag { + let start_tag = stack + .last() + .ok_or_else(|| ParseError("unbalanced end".into()))? + .tag + .as_str(); + if start_tag != end_tag { + return err(format!("mismatched end tag for <{start_tag}>")); + } + } let p = stack .pop() .ok_or_else(|| ParseError("unbalanced end".into()))?; @@ -373,6 +1637,10 @@ fn fmt_num(v: f32) -> String { } } +fn fmt_num_f64(v: f64) -> String { + format!("{v}") +} + fn fmt_binding(b: AxisBinding) -> Option { match b { AxisBinding::Pin { @@ -404,19 +1672,618 @@ fn fmt_binding(b: AxisBinding) -> Option { } fn push_attr(out: &mut String, key: &str, val: &str) { - let _ = write!(out, " {key}=\"{val}\""); + let escaped = val + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">"); + let _ = write!(out, " {key}=\"{escaped}\""); } -pub fn print(doc: &Document) -> String { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Vocabulary { + TextIr, + GridaXml, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrintError(pub String); + +impl std::fmt::Display for PrintError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "textir: {}", self.0) + } +} + +impl std::error::Error for PrintError {} + +/// Historical E3 TextIr has only a singleton opaque solid `fill` attribute. +/// This fallible entry point refuses richer model state instead of narrowing +/// it. New file-first documents should use [`crate::grida_xml::print`]. +pub fn try_print(doc: &Document) -> Result { let mut out = String::new(); - print_node(doc, doc.root, 0, &mut out); - out + print_node(doc, doc.root, 0, Vocabulary::TextIr, &mut out).map_err(PrintError)?; + Ok(out) +} + +pub fn print(doc: &Document) -> String { + try_print(doc).expect("document is not representable by historical E3 TextIr") +} + +pub(crate) fn print_grida_xml_render_root( + doc: &Document, + id: NodeId, + depth: usize, + out: &mut String, +) -> Result<(), String> { + print_node(doc, id, depth, Vocabulary::GridaXml, out) +} + +#[derive(Debug, Clone, Copy)] +enum FillEmission<'a> { + Omit, + Attribute(Color), + Empty, + Stack(&'a Paints), +} + +fn grida_xml_fill_emission(node: &Node) -> Result, String> { + if matches!( + node.payload, + Payload::Group + | Payload::Lens { .. } + | Payload::Shape { + desc: ShapeDesc::Line + } + ) && !node.fills.is_empty() + { + return Err(format!("<{}> cannot carry fills", node.payload.kind_name())); + } + let default = grida_xml_default_fills(&node.payload); + if node.fills == default { + Ok(FillEmission::Omit) + } else if let [Paint::Solid(solid)] = node.fills.as_slice() { + if solid.active && solid.color.alpha() == 255 && solid.blend_mode == BlendMode::Normal { + Ok(FillEmission::Attribute(solid.color)) + } else { + Ok(FillEmission::Stack(&node.fills)) + } + } else if node.fills.is_empty() { + Ok(FillEmission::Empty) + } else { + Ok(FillEmission::Stack(&node.fills)) + } +} + +fn historical_fill(node: &Node) -> Result, String> { + if node + .strokes + .iter() + .any(|stroke| !(stroke.paints.is_empty() && stroke.geometry_is_default_for(&node.payload))) + { + return Err(format!( + "node {} has strokes historical E3 TextIr cannot represent", + node.id + )); + } + match node.fills.as_slice() { + [] => Ok(None), + [Paint::Solid(solid)] + if solid.active + && solid.blend_mode == BlendMode::Normal + && solid.color.alpha() == 255 => + { + Ok(Some(solid.color)) + } + _ => Err(format!( + "node {} has a paint stack historical E3 TextIr cannot represent", + node.id + )), + } +} + +fn validate_common_paint(opacity: f32, tag: &str) -> Result<(), String> { + if !opacity.is_finite() || !(0.0..=1.0).contains(&opacity) { + return Err(format!( + "<{tag}> opacity must be finite and between 0 and 1" + )); + } + Ok(()) +} + +fn push_common_paint_attrs( + out: &mut String, + active: bool, + opacity: f32, + blend_mode: BlendMode, + tag: &str, +) -> Result<(), String> { + validate_common_paint(opacity, tag)?; + if !active { + push_attr(out, "visible", "false"); + } + if opacity != 1.0 { + push_attr(out, "opacity", &fmt_num(opacity)); + } + if blend_mode != BlendMode::Normal { + push_attr(out, "blend-mode", blend_mode.as_str()); + } + Ok(()) +} + +fn validate_gradient_stops(stops: &[GradientStop], tag: &str) -> Result<(), String> { + if stops.len() < 2 { + return Err(format!("<{tag}> requires at least two stops")); + } + let mut previous = None; + for stop in stops { + if !stop.offset.is_finite() || !(0.0..=1.0).contains(&stop.offset) { + return Err(format!( + "<{tag}> stop offset must be finite and between 0 and 1" + )); + } + if previous.is_some_and(|offset| stop.offset < offset) { + return Err(format!("<{tag}> stop offsets must be nondecreasing")); + } + previous = Some(stop.offset); + } + Ok(()) } -fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { +fn validate_affine(transform: crate::math::Affine, tag: &str) -> Result<(), String> { + if [ + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ] + .iter() + .any(|value| !value.is_finite()) + { + return Err(format!( + "<{tag}> transform must contain only finite numbers" + )); + } + Ok(()) +} + +fn push_transform_attr( + out: &mut String, + transform: crate::math::Affine, + tag: &str, +) -> Result<(), String> { + validate_affine(transform, tag)?; + if transform != crate::math::Affine::IDENTITY { + push_attr( + out, + "transform", + &format!( + "{} {} {} {} {} {}", + fmt_num(transform.a), + fmt_num(transform.b), + fmt_num(transform.c), + fmt_num(transform.d), + fmt_num(transform.e), + fmt_num(transform.f) + ), + ); + } + Ok(()) +} + +fn write_gradient_stops( + stops: &[GradientStop], + tag: &str, + depth: usize, + out: &mut String, +) -> Result<(), String> { + validate_gradient_stops(stops, tag)?; + let indent = " ".repeat(depth); + for stop in stops { + let _ = write!(out, "{indent}"); + } + Ok(()) +} + +fn write_paint(paint: &Paint, depth: usize, out: &mut String) -> Result<(), String> { + let indent = " ".repeat(depth); + match paint { + Paint::Solid(solid) => { + let _ = write!(out, "{indent}"); + } + Paint::LinearGradient(gradient) => { + if !gradient.xy1.0.is_finite() + || !gradient.xy1.1.is_finite() + || !gradient.xy2.0.is_finite() + || !gradient.xy2.1.is_finite() + { + return Err(" endpoints must be finite".into()); + } + if gradient.xy1 == gradient.xy2 { + return Err(" from and to must differ".into()); + } + let from = gradient.xy1.try_to_uv().ok_or_else(|| { + " from alignment is not representable: binary64 UV arithmetic cannot reproduce its stored f32 components".to_string() + })?; + let to = gradient.xy2.try_to_uv().ok_or_else(|| { + " to alignment is not representable: binary64 UV arithmetic cannot reproduce its stored f32 components".to_string() + })?; + let _ = write!(out, "{indent}"); + write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; + let _ = writeln!(out, "{indent}"); + } + Paint::RadialGradient(gradient) => { + let _ = write!(out, "{indent}"); + write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; + let _ = writeln!(out, "{indent}"); + } + Paint::SweepGradient(gradient) => { + let _ = write!(out, "{indent}"); + write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; + let _ = writeln!(out, "{indent}"); + } + Paint::DiamondGradient(gradient) => { + let _ = write!(out, "{indent}"); + write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; + let _ = writeln!(out, "{indent}"); + } + Paint::Image(image) => { + let src = match &image.image { + ResourceRef::Rid(src) if !src.trim().is_empty() => src, + ResourceRef::Rid(_) => return Err(" src must not be empty".into()), + ResourceRef::Hash(_) => { + return Err("Draft 0 XML cannot represent a hash image resource".into()); + } + }; + if image.quarter_turns != 0 { + return Err("Draft 0 XML cannot represent image quarter-turns".into()); + } + if image.alignment != Alignment::CENTER { + return Err("Draft 0 XML cannot represent non-centered image alignment".into()); + } + if image.filters != ImageFilters::default() { + return Err("Draft 0 XML cannot represent image filters".into()); + } + let fit = match image.fit { + ImagePaintFit::Fit(fit) => fit, + ImagePaintFit::Transform(_) => { + return Err("Draft 0 XML cannot represent transformed image fit".into()); + } + ImagePaintFit::Tile(_) => { + return Err("Draft 0 XML cannot represent tiled image fit".into()); + } + }; + let _ = write!(out, "{indent}"); + } + } + Ok(()) +} + +fn write_fill( + emission: FillEmission<'_>, + depth: usize, + inline: bool, + out: &mut String, +) -> Result<(), String> { + match emission { + FillEmission::Omit | FillEmission::Attribute(_) => {} + FillEmission::Empty => { + if !inline { + let _ = write!(out, "{}", " ".repeat(depth)); + } + if inline { + let _ = write!(out, ""); + } else { + let _ = writeln!(out, ""); + } + } + FillEmission::Stack(paints) => { + if !inline { + let _ = write!(out, "{}", " ".repeat(depth)); + } + let _ = writeln!(out, ""); + for paint in paints.iter() { + write_paint(paint, depth + 1, out)?; + } + if inline { + let _ = write!(out, "{}", " ".repeat(depth)); + } else { + let _ = writeln!(out, "{}", " ".repeat(depth)); + } + } + } + Ok(()) +} + +fn normalized_dash_array(values: &[f32]) -> Vec { + if values.len().is_multiple_of(2) { + values.to_vec() + } else { + values.iter().chain(values).copied().collect() + } +} + +fn validate_stroke_for_write(stroke: &Stroke, payload: &Payload) -> Result<(), String> { + let default = Stroke::default_for(payload) + .ok_or_else(|| format!("<{}> cannot carry strokes", payload.kind_name()))?; + if !stroke.width.is_finite() || stroke.width < 0.0 { + return Err(" width must be finite and non-negative".into()); + } + if !stroke.miter_limit.is_finite() || stroke.miter_limit <= 0.0 { + return Err(" miter-limit must be finite and positive".into()); + } + if let Some(values) = &stroke.dash_array { + if values.is_empty() + || values + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + || values.iter().all(|value| *value == 0.0) + { + return Err( + " dash-array must contain non-negative finite values and not be all zero" + .into(), + ); + } + } + + match payload { + Payload::Shape { + desc: ShapeDesc::Line, + } => { + if stroke.align != StrokeAlign::Center { + return Err("a stroke must use align=\"center\"".into()); + } + if stroke.join != default.join || stroke.miter_limit != default.miter_limit { + return Err("a stroke cannot carry join or miter-limit state".into()); + } + } + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect, + } => { + if stroke.cap != default.cap { + return Err("a container/rect stroke cannot carry cap state".into()); + } + } + Payload::Shape { + desc: ShapeDesc::Ellipse, + } => { + if stroke.cap != default.cap + || stroke.join != default.join + || stroke.miter_limit != default.miter_limit + { + return Err( + "an ellipse stroke cannot carry cap, join, or miter-limit state".into(), + ); + } + } + Payload::Text { .. } => { + if stroke.cap != default.cap + || stroke.join != default.join + || stroke.miter_limit != default.miter_limit + || stroke.dash_array.is_some() + { + return Err( + "a text stroke cannot carry cap, join, miter-limit, or dash-array state".into(), + ); + } + } + Payload::Group | Payload::Lens { .. } => unreachable!("checked above"), + } + Ok(()) +} + +fn stroke_is_omitted(stroke: &Stroke, payload: &Payload) -> bool { + stroke.paints.is_empty() && stroke.geometry_is_default_for(payload) +} + +fn write_stroke( + stroke: &Stroke, + payload: &Payload, + depth: usize, + inline: bool, + out: &mut String, +) -> Result<(), String> { + validate_stroke_for_write(stroke, payload)?; + if stroke_is_omitted(stroke, payload) { + return Ok(()); + } + let default = Stroke::default_for(payload).expect("validated stroke target"); + if !inline { + let _ = write!(out, "{}", " ".repeat(depth)); + } + let _ = write!(out, ">() + .join(" "); + push_attr(out, "dash-array", &value); + } + if stroke.paints.is_empty() { + if inline { + let _ = write!(out, "/>"); + } else { + let _ = writeln!(out, "/>"); + } + return Ok(()); + } + let _ = writeln!(out, ">"); + for paint in stroke.paints.iter() { + write_paint(paint, depth + 1, out)?; + } + if inline { + let _ = write!(out, "{}", " ".repeat(depth)); + } else { + let _ = writeln!(out, "{}", " ".repeat(depth)); + } + Ok(()) +} + +fn print_node( + doc: &Document, + id: NodeId, + depth: usize, + vocabulary: Vocabulary, + out: &mut String, +) -> Result<(), String> { let node = doc.get(id); let indent = " ".repeat(depth); - let tag = node.payload.kind_name(); + let tag = match (vocabulary, &node.payload) { + (Vocabulary::GridaXml, Payload::Frame { .. }) => "container", + ( + Vocabulary::GridaXml, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) => "rect", + ( + Vocabulary::GridaXml, + Payload::Shape { + desc: ShapeDesc::Ellipse, + }, + ) => "ellipse", + ( + Vocabulary::GridaXml, + Payload::Shape { + desc: ShapeDesc::Line, + }, + ) => "line", + _ => node.payload.kind_name(), + }; + let width_attr = if vocabulary == Vocabulary::GridaXml { + "width" + } else { + "w" + }; + let height_attr = if vocabulary == Vocabulary::GridaXml { + "height" + } else { + "h" + }; + let (min_width_attr, max_width_attr, min_height_attr, max_height_attr, aspect_attr) = + if vocabulary == Vocabulary::GridaXml { + ( + "min-width", + "max-width", + "min-height", + "max-height", + "aspect-ratio", + ) + } else { + ("min-w", "max-w", "min-h", "max-h", "aspect") + }; let _ = write!(out, "{indent}<{tag}"); if let Some(name) = &node.header.name { @@ -430,10 +2297,10 @@ fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { } if !node.payload.box_is_derived() { match node.header.width { - SizeIntent::Fixed(v) => push_attr(out, "w", &fmt_num(v)), + SizeIntent::Fixed(v) => push_attr(out, width_attr, &fmt_num(v)), SizeIntent::Auto => { if matches!(node.payload, Payload::Frame { .. }) { - push_attr(out, "w", "auto"); + push_attr(out, width_attr, "auto"); } // text: auto is the kind default — omitted } @@ -447,30 +2314,30 @@ fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { match node.header.height { SizeIntent::Fixed(v) => { if !is_line { - push_attr(out, "h", &fmt_num(v)); + push_attr(out, height_attr, &fmt_num(v)); } } SizeIntent::Auto => { if matches!(node.payload, Payload::Frame { .. }) { - push_attr(out, "h", "auto"); + push_attr(out, height_attr, "auto"); } } } } if let Some(v) = node.header.min_width { - push_attr(out, "min-w", &fmt_num(v)); + push_attr(out, min_width_attr, &fmt_num(v)); } if let Some(v) = node.header.max_width { - push_attr(out, "max-w", &fmt_num(v)); + push_attr(out, max_width_attr, &fmt_num(v)); } if let Some(v) = node.header.min_height { - push_attr(out, "min-h", &fmt_num(v)); + push_attr(out, min_height_attr, &fmt_num(v)); } if let Some(v) = node.header.max_height { - push_attr(out, "max-h", &fmt_num(v)); + push_attr(out, max_height_attr, &fmt_num(v)); } if let Some((a, b)) = node.header.aspect_ratio { - push_attr(out, "aspect", &format!("{}:{}", fmt_num(a), fmt_num(b))); + push_attr(out, aspect_attr, &format!("{}:{}", fmt_num(a), fmt_num(b))); } if node.header.rotation != 0.0 { push_attr(out, "rotation", &fmt_num(node.header.rotation)); @@ -563,12 +2430,14 @@ fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { } } Payload::Shape { desc } => { - let kind = match desc { - ShapeDesc::Rect => "rect", - ShapeDesc::Ellipse => "ellipse", - ShapeDesc::Line => "line", - }; - push_attr(out, "kind", kind); + if vocabulary == Vocabulary::TextIr { + let kind = match desc { + ShapeDesc::Rect => "rect", + ShapeDesc::Ellipse => "ellipse", + ShapeDesc::Line => "line", + }; + push_attr(out, "kind", kind); + } } Payload::Text { font_size, .. } => { if *font_size != 16.0 { @@ -614,9 +2483,32 @@ fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { push_attr(out, "ops", &parts.join(" ")); } } - if let Some(fill) = &node.fill { - push_attr(out, "fill", &fill.to_hex()); + let fill_emission = match vocabulary { + Vocabulary::TextIr => { + if let Some(fill) = historical_fill(node)? { + push_attr(out, "fill", &fill.to_hex()); + } + FillEmission::Omit + } + Vocabulary::GridaXml => grida_xml_fill_emission(node)?, + }; + if let FillEmission::Attribute(color) = fill_emission { + push_attr(out, "fill", &color.to_hex()); } + let strokes: Vec<&Stroke> = match vocabulary { + Vocabulary::TextIr => vec![], + Vocabulary::GridaXml => { + for stroke in &node.strokes { + validate_stroke_for_write(stroke, &node.payload)?; + } + node.strokes + .iter() + .filter(|stroke| !stroke_is_omitted(stroke, &node.payload)) + .collect() + } + }; + let has_fill_child = matches!(fill_emission, FillEmission::Empty | FillEmission::Stack(_)); + let has_properties = has_fill_child || !strokes.is_empty(); let is_text = matches!(node.payload, Payload::Text { .. }); if is_text { @@ -628,14 +2520,28 @@ fn print_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { .replace('&', "&") .replace('<', "<") .replace('>', ">"); - let _ = writeln!(out, ">{escaped}"); - } else if node.children.is_empty() { + let _ = write!(out, ">"); + if has_fill_child { + write_fill(fill_emission, depth + 1, true, out)?; + } + for stroke in strokes { + write_stroke(stroke, &node.payload, depth + 1, true, out)?; + } + let _ = writeln!(out, "{escaped}"); + } else if node.children.is_empty() && !has_properties { let _ = writeln!(out, "/>"); } else { let _ = writeln!(out, ">"); + if has_fill_child { + write_fill(fill_emission, depth + 1, false, out)?; + } + for stroke in strokes { + write_stroke(stroke, &node.payload, depth + 1, false, out)?; + } for c in &node.children { - print_node(doc, *c, depth + 1, out); + print_node(doc, *c, depth + 1, vocabulary, out)?; } let _ = writeln!(out, "{indent}"); } + Ok(()) } diff --git a/model-v2/a/lab/tests/arena_pick.rs b/model-v2/a/lab/tests/arena_pick.rs index e197bee1ef..c538a27519 100644 --- a/model-v2/a/lab/tests/arena_pick.rs +++ b/model-v2/a/lab/tests/arena_pick.rs @@ -214,3 +214,37 @@ fn pick_hairline_line_is_grabbable() { let r = resolve(&doc, &opts()); assert_eq!(pick(&doc, &r, 180.0, 401.5), Some(line)); } + +#[test] +fn pick_respects_the_exact_transformed_clip_of_every_ancestor() { + let mut b = DocBuilder::new(); + let mut clip_header = Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(100.0)); + clip_header = at(clip_header, 200.0, 200.0); + clip_header.rotation = 45.0; + let clip = b.add( + 0, + clip_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: true, + }, + ); + let (child_header, child_payload) = card(40.0, 40.0); + let child = b.add(clip, at(child_header, 80.0, 20.0), child_payload); + let doc = b.build(); + let r = resolve(&doc, &opts()); + + let inside_clip = r.world_of(child).apply((10.0, 10.0)); + assert_eq!( + pick(&doc, &r, inside_clip.0, inside_clip.1), + Some(child), + "the visible part of the child remains pickable" + ); + + let outside_clip = r.world_of(child).apply((30.0, 10.0)); + assert_eq!( + pick(&doc, &r, outside_clip.0, outside_clip.1), + Some(doc.root), + "the child is not pickable through its rotated ancestor clip" + ); +} diff --git a/model-v2/a/lab/tests/composition.rs b/model-v2/a/lab/tests/composition.rs new file mode 100644 index 0000000000..3a59e9a592 --- /dev/null +++ b/model-v2/a/lab/tests/composition.rs @@ -0,0 +1,142 @@ +//! Boxed-node composition: an internal Shape may own free-positioned children +//! while keeping its parametric box and layout contribution independent. Text +//! stays a leaf. Draft 0 exposes this as direct primitive composition such as +//! ``. + +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions}; + +fn rect(w: f32, h: f32) -> (Header, Payload) { + ( + Header::new(SizeIntent::Fixed(w), SizeIntent::Fixed(h)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) +} + +#[test] +fn shape_children_resolve_in_the_shape_local_box() { + let mut builder = DocBuilder::new(); + let (mut shape_header, shape_payload) = rect(200.0, 100.0); + shape_header.x = AxisBinding::start(100.0); + shape_header.y = AxisBinding::start(50.0); + let shape = builder.add(0, shape_header, shape_payload); + + let mut text_header = Header::new(SizeIntent::Auto, SizeIntent::Auto); + text_header.x = AxisBinding::start(10.0); + text_header.y = AxisBinding::start(12.0); + let text = builder.add( + shape, + text_header, + Payload::Text { + content: "content".into(), + font_size: 10.0, + }, + ); + + let doc = builder.build(); + let resolved = resolve(&doc, &ResolveOptions::default()); + assert_eq!(resolved.xywh(shape), (100.0, 50.0, 200.0, 100.0)); + assert_eq!(resolved.box_of(text).x, 10.0); + assert_eq!(resolved.box_of(text).y, 12.0); + let world = resolved.world_of(text); + assert_eq!((world.e, world.f), (110.0, 62.0)); +} + +/// Children are paint/content, not a second measurement input. Overflow may +/// enlarge read-tier ink bounds, but it cannot resize the Shape or move its +/// flex sibling. +#[test] +fn shape_children_do_not_feed_back_into_box_or_layout() { + let mut builder = DocBuilder::new(); + let frame = builder.add( + 0, + Header::new(SizeIntent::Fixed(300.0), SizeIntent::Fixed(100.0)), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + gap_main: 10.0, + ..Default::default() + }, + clips_content: false, + }, + ); + + let (shape_header, shape_payload) = rect(80.0, 60.0); + let composed = builder.add(frame, shape_header, shape_payload); + let mut overflow_header = Header::new(SizeIntent::Auto, SizeIntent::Auto); + overflow_header.x = AxisBinding::start(90.0); + let overflow = builder.add( + composed, + overflow_header, + Payload::Text { + content: "overflow".into(), + font_size: 10.0, + }, + ); + let (sibling_header, sibling_payload) = rect(40.0, 40.0); + let sibling = builder.add(frame, sibling_header, sibling_payload); + + let doc = builder.build(); + let resolved = resolve(&doc, &ResolveOptions::default()); + assert_eq!(resolved.box_of(composed).w, 80.0); + assert_eq!(resolved.box_of(sibling).x, 90.0, "80px box + 10px gap"); + assert_eq!(resolved.box_of(overflow).x, 90.0); + assert!( + resolved.aabb_of(composed).w > resolved.box_of(composed).w, + "read-tier ink still includes overflowing descendants" + ); +} + +#[test] +fn payload_child_capabilities_are_explicit() { + assert!(Payload::Shape { + desc: ShapeDesc::Rect + } + .accepts_children()); + assert!(Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + } + .accepts_children()); + assert!(!Payload::Text { + content: String::new(), + font_size: 16.0, + } + .accepts_children()); +} + +#[test] +#[should_panic(expected = "text does not accept children")] +fn builder_rejects_text_children() { + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: "leaf".into(), + font_size: 16.0, + }, + ); + let (child_header, child_payload) = rect(10.0, 10.0); + builder.add(text, child_header, child_payload); +} + +#[test] +#[should_panic(expected = "text does not accept children")] +fn splice_rejects_text_children() { + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: "leaf".into(), + font_size: 16.0, + }, + ); + let (header, payload) = rect(10.0, 10.0); + let child = builder.add(0, header, payload); + let mut doc = builder.build(); + doc.splice_children(text, 0, 0, vec![child]); +} diff --git a/model-v2/a/lab/tests/draft0_resolution.rs b/model-v2/a/lab/tests/draft0_resolution.rs new file mode 100644 index 0000000000..81afcf6f92 --- /dev/null +++ b/model-v2/a/lab/tests/draft0_resolution.rs @@ -0,0 +1,374 @@ +//! Focused Draft 0 resolution regressions: text measurement order, flex +//! stretch intent, and stroke-aware visual bounds. + +mod common; +use common::*; + +use anchor_lab::math::RectF; +use anchor_lab::model::*; + +fn text_node( + width: SizeIntent, + min_width: Option, + max_width: Option, +) -> (Document, NodeId) { + let mut builder = DocBuilder::new(); + let mut header = Header::new(width, SizeIntent::Auto); + header.min_width = min_width; + header.max_width = max_width; + let text = builder.add( + 0, + header, + Payload::Text { + content: "aa bb cc".into(), + font_size: 10.0, + }, + ); + (builder.build(), text) +} + +#[test] +fn text_auto_height_remeasures_at_the_final_constrained_width() { + let cases = [ + (SizeIntent::Auto, None, Some(30.0), 30.0, 24.0), + (SizeIntent::Auto, Some(60.0), None, 60.0, 12.0), + (SizeIntent::Fixed(60.0), None, Some(30.0), 30.0, 24.0), + (SizeIntent::Fixed(30.0), Some(60.0), None, 60.0, 12.0), + ]; + + for (width, min_width, max_width, expected_width, expected_height) in cases { + let (doc, text) = text_node(width, min_width, max_width); + let resolved = run(&doc); + assert_close(resolved.box_of(text).w, expected_width, "final text width"); + assert_close( + resolved.box_of(text).h, + expected_height, + "height measured from final width", + ); + } +} + +#[test] +fn explicit_line_breaks_contribute_to_auto_text_extents() { + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: "a\nbb\n".into(), + font_size: 10.0, + }, + ); + let resolved = run(&builder.build()); + assert_close(resolved.box_of(text).w, 12.0, "widest explicit line"); + assert_close( + resolved.box_of(text).h, + 36.0, + "trailing empty line is preserved", + ); +} + +fn column_flex(cross_align: CrossAlign) -> Payload { + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Column, + cross_align, + ..Default::default() + }, + clips_content: false, + } +} + +#[test] +fn container_cross_stretch_reads_authored_auto_for_ordinary_children() { + let mut builder = DocBuilder::new(); + let container = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(100.0)), + column_flex(CrossAlign::Stretch), + ); + let auto = builder.add( + container, + Header::new(SizeIntent::Auto, SizeIntent::Fixed(20.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let fixed = builder.add( + container, + Header::new(SizeIntent::Fixed(30.0), SizeIntent::Fixed(20.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + + let resolved = run(&builder.build()); + assert_close(resolved.box_of(auto).w, 100.0, "auto cross size stretches"); + assert_close( + resolved.box_of(fixed).w, + 30.0, + "container stretch preserves fixed cross size", + ); +} + +#[test] +fn child_align_stretch_overrides_fixed_text_cross_size() { + let mut builder = DocBuilder::new(); + let container = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(100.0)), + column_flex(CrossAlign::Start), + ); + let mut header = Header::new(SizeIntent::Fixed(30.0), SizeIntent::Auto); + header.self_align = SelfAlign::Stretch; + let text = builder.add( + container, + header, + Payload::Text { + content: "aaaa bbbb cccc".into(), + font_size: 10.0, + }, + ); + + let resolved = run(&builder.build()); + assert_close(resolved.box_of(text).w, 100.0, "explicit text stretch"); + assert_close( + resolved.box_of(text).h, + 12.0, + "text remeasures at stretched width", + ); +} + +#[test] +fn row_flex_self_stretch_preserves_the_lines_locked_zero_height() { + let mut builder = DocBuilder::new(); + let container = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(80.0)), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Row, + ..Default::default() + }, + clips_content: false, + }, + ); + let payload = Payload::Shape { + desc: ShapeDesc::Line, + }; + let mut header = Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(0.0)); + header.self_align = SelfAlign::Stretch; + let line = builder.add(container, header, payload.clone()); + let mut stroke = Stroke::default_for(&payload).unwrap(); + stroke.width = 10.0; + stroke.paints = Paints::solid(Color::BLACK); + builder.node_mut(line).strokes.push(stroke); + + let resolved = run(&builder.build()); + assert_close( + resolved.box_of(line).h, + 0.0, + "flex cannot rewrite locked line height", + ); + assert_close( + resolved.aabb_of(line).h, + 10.0, + "the line keeps a degenerate paint box plus stroke coverage", + ); +} + +fn stroked_shape(desc: ShapeDesc, align: StrokeAlign, width: f32) -> (Document, NodeId) { + let mut builder = DocBuilder::new(); + let mut header = Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(50.0)); + header.x = AxisBinding::start(10.0); + header.y = AxisBinding::start(20.0); + if desc == ShapeDesc::Line { + header.height = SizeIntent::Fixed(0.0); + } + let payload = Payload::Shape { desc }; + let mut stroke = Stroke::default_for(&payload).expect("shape supports strokes"); + stroke.paints = Paints::solid(Color::BLACK); + stroke.width = width; + stroke.align = align; + let shape = builder.add(0, header, payload); + builder.node_mut(shape).strokes.push(stroke); + (builder.build(), shape) +} + +#[test] +fn effective_strokes_expand_visual_bounds_without_changing_layout_bounds() { + let cases = [ + (StrokeAlign::Inside, (10.0, 20.0, 100.0, 50.0)), + (StrokeAlign::Center, (5.0, 15.0, 110.0, 60.0)), + (StrokeAlign::Outside, (0.0, 10.0, 120.0, 70.0)), + ]; + + for (align, (x, y, width, height)) in cases { + let (doc, shape) = stroked_shape(ShapeDesc::Rect, align, 10.0); + let resolved = run(&doc); + assert_rect( + resolved.box_of(shape), + 10.0, + 20.0, + 100.0, + 50.0, + "stroke does not affect layout box", + ); + assert_rect( + resolved.aabb_of(shape), + x, + y, + width, + height, + "stroke expands visual bounds", + ); + } +} + +#[test] +fn stroke_expansion_is_transformed_with_the_node() { + let (mut doc, shape) = stroked_shape(ShapeDesc::Rect, StrokeAlign::Outside, 10.0); + doc.get_mut(shape).header.rotation = 90.0; + let resolved = run(&doc); + assert_rect( + resolved.aabb_of(shape), + 25.0, + -15.0, + 70.0, + 120.0, + "expanded local stroke bounds rotate into world bounds", + ); +} + +#[test] +fn line_stroke_bounds_expand_conservatively_around_the_degenerate_axis() { + let (doc, line) = stroked_shape(ShapeDesc::Line, StrokeAlign::Center, 10.0); + let resolved = run(&doc); + assert_rect( + resolved.box_of(line), + 10.0, + 20.0, + 100.0, + 0.0, + "line layout box remains degenerate", + ); + assert_rect( + resolved.aabb_of(line), + 5.0, + 15.0, + 110.0, + 10.0, + "line caps and width fit conservative bounds", + ); +} + +#[test] +fn text_miter_limit_is_included_in_conservative_visual_bounds() { + let mut builder = DocBuilder::new(); + let mut header = Header::new(SizeIntent::Auto, SizeIntent::Auto); + header.x = AxisBinding::start(20.0); + header.y = AxisBinding::start(20.0); + let payload = Payload::Text { + content: "A".into(), + font_size: 10.0, + }; + let text = builder.add(0, header, payload.clone()); + let mut stroke = Stroke::default_for(&payload).unwrap(); + stroke.paints = Paints::solid(Color::BLACK); + stroke.width = 2.0; + stroke.align = StrokeAlign::Center; + builder.node_mut(text).strokes.push(stroke); + + let resolved = run(&builder.build()); + assert_rect( + resolved.aabb_of(text), + 16.0, + 16.0, + 14.0, + 20.0, + "text miter extent is conservatively bounded", + ); +} + +#[test] +fn ineffective_strokes_do_not_expand_visual_bounds() { + let (mut doc, shape) = stroked_shape(ShapeDesc::Rect, StrokeAlign::Outside, 10.0); + let stroke = &mut doc.get_mut(shape).strokes[0]; + stroke.paints = Paints::solid(Color::TRANSPARENT); + let resolved = run(&doc); + assert_rect( + resolved.aabb_of(shape), + 10.0, + 20.0, + 100.0, + 50.0, + "transparent stroke has no visual extent", + ); + + doc.get_mut(shape).strokes[0].paints = Paints::solid(Color::BLACK); + doc.get_mut(shape).strokes[0].width = 0.0; + let resolved = run(&doc); + assert_rect( + resolved.aabb_of(shape), + 10.0, + 20.0, + 100.0, + 50.0, + "zero-width stroke has no visual extent", + ); +} + +#[test] +fn transformed_clip_bounds_exclude_outside_descendant_contributions() { + let mut builder = DocBuilder::new(); + let mut clip_header = Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(60.0)); + clip_header.x = AxisBinding::start(200.0); + clip_header.y = AxisBinding::start(100.0); + clip_header.rotation = 90.0; + let clip = builder.add( + 0, + clip_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: true, + }, + ); + let mut child_header = Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(20.0)); + child_header.x = AxisBinding::start(150.0); + child_header.y = AxisBinding::start(10.0); + let child = builder.add( + clip, + child_header, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + + let resolved = run(&builder.build()); + let clip_world_aabb = RectF { + x: 0.0, + y: 0.0, + w: 100.0, + h: 60.0, + } + .transformed_aabb(&resolved.world_of(clip)); + assert_eq!( + resolved.aabb_of(clip), + clip_world_aabb, + "a clipped-out child cannot enlarge its ancestor's visual bounds" + ); + assert_eq!( + resolved.aabb_of(child), + RectF { + x: 250.0, + y: 230.0, + w: 20.0, + h: 20.0, + }, + "the child's own conservative AABB remains independently readable" + ); +} diff --git a/model-v2/a/lab/tests/grida_xml_paints.rs b/model-v2/a/lab/tests/grida_xml_paints.rs new file mode 100644 index 0000000000..6c5c7a1e14 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_paints.rs @@ -0,0 +1,551 @@ +//! Producer contract for ordered typed paints in Draft 0 `.grida.xml`. + +use anchor_lab::grida_xml::{self, PrintError}; +use anchor_lab::model::*; + +const THREE_LAYER: &str = r##" + + + + + + + + + + + + Example + + + +"##; + +fn named(doc: &Document, name: &str) -> NodeId { + (0..doc.capacity() as NodeId) + .find(|id| { + doc.get_opt(*id) + .is_some_and(|node| node.header.name.as_deref() == Some(name)) + }) + .expect("named node") +} + +#[test] +fn typed_fill_lowers_to_the_ordered_production_variants() { + let doc = grida_xml::parse(THREE_LAYER).expect("typed fills parse"); + let card = doc.get(named(&doc, "card")); + assert_eq!(card.fills.len(), 3); + + let Paint::Solid(solid) = &card.fills[0] else { + panic!("first paint must be solid"); + }; + assert_eq!(solid.color.to_hex(), "#101828"); + + let Paint::LinearGradient(linear) = &card.fills[1] else { + panic!("second paint must be linear gradient"); + }; + assert_eq!(linear.xy1, Alignment::from_uv(0.0, 0.0)); + assert_eq!(linear.xy2, Alignment::from_uv(1.0, 1.0)); + assert_eq!(linear.opacity, 0.8); + assert_eq!(linear.stops.len(), 2); + assert_eq!(linear.stops[0].offset, 0.0); + assert_eq!(linear.stops[1].offset, 1.0); + + let Paint::Image(image) = &card.fills[2] else { + panic!("third paint must be image"); + }; + assert_eq!(image.image, ResourceRef::Rid("./noise.png".into())); + assert_eq!(image.fit, ImagePaintFit::Fit(BoxFit::Cover)); + assert_eq!(image.opacity, 0.15); +} + +#[test] +fn canonical_writer_preserves_rich_fill_order_and_uses_the_family_gradient() { + let doc = grida_xml::parse(THREE_LAYER).unwrap(); + let printed = grida_xml::print(&doc).expect("rich paints print"); + let solid = printed.find(""), "{printed}"); + assert!(!printed.contains(""##, + ) + .unwrap(); + let printed = grida_xml::print(&shorthand).unwrap(); + assert!(printed.contains("fill=\"#FFFFFF\""), "{printed}"); + assert!(!printed.contains(""), "{printed}"); + + let structured = grida_xml::parse( + r##""##, + ) + .unwrap(); + let structured_printed = grida_xml::print(&structured).unwrap(); + assert!( + structured_printed.contains("fill=\"#AABBCC\""), + "{structured_printed}" + ); + assert!( + !structured_printed.contains(""), + "{structured_printed}" + ); + + let defaulted = grida_xml::parse( + r#""#, + ) + .unwrap(); + assert_eq!( + defaulted.get(named(&defaulted, "default")).fills, + Paints::solid(Color::BLACK) + ); + let default_printed = grida_xml::print(&defaulted).unwrap(); + assert!(!default_printed.contains(" fill="), "{default_printed}"); + assert!(!default_printed.contains(""#, + ) + .unwrap(); + assert!(empty.get(named(&empty, "empty")).fills.is_empty()); + let empty_printed = grida_xml::print(&empty).unwrap(); + assert!(empty_printed.contains(""), "{empty_printed}"); + + let empty_container = grida_xml::parse( + r#""#, + ) + .unwrap(); + let empty_container_printed = grida_xml::print(&empty_container).unwrap(); + assert!( + !empty_container_printed.contains(""##, + ) + .unwrap(); + let translucent_printed = grida_xml::print(&translucent).unwrap(); + assert!( + translucent_printed.contains(""), + "{translucent_printed}" + ); + assert!( + translucent_printed.contains("opacity=\"0.5019608\""), + "{translucent_printed}" + ); + + let conflict = grida_xml::parse( + r##""##, + ) + .unwrap_err(); + assert!(conflict + .to_string() + .contains("both the `fill` attribute and ")); +} + +#[test] +fn all_gradient_variants_and_common_properties_round_trip() { + let source = r##" + + + + + + +"##; + let doc = grida_xml::parse(source).unwrap(); + let fills = &doc.get(named(&doc, "painted")).fills; + assert!(matches!(fills[0], Paint::Solid(_))); + assert!(matches!(fills[1], Paint::RadialGradient(_))); + assert!(matches!(fills[2], Paint::SweepGradient(_))); + assert!(matches!(fills[3], Paint::DiamondGradient(_))); + let Paint::Solid(solid) = &fills[0] else { + unreachable!() + }; + assert!(!solid.active); + assert_eq!(solid.color.alpha(), 128, "solid opacity quantizes to RGBA8"); + assert_eq!(solid.blend_mode, BlendMode::Multiply); + + let printed = grida_xml::print(&doc).unwrap(); + assert!(printed.contains("opacity=\"0.5019608\""), "{printed}"); + assert!(printed.contains("visible=\"false\""), "{printed}"); + assert!(printed.contains("tile-mode=\"mirror\""), "{printed}"); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); +} + +#[test] +fn enumerated_paint_values_match_the_model_vocabulary() { + for mode in [ + "normal", + "multiply", + "screen", + "overlay", + "darken", + "lighten", + "color-dodge", + "color-burn", + "hard-light", + "soft-light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity", + ] { + let source = format!( + "" + ); + let doc = grida_xml::parse(&source).unwrap(); + let Paint::Solid(solid) = &doc.get(named(&doc, "r")).fills[0] else { + unreachable!() + }; + assert_eq!(solid.blend_mode.as_str(), mode); + } + + for fit in ["cover", "contain", "fill", "none"] { + let source = format!( + "" + ); + let doc = grida_xml::parse(&source).unwrap(); + let Paint::Image(image) = &doc.get(named(&doc, "r")).fills[0] else { + unreachable!() + }; + let ImagePaintFit::Fit(actual) = image.fit else { + unreachable!() + }; + assert_eq!(actual.as_str(), fit); + } + + for tile_mode in ["clamp", "repeated", "mirror", "decal"] { + let source = format!( + "" + ); + let doc = grida_xml::parse(&source).unwrap(); + let Paint::LinearGradient(gradient) = &doc.get(named(&doc, "r")).fills[0] else { + unreachable!() + }; + assert_eq!(gradient.tile_mode.as_str(), tile_mode); + } +} + +#[test] +fn text_content_is_exact_around_a_structural_fill_child() { + let source = " hello\nworld "; + let doc = grida_xml::parse(source).unwrap(); + let label = doc.get(named(&doc, "label")); + let Payload::Text { content, .. } = &label.payload else { + panic!("text payload") + }; + assert_eq!(content, " hello\nworld "); + let printed = grida_xml::print(&doc).unwrap(); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); +} + +#[test] +fn paint_context_and_structure_errors_are_targeted() { + let cases = [ + ( + r##""##, + "direct child of or ", + ), + ( + r#""#, + "scene is not supported", + ), + ( + r##""##, + "direct child of a gradient", + ), + ( + r#""#, + "not valid on ", + ), + ( + r#""#, + "duplicate ", + ), + ( + r#""#, + "nested ", + ), + ( + r#"x"#, + "must appear before", + ), + ]; + for (source, expected) in cases { + let error = grida_xml::parse(source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected {expected}: {error}" + ); + } +} + +#[test] +fn bad_paint_values_are_rejected_without_coercion() { + let cases = [ + ("", "#RGB or #RRGGBB"), + ("", "must be empty"), + ("", "exactly `true` or `false`"), + ("", "between 0 and 1"), + ("", "layer mode"), + ("", "src must not be empty"), + ("", "must be empty"), + ("", "bad image fit"), + ("", "kind"), + ("", "kind"), + ("", "from"), + ("", "tile-mode"), + ("", "six numbers"), + ("not-a-stop", "character content"), + ("", "only empty "), + ("", "must be empty"), + ("", "requires `offset`"), + ("", "requires `color`"), + ("", "visible"), + ("", "between 0 and 1"), + ("", "at least two stops"), + ("", "from and to must differ"), + ("", "after lowering to model alignment"), + ("", "after lowering to model alignment"), + ("", "overflow after lowering"), + ("", "nondecreasing"), + ("", "between 0 and 1"), + ]; + for (paint, expected) in cases { + let source = format!( + "{paint}" + ); + let error = grida_xml::parse(&source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected {expected}: {error}" + ); + } +} + +#[test] +fn linear_gradient_uv_boundary_is_exact_or_explicitly_unrepresentable() { + for (u, v) in [ + (0.0, 0.0), + (0.5, 0.5), + (0.55, 0.125), + (-2.5, 1.75), + (f64::from_bits(1), f64::from_bits(2)), + ] { + let alignment = Alignment::from_uv_f64(u, v); + let canonical = alignment + .try_to_uv() + .expect("values produced by the UV boundary are representable"); + assert_eq!(Alignment::from_uv_f64(canonical.0, canonical.1), alignment); + } + + for alignment in [ + Alignment(0.1, -0.1), + Alignment(0.25, -12345.678), + Alignment(-1.0, 1.0), + Alignment(f32::MAX, -f32::MAX), + ] { + let canonical = alignment + .try_to_uv() + .expect("ordinary and extreme finite alignments are representable"); + assert_eq!(Alignment::from_uv_f64(canonical.0, canonical.1), alignment); + } + + let mut doc = grida_xml::parse( + r##""##, + ) + .unwrap(); + let r = named(&doc, "r"); + let Paint::LinearGradient(gradient) = &mut doc.get_mut(r).fills.as_mut_slice()[0] else { + unreachable!() + }; + gradient.xy1 = Alignment(0.1, 0.25); + gradient.xy2 = Alignment(f32::MAX, -f32::MAX); + let printed = + grida_xml::print(&doc).expect("finite f64-representable alignments print exactly"); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); + + let Paint::LinearGradient(gradient) = &mut doc.get_mut(r).fills.as_mut_slice()[0] else { + unreachable!() + }; + gradient.xy1 = Alignment(f32::from_bits(1), gradient.xy1.1); + assert!(gradient.xy1.try_to_uv().is_none()); + assert!(matches!( + grida_xml::print(&doc), + Err(PrintError::InvalidDocument(message)) + if message.contains("from alignment is not representable") + && message.contains("binary64 UV arithmetic") + )); +} + +#[test] +fn writer_refuses_unspellable_gradient_model_state_without_repair() { + let source = r##""##; + + let mut too_few = grida_xml::parse(source).unwrap(); + let r = named(&too_few, "r"); + let Paint::LinearGradient(gradient) = &mut too_few.get_mut(r).fills.as_mut_slice()[0] else { + unreachable!() + }; + gradient.stops.pop(); + assert!(matches!( + grida_xml::print(&too_few), + Err(PrintError::InvalidDocument(message)) if message.contains("at least two stops") + )); + + let mut descending = grida_xml::parse(source).unwrap(); + let r = named(&descending, "r"); + let Paint::LinearGradient(gradient) = &mut descending.get_mut(r).fills.as_mut_slice()[0] else { + unreachable!() + }; + gradient.stops[0].offset = 0.8; + gradient.stops[1].offset = 0.2; + assert!(matches!( + grida_xml::print(&descending), + Err(PrintError::InvalidDocument(message)) if message.contains("nondecreasing") + )); + + let mut coincident = grida_xml::parse(source).unwrap(); + let r = named(&coincident, "r"); + let Paint::LinearGradient(gradient) = &mut coincident.get_mut(r).fills.as_mut_slice()[0] else { + unreachable!() + }; + gradient.xy2 = gradient.xy1; + assert!(matches!( + grida_xml::print(&coincident), + Err(PrintError::InvalidDocument(message)) if message.contains("from and to must differ") + )); +} + +#[test] +fn line_is_stroke_only_and_rejects_both_fill_forms() { + for source in [ + r##""##, + r##""##, + ] { + let error = grida_xml::parse(source).unwrap_err(); + assert!(error.to_string().contains("fill"), "{error}"); + assert!(error.to_string().contains("line"), "{error}"); + } +} + +#[test] +fn retired_paint_spellings_are_rejected_with_canonical_directions() { + let cases = [ + ( + r##""##, + "", + ), + ( + r##""##, + "use "##, + "use "##, + "use "##, + "use "##, + "unknown paint element ", + ), + ( + r##""##, + "unknown paint element ", + ), + ( + r##""##, + "kind", + ), + ( + r##""##, + "", + ), + ( + r##""##, + "", + ), + ]; + for (source, expected) in cases { + let error = grida_xml::parse(source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` in `{error}`" + ); + } +} + +#[test] +fn frozen_textir_is_singleton_solid_and_rich_state_fails_writers() { + let historical = anchor_lab::textir::parse( + r##""##, + ) + .unwrap(); + let printed = anchor_lab::textir::try_print(&historical).unwrap(); + assert!(printed.contains("fill=\"#000000\"")); + assert_eq!(historical, anchor_lab::textir::parse(&printed).unwrap()); + + for (legacy, canonical) in [ + ("112233", "#112233"), + ("#445566", "#445566"), + ("##778899", "#778899"), + ] { + let source = format!( + r#""# + ); + let doc = anchor_lab::textir::parse(&source).unwrap(); + assert!( + anchor_lab::textir::try_print(&doc) + .unwrap() + .contains(&format!(r#"fill="{canonical}""#)), + "legacy color {legacy}" + ); + } + + let grida_short = grida_xml::parse( + r##""##, + ) + .unwrap(); + assert!(grida_xml::print(&grida_short) + .unwrap() + .contains("fill=\"#AABBCC\"")); + assert!(anchor_lab::textir::parse( + r##""## + ) + .is_err()); + + let rich = grida_xml::parse(THREE_LAYER).unwrap(); + assert!(anchor_lab::textir::try_print(&rich).is_err()); + + let mut unsupported_image = rich.clone(); + let card = named(&unsupported_image, "card"); + let Paint::Image(image) = &mut unsupported_image.get_mut(card).fills.as_mut_slice()[2] else { + panic!("image paint") + }; + image.quarter_turns = 1; + assert!(matches!( + grida_xml::print(&unsupported_image), + Err(PrintError::InvalidDocument(message)) if message.contains("quarter-turns") + )); +} diff --git a/model-v2/a/lab/tests/grida_xml_properties.rs b/model-v2/a/lab/tests/grida_xml_properties.rs new file mode 100644 index 0000000000..23abcdc70d --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_properties.rs @@ -0,0 +1,363 @@ +//! Positive Draft 0 property coverage that complements `grida_xml_suite`. +//! +//! That suite already proves `name`, fixed `width`/`height`, all four size +//! constraints, `aspect-ratio`, bare start bindings, and `flip-y`. This file +//! covers the accepted value forms that otherwise lacked both direct model +//! assertions and a canonical parse -> print -> parse fixpoint. + +use anchor_lab::grida_xml; +use anchor_lab::model::*; + +fn canonical(source: &str) -> (Document, String) { + let doc = grida_xml::parse(source).expect("Draft 0 source parses"); + let printed = grida_xml::print(&doc).expect("parsed Draft 0 source prints"); + let reparsed = grida_xml::parse(&printed).expect("canonical source reparses"); + assert_eq!(doc, reparsed, "semantic round-trip\n---\n{printed}"); + assert_eq!( + printed, + grida_xml::print(&reparsed).expect("canonical source reprints"), + "writer fixpoint" + ); + (doc, printed) +} + +fn authored_root(doc: &Document) -> NodeId { + doc.get(doc.root).children[0] +} + +fn frame_layout(doc: &Document, id: NodeId) -> LayoutBehavior { + match doc.get(id).payload { + Payload::Frame { layout, .. } => layout, + ref payload => panic!("expected container, got {payload:?}"), + } +} + +#[test] +fn binding_forms_auto_sizes_and_common_visual_attributes_materialize() { + let source = r#" + + + +"#; + let (doc, printed) = canonical(source); + let root = authored_root(&doc); + let root_node = doc.get(root); + assert_eq!(root_node.header.width, SizeIntent::Auto); + assert_eq!(root_node.header.height, SizeIntent::Auto); + assert!(matches!( + root_node.payload, + Payload::Frame { + clips_content: true, + .. + } + )); + + let [start, end, center, span_x, span_y, text, unclipped] = root_node.children.as_slice() + else { + panic!("expected all property probes") + }; + + let start = doc.get(*start); + assert_eq!(start.header.x, AxisBinding::start(-5.0)); + assert_eq!(start.header.y, AxisBinding::start(7.0)); + assert_eq!(start.header.rotation, -12.5); + assert!(start.header.flip_x); + assert!(!start.header.flip_y); + assert_eq!(start.header.opacity, 0.25); + assert!(!start.header.active); + + let end = doc.get(*end); + assert_eq!(end.header.x, AxisBinding::end(8.0)); + assert_eq!(end.header.y, AxisBinding::end(-2.0)); + + let center = doc.get(*center); + assert_eq!(center.header.x, AxisBinding::center(0.0)); + assert_eq!(center.header.y, AxisBinding::center(3.0)); + + let span_x = doc.get(*span_x); + assert_eq!( + span_x.header.x, + AxisBinding::Span { + start: 1.0, + end: 2.0 + } + ); + assert_eq!(span_x.header.y, AxisBinding::start(4.0)); + assert_eq!(span_x.header.width, SizeIntent::Auto); + + let span_y = doc.get(*span_y); + assert_eq!(span_y.header.x, AxisBinding::start(5.0)); + assert_eq!( + span_y.header.y, + AxisBinding::Span { + start: 3.0, + end: 4.0 + } + ); + assert_eq!(span_y.header.height, SizeIntent::Auto); + + let text = doc.get(*text); + assert_eq!(text.header.width, SizeIntent::Auto); + assert_eq!(text.header.height, SizeIntent::Auto); + assert_eq!(text.header.opacity, 1.0); + assert!(text.header.active); + assert!(matches!( + text.payload, + Payload::Text { + font_size: 21.0, + .. + } + )); + + assert!(matches!( + doc.get(*unclipped).payload, + Payload::Frame { + clips_content: false, + .. + } + )); + + assert!(printed.contains("")); + assert!(printed.contains("x=\"-5\" y=\"7\""), "{printed}"); + assert!(!printed.contains("x=\"start "), "{printed}"); + assert!(printed.contains("x=\"end 8\" y=\"end -2\""), "{printed}"); + assert!(printed.contains("x=\"center\" y=\"center 3\""), "{printed}"); + assert!(printed.contains("x=\"span 1 2\" y=\"4\""), "{printed}"); + assert!(printed.contains("x=\"5\" y=\"span 3 4\""), "{printed}"); + assert!(printed.contains("rotation=\"-12.5\""), "{printed}"); + assert!(printed.contains("flip-x=\"true\""), "{printed}"); + assert!(!printed.contains("flip-y="), "{printed}"); + assert!(printed.contains("opacity=\"0.25\""), "{printed}"); + assert!(printed.contains("hidden=\"true\""), "{printed}"); + assert!(!printed.contains("hidden=\"false\""), "{printed}"); + assert!(!printed.contains("clips=\"false\""), "{printed}"); + assert!(printed.contains("x"), "{printed}"); +} + +#[test] +fn every_lens_operation_materializes_in_source_order() { + let source = r#" + + + + + + + +"#; + let (doc, printed) = canonical(source); + let root = authored_root(&doc); + let lens = doc.get(root).children[0]; + assert_eq!( + doc.get(lens).payload, + Payload::Lens { + ops: vec![ + LensOp::Translate { x: 1.0, y: -2.0 }, + LensOp::Rotate { deg: 45.0 }, + LensOp::Scale { x: 2.0, y: 2.0 }, + LensOp::Scale { x: 3.0, y: 4.0 }, + LensOp::Skew { + x_deg: 5.0, + y_deg: 0.0 + }, + LensOp::Skew { + x_deg: 0.0, + y_deg: -6.0 + }, + LensOp::Skew { + x_deg: 7.0, + y_deg: 8.0 + }, + LensOp::Matrix { + m: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + }, + ] + } + ); + assert!( + printed.contains( + "ops=\"translate(1,-2) rotate(45) scale(2) scale(3,4) skew-x(5) skew-y(-6) skew(7,8) matrix(1,2,3,4,5,6)\"" + ), + "{printed}" + ); +} + +#[test] +fn layout_and_flex_enum_variants_materialize_and_canonicalize() { + let (none, printed) = + canonical(r#""#); + let none_layout = frame_layout(&none, authored_root(&none)); + assert_eq!(none_layout.mode, LayoutMode::None); + assert_eq!(none_layout.padding, EdgeInsets::all(3.0)); + assert!(!printed.contains("layout="), "{printed}"); + assert!(printed.contains("padding=\"3\""), "{printed}"); + + for (value, expected, canonical_attr) in [ + ("row", Direction::Row, None), + ("column", Direction::Column, Some("direction=\"column\"")), + ] { + let source = format!( + "" + ); + let (doc, printed) = canonical(&source); + let layout = frame_layout(&doc, authored_root(&doc)); + assert_eq!(layout.mode, LayoutMode::Flex); + assert_eq!(layout.direction, expected); + match canonical_attr { + Some(attr) => assert!(printed.contains(attr), "{printed}"), + None => assert!(!printed.contains("direction="), "{printed}"), + } + } + + for (value, expected) in [("false", false), ("true", true)] { + let source = + format!(""); + let (doc, printed) = canonical(&source); + assert_eq!(frame_layout(&doc, authored_root(&doc)).wrap, expected); + assert_eq!(printed.contains("wrap=\"true\""), expected, "{printed}"); + assert!(!printed.contains("wrap=\"false\""), "{printed}"); + } + + for (value, expected, canonical_attr) in [ + ("start", MainAlign::Start, None), + ("center", MainAlign::Center, Some("main=\"center\"")), + ("end", MainAlign::End, Some("main=\"end\"")), + ( + "space-between", + MainAlign::SpaceBetween, + Some("main=\"space-between\""), + ), + ( + "space-around", + MainAlign::SpaceAround, + Some("main=\"space-around\""), + ), + ( + "space-evenly", + MainAlign::SpaceEvenly, + Some("main=\"space-evenly\""), + ), + ] { + let source = + format!(""); + let (doc, printed) = canonical(&source); + assert_eq!(frame_layout(&doc, authored_root(&doc)).main_align, expected); + match canonical_attr { + Some(attr) => assert!(printed.contains(attr), "{printed}"), + None => assert!(!printed.contains(" main="), "{printed}"), + } + } + + for (value, expected, canonical_attr) in [ + ("start", CrossAlign::Start, None), + ("center", CrossAlign::Center, Some("cross=\"center\"")), + ("end", CrossAlign::End, Some("cross=\"end\"")), + ("stretch", CrossAlign::Stretch, Some("cross=\"stretch\"")), + ] { + let source = + format!(""); + let (doc, printed) = canonical(&source); + assert_eq!( + frame_layout(&doc, authored_root(&doc)).cross_align, + expected + ); + match canonical_attr { + Some(attr) => assert!(printed.contains(attr), "{printed}"), + None => assert!(!printed.contains(" cross="), "{printed}"), + } + } +} + +#[test] +fn gap_and_padding_grammars_materialize_and_normalize() { + let cases = [ + ("7", "8", (7.0, 7.0), EdgeInsets::all(8.0), "7", "8"), + ( + "4 9", + "1 2 3 4", + (4.0, 9.0), + EdgeInsets { + top: 1.0, + right: 2.0, + bottom: 3.0, + left: 4.0, + }, + "4 9", + "1 2 3 4", + ), + ("6 6", "5 5 5 5", (6.0, 6.0), EdgeInsets::all(5.0), "6", "5"), + ]; + + for (gap, padding, expected_gap, expected_padding, canonical_gap, canonical_padding) in cases { + let source = format!( + "" + ); + let (doc, printed) = canonical(&source); + let layout = frame_layout(&doc, authored_root(&doc)); + assert_eq!((layout.gap_main, layout.gap_cross), expected_gap); + assert_eq!(layout.padding, expected_padding); + assert!( + printed.contains(&format!("gap=\"{canonical_gap}\"")), + "{printed}" + ); + assert!( + printed.contains(&format!("padding=\"{canonical_padding}\"")), + "{printed}" + ); + } +} + +#[test] +fn flex_child_flow_grow_and_all_align_variants_materialize() { + let source = r#" + + + + + + + + + +"#; + let (doc, printed) = canonical(source); + let root = authored_root(&doc); + let children = &doc.get(root).children; + assert_eq!(children.len(), 5); + + for (&id, align) in children[..4].iter().zip([ + SelfAlign::Start, + SelfAlign::Center, + SelfAlign::End, + SelfAlign::Stretch, + ]) { + let header = &doc.get(id).header; + assert_eq!(header.flow, Flow::InFlow); + assert_eq!(header.self_align, align); + } + assert_eq!(doc.get(children[0]).header.grow, 0.0); + assert_eq!(doc.get(children[1]).header.grow, 2.0); + + let absolute = &doc.get(children[4]).header; + assert_eq!(absolute.flow, Flow::Absolute); + assert_eq!(absolute.x, AxisBinding::end(2.0)); + assert_eq!(absolute.y, AxisBinding::center(0.0)); + assert_eq!(absolute.grow, 0.0); + assert_eq!(absolute.self_align, SelfAlign::Auto); + + assert!(!printed.contains("flow=\"in\""), "{printed}"); + assert!(printed.contains("flow=\"absolute\""), "{printed}"); + assert!(!printed.contains("grow=\"0\""), "{printed}"); + assert!(printed.contains("grow=\"2\""), "{printed}"); + for value in ["start", "center", "end", "stretch"] { + assert!(printed.contains(&format!("align=\"{value}\"")), "{printed}"); + } +} diff --git a/model-v2/a/lab/tests/grida_xml_strokes.rs b/model-v2/a/lab/tests/grida_xml_strokes.rs new file mode 100644 index 0000000000..5763425155 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_strokes.rs @@ -0,0 +1,340 @@ +//! Draft 0 stroke-channel contract: repeated geometry applications, target- +//! specific attributes, ordered paints, canonical defaults, and strict errors. + +use anchor_lab::grida_xml::{self, PrintError}; +use anchor_lab::model::*; + +fn named(doc: &Document, name: &str) -> NodeId { + (0..doc.capacity() as NodeId) + .find(|id| { + doc.get_opt(*id) + .is_some_and(|node| node.header.name.as_deref() == Some(name)) + }) + .expect("named node") +} + +#[test] +fn repeated_strokes_preserve_geometry_paint_and_source_order() { + let source = r##" + + + + + + + + + + + + + + + + +"##; + let doc = grida_xml::parse(source).expect("repeated strokes parse"); + let road = doc.get(named(&doc, "road")); + assert_eq!(road.strokes.len(), 2); + + let bottom = &road.strokes[0]; + assert_eq!(bottom.width, 12.0); + assert_eq!(bottom.align, StrokeAlign::Outside); + assert_eq!(bottom.join, StrokeJoin::Round); + assert_eq!(bottom.miter_limit, 6.0); + assert_eq!( + bottom.dash_array.as_deref(), + Some(&[4.0, 2.0, 1.0, 4.0, 2.0, 1.0][..]), + "odd dash arrays repeat once" + ); + assert!(matches!(bottom.paints[0], Paint::Solid(_))); + + let top = &road.strokes[1]; + assert_eq!(top.width, 3.0); + assert_eq!(top.align, StrokeAlign::Inside); + assert_eq!(top.paints.len(), 2); + assert!(matches!(top.paints[0], Paint::Solid(_))); + assert!(matches!(top.paints[1], Paint::LinearGradient(_))); + + let printed = grida_xml::print(&doc).expect("repeated strokes print"); + let bottom_at = printed.find("width=\"12\"").unwrap(); + let top_at = printed.find("width=\"3\"").unwrap(); + assert!(bottom_at < top_at, "{printed}"); + assert!(printed.contains("dash-array=\"4 2 1 4 2 1\""), "{printed}"); + assert!(printed.contains(" + + + + x + +"##; + let doc = grida_xml::parse(source).unwrap(); + for name in ["rect", "ellipse", "text"] { + let stroke = &doc.get(named(&doc, name)).strokes[0]; + assert_eq!(stroke.width, 1.0, "{name}"); + assert_eq!(stroke.align, StrokeAlign::Inside, "{name}"); + assert_eq!(stroke.cap, StrokeCap::Butt, "{name}"); + assert_eq!(stroke.join, StrokeJoin::Miter, "{name}"); + assert_eq!(stroke.miter_limit, 4.0, "{name}"); + assert_eq!(stroke.dash_array, None, "{name}"); + } + assert_eq!( + doc.get(named(&doc, "line")).strokes[0].align, + StrokeAlign::Center + ); + + let printed = grida_xml::print(&doc).unwrap(); + assert!( + !printed.contains("width=\"1\""), + "defaults are omitted: {printed}" + ); + assert!(!printed.contains("align=\"inside\""), "{printed}"); + assert!(!printed.contains("align=\"center\""), "{printed}"); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); +} + +#[test] +fn enumerated_stroke_geometry_values_match_the_model_vocabulary() { + for (value, expected) in [ + ("inside", StrokeAlign::Inside), + ("center", StrokeAlign::Center), + ("outside", StrokeAlign::Outside), + ] { + let source = format!( + r##""## + ); + let doc = grida_xml::parse(&source).unwrap(); + assert_eq!(doc.get(named(&doc, "r")).strokes[0].align, expected); + } + + for (value, expected) in [ + ("butt", StrokeCap::Butt), + ("round", StrokeCap::Round), + ("square", StrokeCap::Square), + ] { + let source = format!( + r##""## + ); + let doc = grida_xml::parse(&source).unwrap(); + assert_eq!(doc.get(named(&doc, "line")).strokes[0].cap, expected); + } + + for (value, expected) in [ + ("miter", StrokeJoin::Miter), + ("round", StrokeJoin::Round), + ("bevel", StrokeJoin::Bevel), + ] { + let source = format!( + r##""## + ); + let doc = grida_xml::parse(&source).unwrap(); + assert_eq!(doc.get(named(&doc, "r")).strokes[0].join, expected); + } +} + +#[test] +fn default_empty_stroke_is_invalid_but_non_default_empty_geometry_survives() { + for source in [ + r#""#, + r#""#, + ] { + let error = grida_xml::parse(source).unwrap_err(); + assert!(error.to_string().contains("default empty"), "{error}"); + assert!( + error + .to_string() + .contains("indistinguishable from omission"), + "{error}" + ); + } + + let doc = grida_xml::parse( + r#""#, + ) + .expect("non-default empty geometry remains authored state"); + let stroke = &doc.get(named(&doc, "dormant")).strokes[0]; + assert!(stroke.paints.is_empty()); + assert_eq!(stroke.width, 2.0); + let printed = grida_xml::print(&doc).unwrap(); + assert!(printed.contains(""), "{printed}"); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); +} + +#[test] +fn target_specific_stroke_attributes_and_numeric_domains_are_strict() { + let cases = [ + ( + r##""##, + "cap", + ), + ( + r##""##, + "join", + ), + ( + r##"x"##, + "dash-array", + ), + ( + r##""##, + "center", + ), + ( + r##""##, + "join", + ), + ( + r##""##, + "non-negative", + ), + ( + r##""##, + "greater than zero", + ), + ( + r##""##, + "dash-array", + ), + ( + r##""##, + "all zero", + ), + ( + r##""##, + "non-negative", + ), + ( + r##""##, + "group", + ), + ( + r##""##, + "lens", + ), + ]; + + for (node, expected) in cases { + let source = format!("{node}"); + let error = grida_xml::parse(&source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` in `{error}` for {node}" + ); + } +} + +#[test] +fn stroke_structure_and_property_order_are_strict() { + let cases = [ + ( + r##""##, + "nested ", + ), + ( + r##""##, + "", + ), + ( + r##"x"##, + "must appear before", + ), + ( + r##""##, + "nested ", + ), + ]; + for (source, expected) in cases { + let error = grida_xml::parse(source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` in `{error}`" + ); + } +} + +#[test] +fn text_content_is_exact_around_fill_and_stroke_properties() { + let source = r##" + + hello +world "##; + let doc = grida_xml::parse(source).unwrap(); + let label = doc.get(named(&doc, "label")); + let Payload::Text { content, .. } = &label.payload else { + panic!("text payload") + }; + assert_eq!(content, " hello\nworld "); + assert_eq!(label.strokes.len(), 1); + let printed = grida_xml::print(&doc).unwrap(); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); +} + +#[test] +fn writer_omits_default_empty_stroke_and_refuses_invalid_stroke_state() { + let mut doc = grida_xml::parse( + r#""#, + ) + .unwrap(); + let r = named(&doc, "r"); + let payload = doc.get(r).payload.clone(); + doc.get_mut(r) + .strokes + .push(Stroke::default_for(&payload).unwrap()); + let printed = grida_xml::print(&doc).expect("default empty stroke normalizes to omission"); + assert!(!printed.contains(""##, + ) + .unwrap(); + let line_id = named(&line, "line"); + line.get_mut(line_id).strokes[0].align = StrokeAlign::Inside; + assert!(matches!( + grida_xml::print(&line), + Err(PrintError::InvalidDocument(message)) if message.contains("line") && message.contains("center") + )); +} + +#[test] +fn writer_normalizes_programmatic_odd_dash_cycles_without_losing_semantics() { + let mut doc = grida_xml::parse( + r##""##, + ) + .unwrap(); + let r = named(&doc, "r"); + doc.get_mut(r).strokes[0].dash_array = Some(vec![3.0, 2.0, 1.0]); + + let printed = grida_xml::print(&doc).expect("odd dash cycles have an even canonical form"); + assert!(printed.contains("dash-array=\"3 2 1 3 2 1\""), "{printed}"); + let reparsed = grida_xml::parse(&printed).unwrap(); + assert_eq!( + reparsed.get(named(&reparsed, "r")).strokes[0] + .dash_array + .as_deref(), + Some(&[3.0, 2.0, 1.0, 3.0, 2.0, 1.0][..]) + ); +} diff --git a/model-v2/a/lab/tests/grida_xml_suite.rs b/model-v2/a/lab/tests/grida_xml_suite.rs new file mode 100644 index 0000000000..e695a0c2e3 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_suite.rs @@ -0,0 +1,727 @@ +//! Draft 0 `.grida.xml` producer contract: structural envelope, canonical +//! vocabulary, primitive-local composition, strict roots, and E3 compatibility. + +use anchor_lab::grida_xml::{self, PrintError}; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use std::collections::BTreeMap; + +const COMPOSED: &str = r#" + + + + content + + + +"#; + +/// The envelope is not a node. The model keeps its canonical viewport root, +/// then the authored container → rect → text tree below it. A primitive owns +/// a free-positioned child coordinate space without gaining layout behavior. +#[test] +fn draft0_preserves_primitive_children_and_local_geometry() { + let doc = grida_xml::parse(COMPOSED).expect("Draft 0 parses"); + let document_root = doc.get(doc.root); + + assert_eq!(doc.root, 0); + assert_eq!( + document_root.header.x, + AxisBinding::Span { + start: 0.0, + end: 0.0 + } + ); + assert_eq!( + document_root.header.y, + AxisBinding::Span { + start: 0.0, + end: 0.0 + } + ); + assert_eq!(document_root.children.len(), 1); + + let container = document_root.children[0]; + let shape = doc.get(container).children[0]; + let text = doc.get(shape).children[0]; + assert!(matches!(doc.get(container).payload, Payload::Frame { .. })); + assert!(matches!(doc.get(shape).payload, Payload::Shape { .. })); + assert!(matches!(doc.get(text).payload, Payload::Text { .. })); + + let resolved = resolve( + &doc, + &ResolveOptions { + viewport: (1024.0, 768.0), + ..Default::default() + }, + ); + assert_eq!(resolved.xywh(doc.root), (0.0, 0.0, 1024.0, 768.0)); + assert_eq!(resolved.xywh(container), (0.0, 0.0, 800.0, 450.0)); + assert_eq!(resolved.xywh(shape), (100.0, 50.0, 200.0, 100.0)); + assert_eq!(resolved.box_of(text).x, 10.0); + assert_eq!(resolved.box_of(text).y, 12.0); + let world = resolved.world_of(text); + assert_eq!((world.e, world.f), (110.0, 62.0)); +} + +/// Canonical output names the real source format rather than the E3 +/// experiment, and normalized parse → print → parse remains a fixpoint. +#[test] +fn draft0_canonical_print_is_a_semantic_fixpoint() { + let doc = grida_xml::parse(COMPOSED).unwrap(); + let printed = grida_xml::print(&doc).unwrap(); + + assert!(printed.starts_with("\n")); + assert!(printed.contains("")); + assert!(printed.contains("")); + assert!(!printed.contains(" + + + + + content + + + +"#; + let doc = grida_xml::parse(source).expect("direct primitives parse"); + let container = doc.get(doc.root).children[0]; + let rect = doc.get(container).children[0]; + let children = &doc.get(rect).children; + + assert!(matches!( + doc.get(rect).payload, + Payload::Shape { + desc: ShapeDesc::Rect + } + )); + assert_eq!(children.len(), 3); + assert!(matches!( + doc.get(children[0]).payload, + Payload::Shape { + desc: ShapeDesc::Ellipse + } + )); + assert!(matches!( + doc.get(children[1]).payload, + Payload::Shape { + desc: ShapeDesc::Line + } + )); + assert!(matches!(doc.get(children[2]).payload, Payload::Text { .. })); + assert_eq!(doc.get(children[0]).header.name.as_deref(), Some("ellipse")); + assert_eq!(doc.get(children[1]).header.height, SizeIntent::Fixed(0.0)); + + let printed = grida_xml::print(&doc).expect("direct primitives print"); + assert!(printed.contains("")); + assert!(printed.contains("")); + assert!(printed.contains("")); + assert!(!printed.contains(""#, + ) + .unwrap_err(); + assert!(reserved.to_string().contains(" is reserved")); + + let kind_alias = grida_xml::parse( + r#""#, + ) + .unwrap_err(); + assert!(kind_alias.to_string().contains("unknown attribute `kind`")); + + for primitive in [ + r#""#, + r#""#, + r#""#, + ] { + let source = format!("{primitive}"); + let error = anchor_lab::textir::parse(&source).unwrap_err(); + assert!( + error.to_string().contains("unknown element"), + "historical TextIr must reject {primitive}: {error}" + ); + } + + let historical = anchor_lab::textir::parse( + r#""#, + ) + .expect("historical TextIr keeps "); + let printed = anchor_lab::textir::print(&historical); + assert!(printed.contains(r#""#)); +} + +#[test] +fn canonical_print_escapes_attribute_values() { + let source = r#""#; + let doc = grida_xml::parse(source).unwrap(); + let printed = grida_xml::print(&doc).unwrap(); + assert!(printed.contains("name=\"A & "B"\"")); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); +} + +#[test] +fn draft0_uses_long_constraint_vocabulary() { + let source = r#" + + + + + +"#; + let doc = grida_xml::parse(source).unwrap(); + let container = doc.get(doc.root).children[0]; + let shape = doc.get(container).children[0]; + assert_eq!( + resolve(&doc, &ResolveOptions::default()).box_of(shape).h, + 30.0 + ); + let printed = grida_xml::print(&doc).unwrap(); + for spelling in [ + "min-width=\"10\"", + "max-width=\"40\"", + "min-height=\"15\"", + "max-height=\"50\"", + "aspect-ratio=\"2:3\"", + "flip-y=\"true\"", + ] { + assert!(printed.contains(spelling), "missing {spelling}:\n{printed}"); + } + assert!(!printed.contains(" min-w=\"")); + assert!(!printed.contains(" aspect=\"")); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); +} + +#[test] +fn direct_text_whitespace_is_preserved() { + let source = + " hello\nworld "; + let doc = grida_xml::parse(source).unwrap(); + let container = doc.get(doc.root).children[0]; + let text = doc.get(container).children[0]; + match &doc.get(text).payload { + Payload::Text { content, .. } => assert_eq!(content, " hello\nworld "), + payload => panic!("expected text, got {payload:?}"), + } + let printed = grida_xml::print(&doc).unwrap(); + assert_eq!(doc, grida_xml::parse(&printed).unwrap()); + + let historical = + anchor_lab::textir::parse(" hello\nworld ") + .unwrap(); + let historical_text = historical.get(historical.root).children[0]; + match &historical.get(historical_text).payload { + Payload::Text { content, .. } => assert_eq!(content, "hello\nworld"), + payload => panic!("expected text, got {payload:?}"), + } +} + +#[test] +fn booleans_are_exactly_true_or_false() { + let cases = [ + r#""#, + r#""#, + r#""#, + r#""#, + ]; + for source in cases { + let error = grida_xml::parse(source).unwrap_err(); + assert!(error.to_string().contains("exactly `true` or `false`")); + } +} + +#[test] +fn numeric_domains_are_checked_at_parse_time() { + let cases = [ + ( + r#""#, + "width must be non-negative", + ), + ( + r#"x"#, + "size must be greater than zero", + ), + ( + r#"x"#, + "opacity must be between 0 and 1", + ), + ( + r#"x"#, + "min-width must be non-negative", + ), + ( + r#"x"#, + "aspect-ratio must be greater than zero", + ), + ]; + for (source, expected) in cases { + let error = grida_xml::parse(source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` in `{error}`" + ); + } +} + +#[test] +fn grow_gap_and_padding_are_non_negative_only_in_draft0() { + let cases = [ + ( + r#""#, + "grow must be non-negative", + ), + ( + r#""#, + "gap must be non-negative", + ), + ( + r#""#, + "gap must be non-negative", + ), + ( + r#""#, + "padding must be non-negative", + ), + ]; + for (source, expected) in cases { + let error = grida_xml::parse(source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` in `{error}`" + ); + } + + anchor_lab::textir::parse( + r#""#, + ) + .expect("historical TextIr keeps its prior numeric behavior"); +} + +#[test] +fn flex_child_attributes_are_context_strict() { + let rejected = [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ]; + for source in rejected { + assert!(grida_xml::parse(source).is_err(), "must reject: {source}"); + } + + for source in [ + r#""#, + r#""#, + ] { + grida_xml::parse(source).expect("applicable flex attributes accepted"); + } + + anchor_lab::textir::parse( + r#""#, + ) + .expect("historical TextIr retains resolver-reported applicability"); +} + +#[test] +fn strict_lens_ops_reject_empty_arguments() { + for ops in [ + "translate(1,,2)", + "translate(1,2", + "translate(1,2)scale(2)", + "translate(1,2), scale(2)", + "translate(1,2))", + ] { + let strict = + format!(""); + assert!(grida_xml::parse(&strict).is_err(), "must reject `{ops}`"); + } + + anchor_lab::textir::parse(r#""#) + .expect("historical TextIr retains empty-argument filtering"); +} + +#[test] +fn xml_declaration_is_single_and_before_the_envelope() { + for valid in [ + r#""#, + r#""#, + r#""#, + ] { + grida_xml::parse(valid).expect("supported leading declaration is accepted"); + } + + for source in [ + r#""#, + r#""#, + r#""#, + r#""#, + " \n", + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(grida_xml::parse(source).is_err(), "must reject: {source}"); + } + + anchor_lab::textir::parse(r#""#) + .expect("historical TextIr continues to ignore declaration contents"); +} + +#[test] +fn authored_root_and_shape_constraints_are_structural() { + for attr in [ + "x=\"1\"", + "y=\"1\"", + "flow=\"absolute\"", + "grow=\"1\"", + "align=\"center\"", + ] { + let source = format!(""); + let error = grida_xml::parse(&source).unwrap_err(); + assert!(error.to_string().contains("authored root ")); + } + + for source in [ + r#""#, + r#""#, + r#""#, + ] { + let doc = grida_xml::parse(source).expect("Span supplies the primitive axis"); + grida_xml::print(&doc).expect("accepted primitive sizing prints and reparses"); + } + + let cases = [ + ( + r#""#, + "require both axes supplied", + ), + ( + r#""#, + "require both axes supplied", + ), + ( + r#""#, + "must not declare height", + ), + ( + r#""#, + "must not declare min-height/max-height", + ), + ( + r#""#, + "must not declare a y Span", + ), + ( + r#""#, + "span x binding cannot also declare width", + ), + ( + r#"x"#, + "span x binding cannot also declare width/min-width/max-width", + ), + ( + r#"x"#, + "span y binding cannot also declare height/min-height/max-height", + ), + ( + r#""#, + "derived box", + ), + ( + r#""#, + "derived origin and cannot use Span", + ), + ( + r#""#, + "derived origin and cannot use Span", + ), + ( + r#""#, + "require both axes supplied", + ), + ( + r#""#, + "require both axes supplied", + ), + ( + r#""#, + "must not declare aspect-ratio", + ), + ( + r#""#, + "only valid on and ", + ), + ]; + for (source, expected) in cases { + let error = grida_xml::parse(source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` in `{error}`" + ); + } +} + +#[test] +fn fill_shorthand_normalizes_and_is_not_valid_on_derived_kinds() { + for fill in ["112233", "#GG0000", "#12"] { + let source = format!( + "" + ); + let error = grida_xml::parse(&source).unwrap_err(); + assert!(error.to_string().contains("#RGB or #RRGGBB")); + } + let group = r##""##; + assert!(grida_xml::parse(group) + .unwrap_err() + .to_string() + .contains("fill is not valid")); + + let valid = r##""##; + let printed = grida_xml::print(&grida_xml::parse(valid).unwrap()).unwrap(); + assert!(printed.contains("fill=\"#AABBCC\"")); + assert!(!printed.contains("")); +} + +/// Draft 0 has one vocabulary. Historical spellings remain available only +/// through the explicitly historical E3 surface. +#[test] +fn historical_vocabulary_is_textir_only() { + for source in [ + r#""#, + r#""#, + r#""#, + ] { + assert!( + grida_xml::parse(source).is_err(), + "Draft 0 rejected: {source}" + ); + } + + let historical = anchor_lab::textir::parse(r#""#) + .expect("historical E3 parser remains available"); + assert!(anchor_lab::textir::print(&historical).starts_with(""#, + "unsupported", + ), + ( + r#""#, + "requires version", + ), + ( + r#""#, + "multiple render roots", + ), + (r#""#, "exactly one render root"), + (r#""#, "must be inside"), + ( + r#""#, + "unknown attribute `w`", + ), + ( + r#""#, + "cannot contain child", + ), + ( + r#""#, + "only valid on ", + ), + ( + r#""#, + "root must be ", + ), + ( + r#""#, + "CDATA is not supported", + ), + ]; + + for (source, expected) in cases { + let error: grida_xml::ParseError = grida_xml::parse(source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` in `{error}`" + ); + } +} + +/// Printing cannot omit or invent model state to force a document into the +/// envelope. Non-normalized roots and forests are rejected explicitly. +#[test] +fn print_rejects_documents_outside_the_draft0_root_contract() { + let empty = DocBuilder::new().build(); + assert_eq!( + grida_xml::print(&empty), + Err(PrintError::RenderRootCount { found: 0 }) + ); + + let mut parsed = grida_xml::parse(COMPOSED).unwrap(); + parsed.get_mut(parsed.root).header.width = SizeIntent::Fixed(10.0); + assert_eq!( + grida_xml::print(&parsed), + Err(PrintError::NonCanonicalDocumentRoot) + ); + + let mut shape_root = DocBuilder::new(); + shape_root.add( + 0, + Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + assert_eq!( + grida_xml::print(&shape_root.build()), + Err(PrintError::RenderRootMustBeContainer { found: "shape" }) + ); + + let mut invalid_authored_root = grida_xml::parse(COMPOSED).unwrap(); + let authored_root = invalid_authored_root + .get(invalid_authored_root.root) + .children[0]; + invalid_authored_root.get_mut(authored_root).header.x = AxisBinding::start(10.0); + assert!(matches!( + grida_xml::print(&invalid_authored_root), + Err(PrintError::InvalidDocument(_)) + )); + + let mut derived_span = DocBuilder::new(); + let authored_root = derived_span.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(100.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let mut group_header = Header::new(SizeIntent::Auto, SizeIntent::Auto); + group_header.x = AxisBinding::Span { + start: 0.0, + end: 0.0, + }; + derived_span.add(authored_root, group_header, Payload::Group); + assert!(matches!( + grida_xml::print(&derived_span.build()), + Err(PrintError::InvalidDocument(_)) + )); +} + +#[test] +fn print_roundtrip_ignores_arena_ids_but_not_tree_integrity() { + let source = r#" + + + + + + +"#; + let mut tombstoned = grida_xml::parse(source).unwrap(); + let container = tombstoned.get(tombstoned.root).children[0]; + let removed = tombstoned.get(container).children[0]; + let survivor = tombstoned.get(container).children[1]; + tombstoned.remove_subtree(removed); + assert_eq!(tombstoned.get(container).children, vec![survivor]); + let printed = grida_xml::print(&tombstoned).expect("tombstoned ids are storage only"); + assert!(!printed.contains("name=\"A\"")); + assert!(printed.contains("name=\"B\"")); + grida_xml::parse(&printed).expect("printed survivor reparses"); + + let mut sparse = DocBuilder::new(); + let authored_root = sparse.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(100.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let mut sparse = sparse.build(); + sparse.add_child( + authored_root, + Node { + id: 9, + header: Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)), + payload: Payload::Shape { + desc: ShapeDesc::Rect, + }, + children: vec![], + fills: Paints::default(), + strokes: vec![], + }, + ); + grida_xml::print(&sparse).expect("non-DFS arena ids are storage only"); + + let canonical = DocBuilder::new().build(); + let mut relocated_root = canonical.get(canonical.root).clone(); + relocated_root.id = 7; + relocated_root.children = vec![9]; + let relocated_container = Node { + id: 9, + header: Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(100.0)), + payload: Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + children: vec![], + fills: Paints::default(), + strokes: vec![], + }; + let relocated = Document::from_map( + BTreeMap::from([(7, relocated_root), (9, relocated_container)]), + 7, + ); + grida_xml::print(&relocated).expect("root ids are storage only"); + + let mut unreachable = grida_xml::parse(COMPOSED).unwrap(); + let container = unreachable.get(unreachable.root).children[0]; + unreachable.get_mut(container).children.clear(); + assert!(matches!( + grida_xml::print(&unreachable), + Err(PrintError::InvalidDocument(_)) + )); + + let mut dead_edge = grida_xml::parse(COMPOSED).unwrap(); + dead_edge.get_mut(dead_edge.root).children[0] = 99; + assert!(matches!( + grida_xml::print(&dead_edge), + Err(PrintError::InvalidDocument(_)) + )); + + let mut dead_root = DocBuilder::new().build(); + dead_root.remove_subtree(dead_root.root); + assert!(matches!( + grida_xml::print(&dead_root), + Err(PrintError::InvalidDocument(_)) + )); +} diff --git a/model-v2/a/lab/tests/layout.rs b/model-v2/a/lab/tests/layout.rs index 26fc102492..49b57be5a3 100644 --- a/model-v2/a/lab/tests/layout.rs +++ b/model-v2/a/lab/tests/layout.rs @@ -299,3 +299,86 @@ fn l7_resolution_unquantized() { assert!((w - 33.333332).abs() < 1e-3, "fractional width kept: {w}"); let _ = resolve(&doc, &opts_visual()); // both modes agree here } + +#[test] +fn free_container_padding_insets_fixed_content_box() { + let mut b = DocBuilder::new(); + let (h, p) = frame_free(SizeIntent::Fixed(100.0), SizeIntent::Fixed(80.0)); + let p = match p { + Payload::Frame { + mut layout, + clips_content, + } => { + layout.padding = EdgeInsets { + top: 7.0, + right: 20.0, + bottom: 13.0, + left: 10.0, + }; + Payload::Frame { + layout, + clips_content, + } + } + _ => unreachable!(), + }; + let frame = b.add(0, h, p); + + let (mut child_h, child_p) = shape(20.0, 10.0); + child_h.x = AxisBinding::start(5.0); + child_h.y = AxisBinding::start(6.0); + let child = b.add(frame, child_h, child_p); + + let mut span_h = Header::new(SizeIntent::Auto, SizeIntent::Fixed(10.0)); + span_h.x = AxisBinding::Span { + start: 0.0, + end: 0.0, + }; + span_h.y = AxisBinding::start(30.0); + let span = b.add( + frame, + span_h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + + let r = run(&b.build()); + assert_rect(r.box_of(child), 15.0, 13.0, 20.0, 10.0, "padded start"); + assert_rect( + r.box_of(span), + 10.0, + 37.0, + 70.0, + 10.0, + "span fills padded content width", + ); +} + +#[test] +fn free_container_padding_insets_auto_content_box() { + let mut b = DocBuilder::new(); + let (h, p) = frame_free(SizeIntent::Auto, SizeIntent::Auto); + let p = match p { + Payload::Frame { + mut layout, + clips_content, + } => { + layout.padding = EdgeInsets::all(10.0); + Payload::Frame { + layout, + clips_content, + } + } + _ => unreachable!(), + }; + let frame = b.add(0, h, p); + let (mut child_h, child_p) = shape(20.0, 30.0); + child_h.x = AxisBinding::start(5.0); + child_h.y = AxisBinding::start(7.0); + let child = b.add(frame, child_h, child_p); + + let r = run(&b.build()); + assert_rect(r.box_of(frame), 0.0, 0.0, 45.0, 57.0, "auto padded frame"); + assert_rect(r.box_of(child), 15.0, 17.0, 20.0, 30.0, "auto padded child"); +} diff --git a/model-v2/a/lab/tests/mm_laws.rs b/model-v2/a/lab/tests/mm_laws.rs index 3ae8da0bfc..d07d8b1ea6 100644 --- a/model-v2/a/lab/tests/mm_laws.rs +++ b/model-v2/a/lab/tests/mm_laws.rs @@ -173,7 +173,8 @@ fn mm7_add_delete_restores() { header: shape(10.0, 10.0).0, payload: shape(10.0, 10.0).1, children: vec![], - fill: None, + fills: Paints::default(), + strokes: vec![], }, ); let _mid = run(&doc2); diff --git a/model-v2/a/lab/tests/svgout_paints.rs b/model-v2/a/lab/tests/svgout_paints.rs new file mode 100644 index 0000000000..db36165a98 --- /dev/null +++ b/model-v2/a/lab/tests/svgout_paints.rs @@ -0,0 +1,79 @@ +//! SVG snapshot output must either preserve the paint subset it supports or +//! reject the document. It must never choose a representative paint or invent +//! a fallback color. + +use anchor_lab::grida_xml; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use anchor_lab::svgout::{self, SvgOptions}; + +fn render(source: &str) -> Result { + let doc = grida_xml::parse(source).expect("fixture parses"); + let resolved = resolve(&doc, &ResolveOptions::default()); + svgout::render( + &doc, + &resolved, + &SvgOptions { + show_aabb: false, + width: 100.0, + height: 100.0, + }, + ) +} + +#[test] +fn empty_paint_stacks_emit_none_without_snapshot_fallbacks() { + let svg = render( + r#""#, + ) + .unwrap(); + assert!(svg.contains(r#"fill="none""#), "{svg}"); + for fallback in ["#f6f6f6", "#4a90d9", "#222"] { + assert!( + !svg.contains(fallback), + "invented fallback {fallback}: {svg}" + ); + } +} + +#[test] +fn singleton_solid_preserves_rgba8_alpha_and_visibility() { + let alpha = render( + r##""##, + ) + .unwrap(); + assert!( + alpha.contains(r##"fill="#FF0000" fill-opacity="0.5019608""##), + "{alpha}" + ); + + let hidden = render( + r##""##, + ) + .unwrap(); + assert!(hidden.contains(r#"fill="none""#), "{hidden}"); + assert!(!hidden.contains("#FF0000"), "{hidden}"); +} + +#[test] +fn rich_and_ordered_stacks_fail_instead_of_being_narrowed() { + let rich = render( + r##""##, + ) + .unwrap_err(); + assert!(rich.to_string().contains("rich paint"), "{rich}"); + + let ordered = render( + r##""##, + ) + .unwrap_err(); + assert!(ordered.to_string().contains("2 paints"), "{ordered}"); +} + +#[test] +fn authored_strokes_fail_instead_of_disappearing() { + let error = render( + r##""##, + ) + .unwrap_err(); + assert!(error.to_string().contains("authored strokes"), "{error}"); +} diff --git a/model-v2/a/spike-canvas/src/paint.rs b/model-v2/a/spike-canvas/src/paint.rs index 19a8925bb9..f55403dda5 100644 --- a/model-v2/a/spike-canvas/src/paint.rs +++ b/model-v2/a/spike-canvas/src/paint.rs @@ -10,7 +10,7 @@ use anchor_engine::paint::PaintCtx; /// same face the pre-engine painter used), or `None` — in which case text is /// skipped, exactly as before. pub fn paint_ctx() -> PaintCtx { - PaintCtx { - font: skia_safe::FontMgr::new().legacy_make_typeface(None, skia_safe::FontStyle::default()), - } + PaintCtx::new( + skia_safe::FontMgr::new().legacy_make_typeface(None, skia_safe::FontStyle::default()), + ) } diff --git a/model-v2/a/spike-canvas/src/scene.rs b/model-v2/a/spike-canvas/src/scene.rs index 2dd49956d8..7877605595 100644 --- a/model-v2/a/spike-canvas/src/scene.rs +++ b/model-v2/a/spike-canvas/src/scene.rs @@ -54,7 +54,7 @@ pub fn starter() -> (Document, NodeId) { clips_content: false, }, ); - b.node_mut(artboard).fill = Some("#FFFFFF".into()); + b.node_mut(artboard).fills = Paints::solid("#FFFFFF".into()); // Flex row: cards + a rotated card + a grow card. Under DEC-0 // (visual-only) rotation is paint: the row does NOT reflow, fill @@ -73,13 +73,13 @@ pub fn starter() -> (Document, NodeId) { clips_content: false, }, ); - b.node_mut(row).fill = Some("#F1F3F5".into()); + b.node_mut(row).fills = Paints::solid("#F1F3F5".into()); let a1 = b.add(row, named(card(70.0, 110.0), "card.a"), rect()); - b.node_mut(a1).fill = Some("#4A90D9".into()); + b.node_mut(a1).fills = Paints::solid("#4A90D9".into()); let mut rot = named(card(70.0, 110.0), "card.rot"); rot.rotation = 20.0; let a2 = b.add(row, rot, rect()); - b.node_mut(a2).fill = Some("#E2574C".into()); + b.node_mut(a2).fills = Paints::solid("#E2574C".into()); let a3 = b.add( row, named(Header::new(SizeIntent::Auto, SizeIntent::Auto), "label"), @@ -88,11 +88,11 @@ pub fn starter() -> (Document, NodeId) { font_size: 18.0, }, ); - b.node_mut(a3).fill = Some("#171A1F".into()); + b.node_mut(a3).fills = Paints::solid("#171A1F".into()); let mut grow = named(card(70.0, 110.0), "card.grow"); grow.grow = 1.0; let a4 = b.add(row, grow, rect()); - b.node_mut(a4).fill = Some("#57B894".into()); + b.node_mut(a4).fills = Paints::solid("#57B894".into()); // Hug frame: breathes when its rotated member turns. let hug = b.add( @@ -112,20 +112,20 @@ pub fn starter() -> (Document, NodeId) { clips_content: false, }, ); - b.node_mut(hug).fill = Some("#F1F3F5".into()); + b.node_mut(hug).fills = Paints::solid("#F1F3F5".into()); let h1 = b.add(hug, named(card(60.0, 90.0), "hug.a"), rect()); - b.node_mut(h1).fill = Some("#8B7BD8".into()); + b.node_mut(h1).fills = Paints::solid("#8B7BD8".into()); let mut h2h = named(card(60.0, 90.0), "hug.rot"); h2h.rotation = 30.0; let h2 = b.add(hug, h2h, rect()); - b.node_mut(h2).fill = Some("#E2A23F".into()); + b.node_mut(h2).fills = Paints::solid("#E2A23F".into()); // End-pinned badge: tracks the artboard's right edge with 0 writes. let mut badge = named(card(84.0, 48.0), "badge"); badge.x = AxisBinding::end(32.0); badge.y = AxisBinding::start(32.0); let bd = b.add(artboard, badge, rect()); - b.node_mut(bd).fill = Some("#57B894".into()); + b.node_mut(bd).fills = Paints::solid("#57B894".into()); // Span bar: stretches with the artboard, x is owned by the Span // (dragging it sideways is a TYPED error, visible in the log). @@ -139,7 +139,7 @@ pub fn starter() -> (Document, NodeId) { }; bar.y = AxisBinding::end(24.0); let br = b.add(artboard, bar, rect()); - b.node_mut(br).fill = Some("#E2A23F".into()); + b.node_mut(br).fills = Paints::solid("#E2A23F".into()); // Group: transparent-select, origin pivot, 3-write center-feel rotate. let grp = b.add( @@ -155,13 +155,13 @@ pub fn starter() -> (Document, NodeId) { Payload::Group, ); let g1 = b.add(grp, named(card(56.0, 36.0), "chip.a"), rect()); - b.node_mut(g1).fill = Some("#8B7BD8".into()); + b.node_mut(g1).fills = Paints::solid("#8B7BD8".into()); let g2 = b.add( grp, named(at(card(56.0, 36.0), 26.0, 46.0), "chip.b"), rect(), ); - b.node_mut(g2).fill = Some("#E2574C".into()); + b.node_mut(g2).fills = Paints::solid("#E2574C".into()); // Lens: the paint lane — rotates visually, layout-transparent. let lens = b.add( @@ -179,7 +179,7 @@ pub fn starter() -> (Document, NodeId) { }, ); let l1 = b.add(lens, named(card(80.0, 80.0), "lens.child"), rect()); - b.node_mut(l1).fill = Some("#4A90D9".into()); + b.node_mut(l1).fills = Paints::solid("#4A90D9".into()); // Free text. let t = b.add( @@ -193,7 +193,7 @@ pub fn starter() -> (Document, NodeId) { font_size: 24.0, }, ); - b.node_mut(t).fill = Some("#171A1F".into()); + b.node_mut(t).fills = Paints::solid("#171A1F".into()); (b.build(), artboard) } @@ -253,7 +253,7 @@ fn page(b: &mut DocBuilder, parent: NodeId, x: f32, y: f32, i: usize) -> NodeId clips_content: false, }, ); - b.node_mut(page).fill = Some("#FFFFFF".into()); + b.node_mut(page).fills = Paints::solid("#FFFFFF".into()); // header row: avatar (ellipse) · title (text) · menu (rect), space-between. let header = b.add( @@ -268,7 +268,7 @@ fn page(b: &mut DocBuilder, parent: NodeId, x: f32, y: f32, i: usize) -> NodeId desc: ShapeDesc::Ellipse, }, ); - b.node_mut(avatar).fill = Some(accent.into()); + b.node_mut(avatar).fills = Paints::solid(accent.into()); let title = b.add( header, Header::new(SizeIntent::Auto, SizeIntent::Auto), @@ -277,19 +277,19 @@ fn page(b: &mut DocBuilder, parent: NodeId, x: f32, y: f32, i: usize) -> NodeId font_size: 15.0, }, ); - b.node_mut(title).fill = Some("#171A1F".into()); + b.node_mut(title).fills = Paints::solid("#171A1F".into()); let menu = b.add(header, card(20.0, 20.0), rect()); - b.node_mut(menu).fill = Some("#CED4DA".into()); + b.node_mut(menu).fills = Paints::solid("#CED4DA".into()); // hero block (image placeholder). let hero = b.add(page, card(PAGE_INNER_W, 110.0), rect()); - b.node_mut(hero).fill = Some(accent.into()); + b.node_mut(hero).fills = Paints::solid(accent.into()); // three body lines (the last one short, left-set — a text paragraph feel). for k in 0..3 { let w = if k == 2 { 120.0 } else { PAGE_INNER_W }; let line = b.add(page, card(w, 10.0), rect()); - b.node_mut(line).fill = Some("#E9ECEF".into()); + b.node_mut(line).fills = Paints::solid("#E9ECEF".into()); } // footer: two buttons pushed to the edges. @@ -299,9 +299,9 @@ fn page(b: &mut DocBuilder, parent: NodeId, x: f32, y: f32, i: usize) -> NodeId flex_row(MainAlign::SpaceBetween, 8.0), ); let b1 = b.add(footer, card(70.0, 28.0), rect()); - b.node_mut(b1).fill = Some("#F1F3F5".into()); + b.node_mut(b1).fills = Paints::solid("#F1F3F5".into()); let b2 = b.add(footer, card(70.0, 28.0), rect()); - b.node_mut(b2).fill = Some(accent.into()); + b.node_mut(b2).fills = Paints::solid(accent.into()); // rotated "NEW" badge, absolutely pinned to the top-right corner. Out of // flex flow, end-pinned, visually rotated — exercises the paint transform. @@ -318,7 +318,7 @@ fn page(b: &mut DocBuilder, parent: NodeId, x: f32, y: f32, i: usize) -> NodeId font_size: 12.0, }, ); - b.node_mut(bg).fill = Some("#E2574C".into()); + b.node_mut(bg).fills = Paints::solid("#E2574C".into()); page } @@ -346,7 +346,7 @@ pub fn pages(cols: usize, rows: usize) -> (Document, NodeId) { clips_content: false, }, ); - b.node_mut(board).fill = Some("#F7F8F9".into()); + b.node_mut(board).fills = Paints::solid("#F7F8F9".into()); for i in 0..(cols * rows) { let (col, row) = (i % cols, i / cols); @@ -358,6 +358,18 @@ pub fn pages(cols: usize, rows: usize) -> (Document, NodeId) { (b.build(), board) } +#[cfg(test)] +mod tests { + use super::{pages, starter}; + + #[test] + fn live_scenes_remain_editable_in_the_historical_panel_ir() { + for (doc, _) in [starter(), pages(1, 1)] { + anchor_lab::textir::try_print(&doc).expect("live scene must remain TextIr-compatible"); + } + } +} + /// The live-window scene, selected by `ANCHOR_SCENE` (the `--shot` goldens /// always use `starter`, so they are unaffected): /// • unset / `starter` → the feel scene diff --git a/model-v2/engine/DATA-MODEL.md b/model-v2/engine/DATA-MODEL.md index 7cadb1abdf..448428c47a 100644 --- a/model-v2/engine/DATA-MODEL.md +++ b/model-v2/engine/DATA-MODEL.md @@ -45,30 +45,40 @@ SOA" was an overstatement, corrected below.) ## The six details -| detail | proven precedent (cited) | anchor now | decision | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Node storage** | cold = AoS + RareData (Blink `Node`/Servo `Node`); hot = `cc` domain-`Vec`s by int index / Stylo `Arc` groups | `Document` = index-arena `Vec>` + `parents`/`generations` columns; `Resolved` = SOA columns by `NodeId` | **[ALIGNED]** — cold AoS-arena (index, not pointers — Servo-ish, _more_ cache-coherent than Blink's GC pointer DOM); hot SOA-by-domain = `cc`'s property-tree shape | -| **Computed tier** | builder-sealed, **immutable**, structurally-shared snapshot, **cached across frames** (Blink `ComputedStyle`/`LayoutResult`; Stylo `Arc`) | `resolve()` builds a value `Resolved` — but **rebuilt every frame**, not shared/cached | **[SOCKET]** immutability is fine; the _persistence_ is incremental-resolve (ENG-1) | -| **Color** | never a string — `f32×4` + color-space tag is canonical (`SkColor4f`, `blink::Color`, Stylo `AbsoluteColor`); packed `u32` is interchange only | ~~`fill: Option`, re-parsed every build~~ → **`model::Color(u32)`**, read straight | **[ADOPTED]** numeric now (packed `u32`); `f32×4`+space = **[SOCKET]** (the color-management gap) | -| **Style sharing** | share **by group** COW + cache **by matched-rule-set**; RAM ∝ distinct group-values, not node count | every node owns a **full `Header`**; nothing shared (100 identical cards = 100 copies) | **[SOCKET]** the memory-scaling move — group-partition + COW, or interning | -| **Caching / incremental** | dirty-scope + one "descendant-dirty" bit up (O(depth) prune); memoize keyed by inputs; **graded** damage descriptor; isolation boundaries; **reset after frame** | **full-resolve every frame**; `DirtyClass` + `damage` exist but unconsumed | **[SOCKET]** the big one (ENG-1/ENG-2). Our `DirtyClass` ≈ the graded descriptor; `damage` ≈ dirty-scope | -| **Index vs hash** | dense engine ids → **direct array index, never hashed**; hash only at the sparse _identity seam_ (`cc` `flat_map`; Stylo rule-source ptr) | ~~`HashMap` in `resolve`~~ → **`Vec>`** indexed | **[ADOPTED]** dense-index columns, no hashing | - -### Landed this pass (both gate-verified, goldens byte-identical) - -1. **Color: `String` → `model::Color(u32)`** (`0xAARRGGBB`). The drawlist reads - the number directly — no per-node heap `String`, no per-build hex parse. The - text-IR and SVG paths convert at the boundary via `from_hex`/`to_hex` - (string as parse-input / serialize-output only — the browser rule). Measured: - `build` −31% at 100k nodes (the parse removal shows at scale). Parse logic is - byte-identical to the old `resolve_color`, so pixels are unchanged (gate: 4/4 - IDENTICAL). +| detail | proven precedent (cited) | anchor now | decision | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Node storage** | cold = AoS + RareData (Blink `Node`/Servo `Node`); hot = `cc` domain-`Vec`s by int index / Stylo `Arc` groups | `Document` = index-arena `Vec>` + `parents`/`generations` columns; `Resolved` = SOA columns by `NodeId` | **[ALIGNED]** — cold AoS-arena (index, not pointers — Servo-ish, _more_ cache-coherent than Blink's GC pointer DOM); hot SOA-by-domain = `cc`'s property-tree shape | +| **Computed tier** | builder-sealed, **immutable**, structurally-shared snapshot, **cached across frames** (Blink `ComputedStyle`/`LayoutResult`; Stylo `Arc`) | `resolve()` builds a value `Resolved` — but **rebuilt every frame**, not shared/cached | **[SOCKET]** immutability is fine; the _persistence_ is incremental-resolve (ENG-1) | +| **Paint** | colors are numeric values inside typed, ordered paints; `f32×4` + color-space tag is the wide-gamut destination (`SkColor4f`, `blink::Color`, Stylo `AbsoluteColor`) | `Node.fills: Paints`; RGBA8 `Color(u32)` plus typed gradient/image values, read straight by the drawlist | **[ADOPTED]** ordered typed paints now; `f32×4`+space = **[SOCKET]** (the color-management gap) | +| **Style sharing** | share **by group** COW + cache **by matched-rule-set**; RAM ∝ distinct group-values, not node count | every node owns a **full `Header`**; nothing shared (100 identical cards = 100 copies) | **[SOCKET]** the memory-scaling move — group-partition + COW, or interning | +| **Caching / incremental** | dirty-scope + one "descendant-dirty" bit up (O(depth) prune); memoize keyed by inputs; **graded** damage descriptor; isolation boundaries; **reset after frame** | **full-resolve every frame**; `DirtyClass` + `damage` exist but unconsumed | **[SOCKET]** the big one (ENG-1/ENG-2). Our `DirtyClass` ≈ the graded descriptor; `damage` ≈ dirty-scope | +| **Index vs hash** | dense engine ids → **direct array index, never hashed**; hash only at the sparse _identity seam_ (`cc` `flat_map`; Stylo rule-source ptr) | ~~`HashMap` in `resolve`~~ → **`Vec>`** indexed | **[ADOPTED]** dense-index columns, no hashing | + +### Landed in the original re-host pass + +These storage changes were gate-verified and golden-identical when they +landed. + +1. **Color: `String` → `Color(u32)`.** The original singleton fill became a + numeric RGBA8 value read directly by the drawlist. Text IR and SVG convert + strings only at their boundaries. Removing the per-node heap string and + per-build parse measured `build` −31% at 100k nodes. 2. **Resolve caches: `HashMap` → `Vec>`** (`union_cache`, `ops_cache`). `NodeId` is a dense arena index; hashing it is the exact - anti-pattern `cc` avoids. Correctness preserved (gate PASS, replays - deterministic, 121 lab tests). Perf-neutral on the `pages` workload (those - caches serve derived boxes, which `pages` has none of) — this one is - principled alignment, not a measured mover here. + anti-pattern `cc` avoids. Correctness remains covered by deterministic + replays and the differential/cache checks. Perf-neutral on the `pages` + workload (those caches serve derived boxes, which `pages` has none of) — + this one is principled alignment, not a measured mover here. + +### Landed in the Draft 0 pass + +**Fill: singleton color → typed `Paints`.** A node now owns the same +bottom-to-top stack projected by Grida XML: RGBA8 solids, structured gradients, +and image RIDs. Richer paints retain the parse-free hot path, but this pass is +not golden-identical: it also removes implicit frame ink and paints explicit +parent strokes after children. The legacy screenshots therefore require an +intentional oracle review; they are not evidence for or against the storage +decision. ## Corrections to the record (honesty) diff --git a/model-v2/engine/rig/fixtures/nested-rects.grida.xml b/model-v2/engine/rig/fixtures/nested-rects.grida.xml new file mode 100644 index 0000000000..ca3962ef22 --- /dev/null +++ b/model-v2/engine/rig/fixtures/nested-rects.grida.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/model-v2/engine/src/bin/gate.rs b/model-v2/engine/src/bin/gate.rs index 2b511273d1..0b6323103c 100644 --- a/model-v2/engine/src/bin/gate.rs +++ b/model-v2/engine/src/bin/gate.rs @@ -151,7 +151,7 @@ fn gate_diff() -> bool { viewport: (2000.0, 1400.0), rotation_in_flow: RotationInFlow::VisualOnly, }; - let ctx = paint::PaintCtx { font: None }; + let ctx = paint::PaintCtx::new(None); let view = Affine::scale(0.6, 0.6); // fit-ish so the frame has real draws let (w, h) = (1360, 900); let mut all = true; diff --git a/model-v2/engine/src/bin/grida_xml_render.rs b/model-v2/engine/src/bin/grida_xml_render.rs new file mode 100644 index 0000000000..5441d2e879 --- /dev/null +++ b/model-v2/engine/src/bin/grida_xml_render.rs @@ -0,0 +1,305 @@ +//! Thin file host for the Draft 0 `.grida.xml` proof. +//! +//! The format parser remains a pure `&str -> Document` boundary. This binary +//! owns path I/O, a platform typeface, the raster surface, and PNG encoding; +//! the scene itself still runs through the engine's one frame entry. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::model::{Document, NodeId, Paint, ResourceRef}; +use anchor_lab::resolve::{Report, ResolveOptions, Resolved}; +use skia_safe::{surfaces, Color, EncodedImageFormat, FontMgr, FontStyle}; + +const DEFAULT_WIDTH: i32 = 1280; +const DEFAULT_HEIGHT: i32 = 720; +const USAGE: &str = "usage: grida_xml_render [width height]"; + +fn parse_extent(value: &str, label: &str) -> Result { + let extent = value + .parse::() + .map_err(|_| format!("{label} must be a positive integer, got `{value}`"))?; + if extent <= 0 { + return Err(format!("{label} must be positive, got `{value}`")); + } + Ok(extent) +} + +fn dimensions(args: &[String]) -> Result<(i32, i32), String> { + match args { + [] => Ok((DEFAULT_WIDTH, DEFAULT_HEIGHT)), + [width, height] => Ok(( + parse_extent(width, "width")?, + parse_extent(height, "height")?, + )), + _ => Err(USAGE.into()), + } +} + +fn node_label(doc: &Document, id: NodeId) -> String { + doc.get(id) + .header + .name + .as_deref() + .map(|name| format!("`{name}`")) + .unwrap_or_else(|| format!("node {id}")) +} + +fn collect_visible_image_resources( + doc: &Document, + id: NodeId, + out: &mut Vec<(NodeId, ResourceRef)>, +) { + let node = doc.get(id); + if !node.header.active { + return; + } + for paint in node.fills.iter().filter(|paint| paint.visible()) { + if let Paint::Image(image) = paint { + out.push((id, image.image.clone())); + } + } + for stroke in node.strokes.iter().filter(|stroke| stroke.visible()) { + for paint in stroke.paints.iter().filter(|paint| paint.visible()) { + if let Paint::Image(image) = paint { + out.push((id, image.image.clone())); + } + } + } + for &child in &node.children { + collect_visible_image_resources(doc, child, out); + } +} + +fn local_resource_path(base: &Path, rid: &str) -> Result { + if rid.contains("://") || rid.starts_with("data:") { + return Err(format!( + "non-file image resource `{rid}` is not supported by this file host" + )); + } + let path = Path::new(rid); + Ok(if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }) +} + +/// Resolve and decode every image that can contribute to the frame before any +/// paint command runs. Relative RIDs are based at the source document, never +/// the process working directory; duplicate RIDs share one decoded image. +fn load_image_resources( + input: &Path, + doc: &Document, + paint_ctx: &mut PaintCtx, +) -> Result<(), String> { + let base = input.parent().unwrap_or_else(|| Path::new(".")); + let mut refs = vec![]; + collect_visible_image_resources(doc, doc.root, &mut refs); + let mut loaded = BTreeSet::new(); + for (node, resource) in refs { + let rid = match resource { + ResourceRef::Rid(rid) => rid, + ResourceRef::Hash(hash) => { + return Err(format!( + "{} content-hash image resource `{hash}` is not supported by this file host", + node_label(doc, node) + )); + } + }; + if !loaded.insert(rid.clone()) || paint_ctx.contains_image(&rid) { + continue; + } + let resolved = local_resource_path(base, &rid) + .map_err(|error| format!("{} image `{rid}`: {error}", node_label(doc, node)))?; + let bytes = std::fs::read(&resolved).map_err(|error| { + format!( + "{} image `{rid}` resolved to {}: {error}", + node_label(doc, node), + resolved.display() + ) + })?; + paint_ctx + .insert_encoded(rid.clone(), &bytes) + .map_err(|error| { + format!( + "{} image `{rid}` resolved to {}: {error}", + node_label(doc, node), + resolved.display() + ) + })?; + } + Ok(()) +} + +/// A resolver report that says intent was ignored or underdetermined is a +/// failed file render, not a warning the CLI may discard. Ordinary min/max +/// and span clamps remain valid resolved output. +fn ensure_resolved_without_errors(doc: &Document, resolved: &Resolved) -> Result<(), String> { + for report in &resolved.reports { + let (node, field, rule) = match report { + Report::IgnoredByRule { node, field, rule } + | Report::ErrorByRule { node, field, rule } => (*node, *field, *rule), + Report::Clamped { .. } => continue, + }; + return Err(format!( + "{} could not resolve `{field}`: {rule}", + node_label(doc, node) + )); + } + Ok(()) +} + +fn run() -> Result<(), String> { + let args: Vec = std::env::args().skip(1).collect(); + let [input, output, rest @ ..] = args.as_slice() else { + return Err(USAGE.into()); + }; + let (width, height) = dimensions(rest)?; + + let source = std::fs::read_to_string(input) + .map_err(|error| format!("read {}: {error}", Path::new(input).display()))?; + let doc = grida_xml::parse(&source) + .map_err(|error| format!("parse {}: {error}", Path::new(input).display()))?; + + let mut surface = surfaces::raster_n32_premul((width, height)) + .ok_or_else(|| format!("could not allocate {width}x{height} raster surface"))?; + surface.canvas().clear(Color::WHITE); + + let options = ResolveOptions { + viewport: (width as f32, height as f32), + ..Default::default() + }; + let font = FontMgr::new() + .legacy_make_typeface(None, FontStyle::default()) + .ok_or_else(|| "no platform-default typeface is available".to_string())?; + let mut paint_ctx = PaintCtx::new(Some(font)); + load_image_resources(Path::new(input), &doc, &mut paint_ctx)?; + let (resolved, _, _) = frame::render( + surface.canvas(), + &doc, + &options, + &Affine::IDENTITY, + &paint_ctx, + ); + ensure_resolved_without_errors(&doc, &resolved)?; + + let image = surface.image_snapshot(); + let png = image + .encode(None, EncodedImageFormat::PNG, None) + .ok_or_else(|| "PNG encoding failed".to_string())?; + std::fs::write(output, png.as_bytes()) + .map_err(|error| format!("write {}: {error}", Path::new(output).display()))?; + + println!( + "rendered {} -> {} ({}x{})", + Path::new(input).display(), + Path::new(output).display(), + width, + height + ); + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("grida_xml_render: {error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::{ensure_resolved_without_errors, load_image_resources, local_resource_path}; + use anchor_engine::paint::PaintCtx; + use anchor_lab::grida_xml; + use anchor_lab::resolve::{resolve, ResolveOptions}; + + fn fixture_input() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("rig/fixtures/nested-rects.grida.xml") + } + + #[test] + fn probe_fixture_resolves_without_errors() { + let doc = + grida_xml::parse(include_str!("../../rig/fixtures/nested-rects.grida.xml")).unwrap(); + let resolved = resolve(&doc, &ResolveOptions::default()); + assert_eq!(ensure_resolved_without_errors(&doc, &resolved), Ok(())); + } + + #[test] + fn resolution_guard_rejects_underdetermined_intent() { + let doc = grida_xml::parse( + r#""#, + ) + .unwrap(); + let resolved = resolve(&doc, &ResolveOptions::default()); + let error = ensure_resolved_without_errors(&doc, &resolved).unwrap_err(); + assert!( + error.contains("`centered` could not resolve `x`: End/Center pin underdetermined"), + "{error}" + ); + } + + #[test] + fn image_resources_resolve_from_the_document_directory() { + let rid = "../../../../fixtures/images/border-diamonds.png"; + let source = format!( + r#""# + ); + let doc = grida_xml::parse(&source).unwrap(); + let mut ctx = PaintCtx::new(None); + load_image_resources(&fixture_input(), &doc, &mut ctx).unwrap(); + assert!(ctx.contains_image(rid)); + } + + #[test] + fn image_resources_are_discovered_in_authored_strokes() { + let rid = "../../../../fixtures/images/border-diamonds.png"; + let source = format!( + r#""# + ); + let doc = grida_xml::parse(&source).unwrap(); + let mut ctx = PaintCtx::new(None); + load_image_resources(&fixture_input(), &doc, &mut ctx).unwrap(); + assert!(ctx.contains_image(rid)); + } + + #[test] + fn image_resource_errors_name_authored_and_resolved_locations() { + let missing = grida_xml::parse( + r#""#, + ) + .unwrap(); + let mut ctx = PaintCtx::new(None); + let error = load_image_resources(&fixture_input(), &missing, &mut ctx).unwrap_err(); + assert!(error.contains("`missing` image `./missing.png`"), "{error}"); + assert!(error.contains("rig/fixtures/./missing.png"), "{error}"); + + let corrupt = grida_xml::parse( + r#""#, + ) + .unwrap(); + let error = load_image_resources(&fixture_input(), &corrupt, &mut ctx).unwrap_err(); + assert!( + error.contains("`corrupt` image `./nested-rects.grida.xml`"), + "{error}" + ); + assert!(error.contains("could not decode"), "{error}"); + } + + #[test] + fn resource_id_shape_does_not_imply_a_hash_reference() { + let base = Path::new("document"); + assert_eq!( + local_resource_path(base, "hash:asset.png").unwrap(), + base.join("hash:asset.png") + ); + } +} diff --git a/model-v2/engine/src/bin/probe.rs b/model-v2/engine/src/bin/probe.rs index ed01c54824..a334b24eda 100644 --- a/model-v2/engine/src/bin/probe.rs +++ b/model-v2/engine/src/bin/probe.rs @@ -145,7 +145,7 @@ fn packed(n: usize) -> (Document, Vec, RectF) { } let mut doc = b.build(); for (i, &id) in ids.iter().enumerate() { - doc.get_mut(id).fill = Some((format!("#{}", palette[i % palette.len()])).into()); + doc.get_mut(id).fills = Paints::solid(format!("#{}", palette[i % palette.len()]).into()); } let aabb = RectF { x: 0.0, @@ -311,7 +311,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { // deliberately exercises the missing paint-damage channel: // damage::diff sees NO geometry change though the pixels do. let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; - doc.get_mut(target).fill = Some((format!("#{hex}")).into()); + doc.get_mut(target).fills = Paints::solid(format!("#{hex}").into()); } _ => {} }, @@ -325,7 +325,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { } else { let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; for &id in &ids[..k] { - doc.get_mut(id).fill = Some((format!("#{hex}")).into()); + doc.get_mut(id).fills = Paints::solid(format!("#{hex}").into()); } } } @@ -572,7 +572,7 @@ fn main() { // font: None → text is skipped by the executor (deterministic, no // font-availability nondeterminism), same as the gate's raster path. - let ctx = PaintCtx { font: None }; + let ctx = PaintCtx::new(None); if cache_mode { println!("== Win 1: scene raster cache (view scenarios) =="); diff --git a/model-v2/engine/src/bin/probe_gpu.rs b/model-v2/engine/src/bin/probe_gpu.rs index 467eee4882..b23d68f262 100644 --- a/model-v2/engine/src/bin/probe_gpu.rs +++ b/model-v2/engine/src/bin/probe_gpu.rs @@ -264,7 +264,7 @@ mod imp { } }; gpu.gl_info(); - let ctx = PaintCtx { font: None }; + let ctx = PaintCtx::new(None); println!( "\n{:<12} {:>8} {:>22} {:>7} {:>7}", diff --git a/model-v2/engine/src/drawlist.rs b/model-v2/engine/src/drawlist.rs index 94cdfa82c6..986112451a 100644 --- a/model-v2/engine/src/drawlist.rs +++ b/model-v2/engine/src/drawlist.rs @@ -1,21 +1,16 @@ //! ENG-2.1 · the display list — a pure, diffable projection of the //! resolved tier into paint primitives. `build(doc, resolved) -> DrawList` -//! (step 5) reads the resolved columns and the document's kinds; it never -//! reads the document's mutable intent directly and it never touches +//! (step 5) reads the resolved columns and the document's authored paint +//! intent; it never touches //! skia. The camera is NOT baked in — the executor ([`crate::paint`]) //! takes the view transform, so the same drawlist paints at any zoom and //! the host stays free. -//! -//! The item vocabulary is exactly what the current spike painter draws, -//! so the engine path reproduces it pixel-for-pixel (the re-host gate). use anchor_lab::math::Affine; -use anchor_lab::model::{Document, NodeId, Payload, ShapeDesc}; +use anchor_lab::measure::{layout_text_lines, LINE_H}; +use anchor_lab::model::{Document, NodeId, Paints, Payload, ShapeDesc, Stroke}; use anchor_lab::resolve::Resolved; - -/// A packed 0xAARRGGBB color — resolved from the node's fill (or the -/// per-kind default) at build time, so the executor does no color logic. -pub type Argb = u32; +use std::sync::Arc; /// One paint primitive, carrying the world transform copied verbatim from /// `resolved.world_of(node)` (never recomputed — pixel identity depends @@ -27,74 +22,126 @@ pub struct Item { pub kind: ItemKind, } -/// The primitive set. A frame emits a `RectFill` then a `RectStroke` -/// (two items, fill under stroke — today's order). Derived kinds -/// (group/lens) and the root emit nothing (no ink); their children are -/// still visited. +/// One materialized visual text line. The model's deterministic measurement +/// owns line topology; the drawlist carries that exact topology into paint so +/// wrapping and explicit empty lines cannot be reinterpreted by the backend. +#[derive(Debug, Clone, PartialEq)] +pub struct TextLine { + pub text: String, + pub baseline_y: f32, +} + +/// The display-list vocabulary. Scope commands are explicit so subtree opacity +/// is composited as a group and a container's content clip can end before its +/// own strokes are painted. Geometry and paint coordinates stay in node-local +/// space; the executor applies `world` and the host view. #[derive(Debug, Clone, PartialEq)] pub enum ItemKind { + BeginOpacity { + opacity: f32, + }, + EndOpacity, + BeginClipRect { + w: f32, + h: f32, + }, + EndClip, RectFill { w: f32, h: f32, - argb: Argb, + paints: Paints, + }, + OvalFill { + w: f32, + h: f32, + paints: Paints, + }, + TextFill { + lines: Arc<[TextLine]>, + font_size: f32, + paint_w: f32, + paint_h: f32, + paints: Paints, }, RectStroke { w: f32, h: f32, - argb: Argb, - stroke_width: f32, + stroke: Stroke, }, - Oval { + OvalStroke { w: f32, h: f32, - argb: Argb, + stroke: Stroke, }, - Line { + LineStroke { x1: f32, y1: f32, x2: f32, y2: f32, - width: f32, - argb: Argb, + paint_w: f32, + paint_h: f32, + stroke: Stroke, }, - /// Baseline already baked to today's `0.85 * font_size` in local space. - TextRun { - text: String, + TextStroke { + lines: Arc<[TextLine]>, font_size: f32, - baseline_y: f32, - argb: Argb, + paint_w: f32, + paint_h: f32, + stroke: Stroke, }, } /// The whole scene as an ordered primitive stream, in paint order -/// (pre-order DFS, parent ink before children). Diffable by `==`. +/// (node fill, clipped children, then authored strokes). Diffable by `==`. #[derive(Debug, Clone, Default, PartialEq)] pub struct DrawList { pub items: Vec, } -// Per-kind default fills — the exact fallbacks the spike painter uses, -// packed as 0xAARRGGBB (skia `Color::from_argb(255, r, g, b)`). -const FRAME_FILL: Argb = 0xFFFF_FFFF; // white -const FRAME_STROKE: Argb = 0xFFC9_CED4; -const SHAPE_FILL: Argb = 0xFF4A_90D9; -const TEXT_FILL: Argb = 0xFF00_0000; // black - -/// A node's fill as packed ARGB, or the per-kind default. The color is a -/// stored number now (`model::Color`), so this is a plain read — no per-build -/// hex parse (the old `resolve_color`), no `String` chase. -#[inline] -fn fill_or(fill: Option, fallback: Argb) -> Argb { - fill.map(|c| c.argb()).unwrap_or(fallback) +/// Materialize only paints that can contribute pixels. The authored stack is +/// already bottom-to-top; filtering preserves that relative order. +fn visible_paints(paints: &Paints) -> Paints { + Paints::new(paints.iter().filter(|paint| paint.visible()).cloned()) +} + +fn visible_stroke(stroke: &Stroke) -> Option { + if stroke.width <= 0.0 { + return None; + } + let paints = visible_paints(&stroke.paints); + if paints.is_empty() { + return None; + } + let mut stroke = stroke.clone(); + stroke.paints = paints; + Some(stroke) +} + +fn push(items: &mut Vec, node: NodeId, world: Affine, kind: ItemKind) { + items.push(Item { node, world, kind }); +} + +fn materialize_text_lines(content: &str, font_size: f32, resolved_width: f32) -> Arc<[TextLine]> { + let first_baseline = font_size * 0.85; + let line_advance = font_size * LINE_H; + layout_text_lines(content, font_size, Some(resolved_width)) + .into_iter() + .enumerate() + .map(|(index, text)| TextLine { + text, + baseline_y: first_baseline + index as f32 * line_advance, + }) + .collect::>() + .into() } /// Project the resolved tier into an ordered primitive stream — the pure /// `resolve -> drawlist` stage. Traversal is exactly the spike painter's -/// (`paint_node`): a hidden subtree (`world_opt == None`) prunes; the root -/// and derived kinds (group/lens) emit no ink but their children are still -/// visited; a frame emits fill then stroke; geometry is local-space -/// (`Rect::from_wh` etc.), positioned by `world` — copied verbatim, never -/// recomputed. The camera is applied later by the executor. +/// (`paint_node`): a hidden subtree (`world_opt == None`) prunes; the root and +/// derived kinds (group/lens) emit no ink but their children are still visited. +/// Node opacity scopes fill, descendants, and strokes. A frame's clip scopes +/// descendants only. Geometry is local-space, positioned by `world` — copied +/// verbatim, never recomputed. The camera is applied later by the executor. pub fn build(doc: &Document, resolved: &Resolved) -> DrawList { let mut items = Vec::new(); emit(doc, resolved, doc.root, &mut items); @@ -106,78 +153,160 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) return; // hidden subtree — pruned, children not visited }; let node = doc.get(id); + let b = resolved.box_of(id); + let text_lines = match &node.payload { + Payload::Text { content, font_size } + if node.fills.iter().any(|paint| paint.visible()) + || node.strokes.iter().any(Stroke::visible) => + { + Some(materialize_text_lines(content, *font_size, b.w)) + } + _ => None, + }; - // Root is the backdrop; derived kinds have no ink. Both still recurse. + let opacity_scope = node.header.opacity != 1.0; + if opacity_scope { + push( + items, + id, + world, + ItemKind::BeginOpacity { + opacity: node.header.opacity, + }, + ); + } + + // Root is the backdrop; derived kinds have no ink. Both still recurse and + // may establish an opacity scope around their descendants. if id != doc.root && !node.payload.box_is_derived() { - let b = resolved.box_of(id); match &node.payload { Payload::Frame { .. } => { - items.push(Item { - node: id, - world, - kind: ItemKind::RectFill { - w: b.w, - h: b.h, - argb: fill_or(node.fill, FRAME_FILL), - }, - }); - items.push(Item { - node: id, - world, - kind: ItemKind::RectStroke { - w: b.w, - h: b.h, - argb: FRAME_STROKE, - stroke_width: 1.0, - }, - }); + let paints = visible_paints(&node.fills); + if !paints.is_empty() { + push( + items, + id, + world, + ItemKind::RectFill { + w: b.w, + h: b.h, + paints, + }, + ); + } } Payload::Shape { desc } => { - let argb = fill_or(node.fill, SHAPE_FILL); - let kind = match desc { - ShapeDesc::Rect => ItemKind::RectFill { - w: b.w, - h: b.h, - argb, - }, - ShapeDesc::Ellipse => ItemKind::Oval { - w: b.w, - h: b.h, - argb, - }, - ShapeDesc::Line => ItemKind::Line { - x1: 0.0, - y1: 0.0, - x2: b.w, - y2: 0.0, - width: 2.0, - argb, - }, - }; - items.push(Item { - node: id, - world, - kind, - }); + let paints = visible_paints(&node.fills); + if !paints.is_empty() { + let kind = match desc { + ShapeDesc::Rect => Some(ItemKind::RectFill { + w: b.w, + h: b.h, + paints, + }), + ShapeDesc::Ellipse => Some(ItemKind::OvalFill { + w: b.w, + h: b.h, + paints, + }), + // Lines have no fill channel and no implicit ink. + ShapeDesc::Line => None, + }; + if let Some(kind) = kind { + push(items, id, world, kind); + } + } } - Payload::Text { content, font_size } => { - items.push(Item { - node: id, - world, - kind: ItemKind::TextRun { - text: content.clone(), - font_size: *font_size, - baseline_y: font_size * 0.85, - argb: fill_or(node.fill, TEXT_FILL), - }, - }); + Payload::Text { font_size, .. } => { + let paints = visible_paints(&node.fills); + if !paints.is_empty() { + push( + items, + id, + world, + ItemKind::TextFill { + lines: text_lines + .as_ref() + .expect("visible text fill has line topology") + .clone(), + font_size: *font_size, + paint_w: b.w, + paint_h: b.h, + paints, + }, + ); + } } // Excluded by the box_is_derived guard above. Payload::Group | Payload::Lens { .. } => unreachable!(), } } + let clip_scope = matches!( + node.payload, + Payload::Frame { + clips_content: true, + .. + } + ); + if clip_scope { + push(items, id, world, ItemKind::BeginClipRect { w: b.w, h: b.h }); + } + for &c in &node.children { emit(doc, resolved, c, items); } + + if clip_scope { + push(items, id, world, ItemKind::EndClip); + } + + if id != doc.root && !node.payload.box_is_derived() { + for stroke in node.strokes.iter().filter_map(visible_stroke) { + let kind = match &node.payload { + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect, + } => ItemKind::RectStroke { + w: b.w, + h: b.h, + stroke, + }, + Payload::Shape { + desc: ShapeDesc::Ellipse, + } => ItemKind::OvalStroke { + w: b.w, + h: b.h, + stroke, + }, + Payload::Shape { + desc: ShapeDesc::Line, + } => ItemKind::LineStroke { + x1: 0.0, + y1: 0.0, + x2: b.w, + y2: 0.0, + paint_w: b.w, + paint_h: b.h, + stroke, + }, + Payload::Text { font_size, .. } => ItemKind::TextStroke { + lines: text_lines + .as_ref() + .expect("visible text stroke has line topology") + .clone(), + font_size: *font_size, + paint_w: b.w, + paint_h: b.h, + stroke, + }, + Payload::Group | Payload::Lens { .. } => unreachable!(), + }; + push(items, id, world, kind); + } + } + + if opacity_scope { + push(items, id, world, ItemKind::EndOpacity); + } } diff --git a/model-v2/engine/src/paint.rs b/model-v2/engine/src/paint.rs index ff982849ac..710df285d1 100644 --- a/model-v2/engine/src/paint.rs +++ b/model-v2/engine/src/paint.rs @@ -6,16 +6,113 @@ //! form the current spike painter uses — pixel identity is a property of //! doing the same float ops in the same order, not a tolerance. +use std::collections::BTreeMap; + use anchor_lab::math::Affine; -use skia_safe::{Canvas, Color, Font, ImageInfo, Matrix, Paint, PaintStyle, Rect}; +use anchor_lab::model::{ + Alignment, BlendMode, BoxFit, DiamondGradientPaint, GradientStop, ImageFilters, ImagePaint, + ImagePaintFit, LinearGradientPaint, Paint as ModelPaint, RadialGradientPaint, ResourceRef, + Stroke, StrokeAlign, StrokeCap, StrokeJoin, SweepGradientPaint, TileMode, +}; +use skia_safe::canvas::{SaveLayerFlags, SaveLayerRec}; +use skia_safe::gradient_shader::{Gradient, GradientColors, Interpolation}; +use skia_safe::{ + image::CachingHint, path_effect::PathEffect, shaders, stroke_rec::InitStyle, Blender, Canvas, + Color, CubicResampler, Data, Font, Image, ImageInfo, Matrix, Paint, PaintCap, PaintJoin, + PaintStyle, Path, PathBuilder, PathDirection, PathOp, Rect, SamplingOptions, Shader, StrokeRec, +}; -use crate::drawlist::{Argb, DrawList, ItemKind}; +use crate::drawlist::{DrawList, ItemKind, TextLine}; -/// Host-supplied paint state the drawlist does not carry: the resolved -/// typeface (mirrors the spike painter's field; `None` skips text, which -/// is today's behavior when no system face is found). +/// Host-supplied paint state the pure drawlist does not carry: resolved fonts +/// and decoded image resources. Images are keyed by the authored RID so path +/// resolution remains a host concern and the drawlist stays I/O-free. +#[derive(Default)] pub struct PaintCtx { pub font: Option, + images: BTreeMap, +} + +impl PaintCtx { + pub fn new(font: Option) -> Self { + Self { + font, + images: BTreeMap::new(), + } + } + + /// Register an already-decoded image under the exact authored resource id. + pub fn insert_image(&mut self, rid: impl Into, image: Image) { + let image = image.with_default_mipmaps().unwrap_or(image); + self.images.insert(rid.into(), image); + } + + /// Eagerly decode encoded PNG/JPEG/WebP bytes and register them under `rid`. + pub fn insert_encoded(&mut self, rid: impl Into, bytes: &[u8]) -> Result<(), String> { + let rid = rid.into(); + let image = Image::from_encoded(Data::new_copy(bytes)) + .ok_or_else(|| format!("could not decode image resource `{rid}`"))?; + let image = image + .make_raster_image(None, CachingHint::Allow) + .ok_or_else(|| format!("could not decode image resource `{rid}`"))?; + self.insert_image(rid, image); + Ok(()) + } + + pub fn contains_image(&self, rid: &str) -> bool { + self.images.contains_key(rid) + } + + fn image(&self, rid: &str) -> Option<&Image> { + self.images.get(rid) + } +} + +#[cfg(test)] +mod paint_ctx_tests { + use super::{sk_paint, PaintBox, PaintCtx}; + use anchor_lab::model::{ + Color as ModelColor, GradientStop, LinearGradientPaint, Paint as ModelPaint, + }; + + #[test] + fn encoded_resources_are_eager_raster_images() { + const IMAGE: &[u8] = include_bytes!("../../../fixtures/images/border-diamonds.png"); + let mut ctx = PaintCtx::new(None); + ctx.insert_encoded("fixture.png", IMAGE).unwrap(); + let image = ctx.image("fixture.png").unwrap(); + assert!( + !image.is_lazy_generated(), + "resource registration must finish pixel decode before rendering" + ); + } + + #[test] + fn shader_paint_opacity_remains_float_precision() { + let opacity = 0.123_456_7; + let gradient = LinearGradientPaint { + opacity, + stops: vec![ + GradientStop { + offset: 0.0, + color: ModelColor::BLACK, + }, + GradientStop { + offset: 1.0, + color: ModelColor(0xFFFF_FFFF), + }, + ], + ..Default::default() + }; + let model = ModelPaint::LinearGradient(gradient); + let paint = sk_paint( + &model, + PaintBox::from_size(10.0, 10.0), + &PaintCtx::new(None), + ) + .expect("valid gradient paint"); + assert_eq!(paint.alpha_f().to_bits(), opacity.to_bits()); + } } /// Row-major `Affine` -> skia `Matrix`, byte-identical to the spike @@ -24,83 +121,592 @@ fn skia_matrix(t: &Affine) -> Matrix { Matrix::new_all(t.a, t.c, t.e, t.b, t.d, t.f, 0.0, 0.0, 1.0) } -/// Packed 0xAARRGGBB -> skia `Color`. Colors are already opaque and resolved -/// at build time, so this is a pure repack. -fn color(argb: Argb) -> Color { - Color::new(argb) +fn sk_blend_mode(mode: BlendMode) -> skia_safe::BlendMode { + match mode { + BlendMode::Normal => skia_safe::BlendMode::SrcOver, + BlendMode::Multiply => skia_safe::BlendMode::Multiply, + BlendMode::Screen => skia_safe::BlendMode::Screen, + BlendMode::Overlay => skia_safe::BlendMode::Overlay, + BlendMode::Darken => skia_safe::BlendMode::Darken, + BlendMode::Lighten => skia_safe::BlendMode::Lighten, + BlendMode::ColorDodge => skia_safe::BlendMode::ColorDodge, + BlendMode::ColorBurn => skia_safe::BlendMode::ColorBurn, + BlendMode::HardLight => skia_safe::BlendMode::HardLight, + BlendMode::SoftLight => skia_safe::BlendMode::SoftLight, + BlendMode::Difference => skia_safe::BlendMode::Difference, + BlendMode::Exclusion => skia_safe::BlendMode::Exclusion, + BlendMode::Hue => skia_safe::BlendMode::Hue, + BlendMode::Saturation => skia_safe::BlendMode::Saturation, + BlendMode::Color => skia_safe::BlendMode::Color, + BlendMode::Luminosity => skia_safe::BlendMode::Luminosity, + } +} + +fn sk_tile_mode(mode: TileMode) -> skia_safe::TileMode { + match mode { + TileMode::Clamp => skia_safe::TileMode::Clamp, + TileMode::Repeated => skia_safe::TileMode::Repeat, + TileMode::Mirror => skia_safe::TileMode::Mirror, + TileMode::Decal => skia_safe::TileMode::Decal, + } +} + +fn gradient_stops(stops: &[GradientStop]) -> (Vec, Vec) { + let colors = stops + .iter() + .map(|stop| skia_safe::Color4f::from(Color::new(stop.color.argb()))) + .collect(); + let positions = stops.iter().map(|stop| stop.offset).collect(); + (colors, positions) +} + +fn gradient<'a>( + colors: &'a [skia_safe::Color4f], + positions: &'a [f32], + tile_mode: skia_safe::TileMode, +) -> Gradient<'a> { + Gradient::new( + GradientColors::new(colors, Some(positions), tile_mode, None), + Interpolation::default(), + ) +} + +/// Node-local box used by every non-solid paint. A degenerate axis becomes a +/// centered one-pixel interval so line and zero-axis paints retain a stable, +/// finite unit-space mapping. +#[derive(Debug, Clone, Copy)] +struct PaintBox { + x: f32, + y: f32, + w: f32, + h: f32, +} + +impl PaintBox { + fn from_size(w: f32, h: f32) -> Self { + let (x, w) = if w == 0.0 { (-0.5, 1.0) } else { (0.0, w) }; + let (y, h) = if h == 0.0 { (-0.5, 1.0) } else { (0.0, h) }; + PaintBox { x, y, w, h } + } +} + +fn paint_box_matrix(paint_box: PaintBox, transform: &Affine) -> Matrix { + let mut matrix = Matrix::new_all( + paint_box.w, + 0.0, + paint_box.x, + 0.0, + paint_box.h, + paint_box.y, + 0.0, + 0.0, + 1.0, + ); + matrix.pre_concat(&skia_matrix(transform)); + matrix +} + +/// Convert the model's centered-normalized gradient point to UV for Skia. +/// The f64 intermediate avoids overflowing or needlessly rounding finite f32 +/// model values before the result returns to Skia's f32 coordinate space. +fn alignment_uv(alignment: Alignment) -> (f32, f32) { + ( + ((f64::from(alignment.0) + 1.0) * 0.5) as f32, + ((f64::from(alignment.1) + 1.0) * 0.5) as f32, + ) +} + +fn linear_gradient_shader(paint: &LinearGradientPaint, paint_box: PaintBox) -> Option { + let (colors, positions) = gradient_stops(&paint.stops); + let from = alignment_uv(paint.xy1); + let to = alignment_uv(paint.xy2); + let stops = gradient(&colors, &positions, sk_tile_mode(paint.tile_mode)); + let matrix = paint_box_matrix(paint_box, &paint.transform); + shaders::linear_gradient((from, to), &stops, Some(&matrix)) +} + +fn radial_gradient_shader(paint: &RadialGradientPaint, paint_box: PaintBox) -> Option { + let (colors, positions) = gradient_stops(&paint.stops); + let stops = gradient(&colors, &positions, sk_tile_mode(paint.tile_mode)); + let matrix = paint_box_matrix(paint_box, &paint.transform); + shaders::radial_gradient(((0.5, 0.5), 0.5), &stops, Some(&matrix)) +} + +fn sweep_gradient_shader(paint: &SweepGradientPaint, paint_box: PaintBox) -> Option { + let (colors, positions) = gradient_stops(&paint.stops); + let stops = gradient(&colors, &positions, skia_safe::TileMode::Clamp); + let matrix = paint_box_matrix(paint_box, &paint.transform); + shaders::sweep_gradient((0.5, 0.5), (0.0, 360.0), &stops, Some(&matrix)) +} + +fn diamond_gradient_shader(paint: &DiamondGradientPaint, paint_box: PaintBox) -> Option { + let (colors, positions) = gradient_stops(&paint.stops); + let stops = gradient(&colors, &positions, skia_safe::TileMode::Clamp); + let ramp = shaders::linear_gradient(((0.0, 0.0), (1.0, 0.0)), &stops, None)?; + const SKSL: &str = r#" + uniform shader gradient; + half4 main(float2 coord) { + float2 p = coord - float2(0.5, 0.5); + float t = (abs(p.x) + abs(p.y)) * 2.0; + t = clamp(t, 0.0, 1.0); + return gradient.eval(float2(t, 0.0)); + } + "#; + let effect = skia_safe::RuntimeEffect::make_for_shader(SKSL, None).ok()?; + let matrix = paint_box_matrix(paint_box, &paint.transform); + effect.make_shader(Data::new_copy(&[]), &[ramp.into()], Some(&matrix)) +} + +fn image_fit_matrix(image: &Image, paint_box: PaintBox, fit: BoxFit) -> Matrix { + let iw = image.width() as f32; + let ih = image.height() as f32; + let w = paint_box.w; + let h = paint_box.h; + let (sx, sy) = match fit { + BoxFit::Contain => { + let scale = (w / iw).min(h / ih); + (scale, scale) + } + BoxFit::Cover => { + let scale = (w / iw).max(h / ih); + (scale, scale) + } + BoxFit::Fill => (w / iw, h / ih), + BoxFit::None => (1.0, 1.0), + }; + let tx = paint_box.x + (w - iw * sx) * 0.5; + let ty = paint_box.y + (h - ih * sy) * 0.5; + Matrix::new_all(sx, 0.0, tx, 0.0, sy, ty, 0.0, 0.0, 1.0) +} + +fn image_shader(paint: &ImagePaint, paint_box: PaintBox, ctx: &PaintCtx) -> Option { + if paint.quarter_turns != 0 + || paint.alignment != anchor_lab::model::Alignment::CENTER + || paint.filters != ImageFilters::default() + { + return None; + } + let ImagePaintFit::Fit(fit) = paint.fit else { + return None; + }; + let rid = match &paint.image { + ResourceRef::Rid(rid) | ResourceRef::Hash(rid) => rid, + }; + let image = ctx.image(rid)?; + let matrix = image_fit_matrix(image, paint_box, fit); + let sampling = SamplingOptions::from(CubicResampler::mitchell()); + let shader = image.to_shader( + Some((skia_safe::TileMode::Decal, skia_safe::TileMode::Decal)), + sampling, + Some(&matrix), + )?; + Some(shader) +} + +/// Materialize one model paint. The caller draws these in list order instead +/// of precomposing a stack: each entry's blend mode must see the actual canvas +/// result of the paints below it, including the scene backdrop. +fn sk_paint(model: &ModelPaint, paint_box: PaintBox, ctx: &PaintCtx) -> Option { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_blend_mode(sk_blend_mode(model.blend_mode())); + match model { + ModelPaint::Solid(solid) => { + paint.set_color(Color::new(solid.color.argb())); + } + ModelPaint::LinearGradient(model) => { + paint.set_shader(linear_gradient_shader(model, paint_box)?); + } + ModelPaint::RadialGradient(model) => { + paint.set_shader(radial_gradient_shader(model, paint_box)?); + } + ModelPaint::SweepGradient(model) => { + paint.set_shader(sweep_gradient_shader(model, paint_box)?); + } + ModelPaint::DiamondGradient(model) => { + paint.set_shader(diamond_gradient_shader(model, paint_box)?); + } + ModelPaint::Image(model) => { + paint.set_shader(image_shader(model, paint_box, ctx)?); + } + } + // Solids store opacity in their RGBA8 color. Gradient and image opacity + // remains the model's independent f32 value and must not be quantized into + // an 8-bit shader mask before stack compositing. + if !matches!(model, ModelPaint::Solid(_)) { + paint.set_alpha_f(model.opacity().clamp(0.0, 1.0)); + } + Some(paint) } -/// Replay a [`DrawList`] onto a skia canvas under `view`. Each item composes -/// `view.then(&item.world)` — the exact call and operands the spike painter -/// used (`item.world` is the resolver's world, copied verbatim) — sets it as -/// the absolute matrix, and issues the same skia draw with the same final -/// `Paint` state. Per-item save/restore is pixel-neutral because the matrix -/// is set absolutely each time; a frame's fill and stroke are two items with -/// the same world, so they land identically to the painter's single-save -/// fill-then-stroke. +fn sk_stroke_cap(cap: StrokeCap) -> PaintCap { + match cap { + StrokeCap::Butt => PaintCap::Butt, + StrokeCap::Round => PaintCap::Round, + StrokeCap::Square => PaintCap::Square, + } +} + +fn sk_stroke_join(join: StrokeJoin) -> PaintJoin { + match join { + StrokeJoin::Miter => PaintJoin::Miter, + StrokeJoin::Round => PaintJoin::Round, + StrokeJoin::Bevel => PaintJoin::Bevel, + } +} + +/// Normalize an authored dash array to the even-length form Skia requires. +/// Invalid/non-finite or all-zero programmatic values produce no geometry; +/// the XML boundary rejects those values before they reach this stage. +fn normalized_dash_array(values: &[f32]) -> Option> { + if values + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + || values.iter().all(|value| *value == 0.0) + { + return None; + } + let mut normalized = values.to_vec(); + if normalized.len() % 2 == 1 { + normalized.extend_from_slice(values); + } + Some(normalized) +} + +/// Convert a stroke application into filled geometry so every existing paint +/// variant (including images and gradients) follows the same ordered painter +/// path. Open contours are necessarily centered; inside/outside are defined +/// only for closed outlines. +fn stroke_geometry(source: &Path, stroke: &Stroke) -> Path { + let align = if source.is_last_contour_closed() { + stroke.align + } else { + StrokeAlign::Center + }; + let stroke_width = match align { + StrokeAlign::Center => stroke.width, + StrokeAlign::Inside | StrokeAlign::Outside => stroke.width * 2.0, + }; + + let mut path_to_stroke = source.clone(); + if let Some(values) = stroke.dash_array.as_deref() { + if !values.is_empty() { + let Some(intervals) = normalized_dash_array(values) else { + return Path::new(); + }; + let Some(effect) = PathEffect::dash(&intervals, 0.0) else { + return Path::new(); + }; + let filter_rec = StrokeRec::new(InitStyle::Hairline); + let Some((dashed, _)) = effect.filter_path(source, &filter_rec, source.bounds()) else { + return Path::new(); + }; + path_to_stroke = dashed.snapshot(); + } + } + + let mut record = StrokeRec::new(InitStyle::Hairline); + record.set_stroke_style(stroke_width, false); + record.set_stroke_params( + sk_stroke_cap(stroke.cap), + sk_stroke_join(stroke.join), + stroke.miter_limit, + ); + let mut builder = PathBuilder::new(); + if !record.apply_to_path(&mut builder, &path_to_stroke) { + return Path::new(); + } + let outline = builder.snapshot(); + match align { + StrokeAlign::Center => outline, + StrokeAlign::Inside => { + skia_safe::op(&outline, source, PathOp::Intersect).unwrap_or_default() + } + StrokeAlign::Outside => { + skia_safe::op(&outline, source, PathOp::Difference).unwrap_or_default() + } + } +} + +fn rect_path(w: f32, h: f32) -> Path { + let mut builder = PathBuilder::new(); + builder.add_rect(Rect::from_wh(w, h), Some(PathDirection::CW), Some(0)); + builder.snapshot() +} + +fn oval_path(w: f32, h: f32) -> Path { + let mut builder = PathBuilder::new(); + // Explicit start index keeps dash origin at the rightmost point across + // Skia versions (the library default changed historically). + builder.add_oval(Rect::from_wh(w, h), Some(PathDirection::CW), Some(1)); + builder.snapshot() +} + +fn line_path(x1: f32, y1: f32, x2: f32, y2: f32) -> Path { + let mut builder = PathBuilder::new(); + builder.add_line((x1, y1), (x2, y2)); + builder.snapshot() +} + +fn draw_stroke( + canvas: &Canvas, + source: &Path, + stroke: &Stroke, + paint_box: PaintBox, + ctx: &PaintCtx, +) { + let geometry = stroke_geometry(source, stroke); + if geometry.is_empty() { + return; + } + for model in stroke.paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + canvas.draw_path(&geometry, &paint); + } + } +} + +/// Use Skia's native stroke rasterization for centered strokes. Converting a +/// centered stroke into filled outline geometry is semantically unnecessary +/// and changes edge coverage relative to the native primitive operations. +fn native_stroke_paint( + model: &ModelPaint, + stroke: &Stroke, + paint_box: PaintBox, + ctx: &PaintCtx, +) -> Option { + let mut paint = sk_paint(model, paint_box, ctx)?; + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(stroke.width); + paint.set_stroke_cap(sk_stroke_cap(stroke.cap)); + paint.set_stroke_join(sk_stroke_join(stroke.join)); + paint.set_stroke_miter(stroke.miter_limit); + if let Some(values) = stroke.dash_array.as_deref() { + if !values.is_empty() { + let intervals = normalized_dash_array(values)?; + paint.set_path_effect(PathEffect::dash(&intervals, 0.0)?); + } + } + Some(paint) +} + +fn draw_native_centered_stroke( + stroke: &Stroke, + paint_box: PaintBox, + ctx: &PaintCtx, + mut draw: impl FnMut(&Paint), +) { + for model in stroke.paints.iter() { + if let Some(paint) = native_stroke_paint(model, stroke, paint_box, ctx) { + draw(&paint); + } + } +} + +fn text_path(lines: &[TextLine], font: &Font) -> Path { + let mut builder = PathBuilder::new(); + for line in lines { + let path = Path::from_str(line.text.as_str(), (0.0, line.baseline_y), font); + builder.add_path(&path); + } + builder.snapshot() +} + +/// Replay a [`DrawList`] onto a skia canvas under `view`. Drawing items compose +/// `view.then(&item.world)` and set that matrix absolutely. Balanced scope +/// commands persist across drawing items: opacity opens a backdrop-preserving +/// group layer, while a content clip saves the node-local rectangular clip +/// until `EndClip`. pub fn execute(canvas: &Canvas, list: &DrawList, view: &Affine, ctx: &PaintCtx) { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Scope { + Opacity, + Clip, + } + + let initial_save_count = canvas.save_count(); + let mut scopes = Vec::new(); for item in &list.items { let total = view.then(&item.world); - canvas.save(); - canvas.set_matrix(&skia_matrix(&total).into()); match &item.kind { - ItemKind::RectFill { w, h, argb } => { - let mut p = Paint::default(); - p.set_anti_alias(true); - p.set_color(color(*argb)); - canvas.draw_rect(Rect::from_wh(*w, *h), &p); + ItemKind::BeginOpacity { opacity } => { + // Copy the current backdrop into the group layer so descendant + // paint blend modes see the same accumulated result they would + // see without node opacity. On restore, arithmetic blending + // computes `opacity * group + (1-opacity) * backdrop` directly + // in premultiplied space. Plain SrcOver alpha would double a + // translucent backdrop copied into the source layer. + let opacity = opacity.clamp(0.0, 1.0); + let mut restore_paint = Paint::default(); + restore_paint.set_blender( + Blender::arithmetic(0.0, opacity, 1.0 - opacity, 0.0, true) + .expect("finite opacity produces an arithmetic blender"), + ); + let layer = SaveLayerRec::default() + .paint(&restore_paint) + .flags(SaveLayerFlags::INIT_WITH_PREVIOUS); + canvas.save_layer(&layer); + scopes.push(Scope::Opacity); + } + ItemKind::EndOpacity => { + let scope = scopes.pop(); + debug_assert_eq!(scope, Some(Scope::Opacity)); + if scope.is_some() { + canvas.restore(); + } } - ItemKind::RectStroke { - w, - h, - argb, - stroke_width, + ItemKind::BeginClipRect { w, h } => { + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + canvas.clip_rect(Rect::from_wh(*w, *h), None, false); + scopes.push(Scope::Clip); + } + ItemKind::EndClip => { + let scope = scopes.pop(); + debug_assert_eq!(scope, Some(Scope::Clip)); + if scope.is_some() { + canvas.restore(); + } + } + ItemKind::RectFill { w, h, paints } => { + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + let paint_box = PaintBox::from_size(*w, *h); + for model in paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + canvas.draw_rect(Rect::from_wh(*w, *h), &paint); + } + } + canvas.restore(); + } + ItemKind::OvalFill { w, h, paints } => { + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + let paint_box = PaintBox::from_size(*w, *h); + for model in paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + canvas.draw_oval(Rect::from_wh(*w, *h), &paint); + } + } + canvas.restore(); + } + ItemKind::TextFill { + lines, + font_size, + paint_w, + paint_h, + paints, } => { - let mut p = Paint::default(); - p.set_anti_alias(true); - p.set_style(PaintStyle::Stroke); - p.set_stroke_width(*stroke_width); - p.set_color(color(*argb)); - canvas.draw_rect(Rect::from_wh(*w, *h), &p); + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + if let Some(tf) = &ctx.font { + let font = Font::new(tf.clone(), *font_size); + let paint_box = PaintBox::from_size(*paint_w, *paint_h); + for model in paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + for line in lines.iter() { + canvas.draw_str( + line.text.as_str(), + (0.0, line.baseline_y), + &font, + &paint, + ); + } + } + } + } + canvas.restore(); } - ItemKind::Oval { w, h, argb } => { - let mut p = Paint::default(); - p.set_anti_alias(true); - p.set_color(color(*argb)); - canvas.draw_oval(Rect::from_wh(*w, *h), &p); + ItemKind::RectStroke { w, h, stroke } => { + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + let paint_box = PaintBox::from_size(*w, *h); + if stroke.align == StrokeAlign::Center { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + canvas.draw_rect(Rect::from_wh(*w, *h), paint); + }); + } else { + draw_stroke(canvas, &rect_path(*w, *h), stroke, paint_box, ctx); + } + canvas.restore(); } - ItemKind::Line { + ItemKind::OvalStroke { w, h, stroke } => { + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + let paint_box = PaintBox::from_size(*w, *h); + if stroke.align == StrokeAlign::Center { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + canvas.draw_oval(Rect::from_wh(*w, *h), paint); + }); + } else { + draw_stroke(canvas, &oval_path(*w, *h), stroke, paint_box, ctx); + } + canvas.restore(); + } + ItemKind::LineStroke { x1, y1, x2, y2, - width, - argb, + paint_w, + paint_h, + stroke, } => { - let mut p = Paint::default(); - p.set_anti_alias(true); - p.set_style(PaintStyle::Stroke); - p.set_stroke_width(*width); - p.set_color(color(*argb)); - canvas.draw_line((*x1, *y1), (*x2, *y2), &p); + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + let paint_box = PaintBox::from_size(*paint_w, *paint_h); + if stroke.align == StrokeAlign::Center { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + canvas.draw_line((*x1, *y1), (*x2, *y2), paint); + }); + } else { + draw_stroke( + canvas, + &line_path(*x1, *y1, *x2, *y2), + stroke, + paint_box, + ctx, + ); + } + canvas.restore(); } - ItemKind::TextRun { - text, + ItemKind::TextStroke { + lines, font_size, - baseline_y, - argb, + paint_w, + paint_h, + stroke, } => { + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); if let Some(tf) = &ctx.font { let font = Font::new(tf.clone(), *font_size); - let mut p = Paint::default(); - p.set_anti_alias(true); - p.set_color(color(*argb)); - canvas.draw_str(text.as_str(), (0.0, *baseline_y), &font, &p); + let paint_box = PaintBox::from_size(*paint_w, *paint_h); + if stroke.align == StrokeAlign::Center { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + for line in lines.iter() { + canvas.draw_str( + line.text.as_str(), + (0.0, line.baseline_y), + &font, + paint, + ); + } + }); + } else { + let source = text_path(lines, &font); + draw_stroke(canvas, &source, stroke, paint_box, ctx); + } } + canvas.restore(); } } - canvas.restore(); } + debug_assert!(scopes.is_empty(), "unclosed drawlist scopes: {scopes:?}"); + debug_assert_eq!(canvas.save_count(), initial_save_count); + // Protect host state even if a hand-authored DrawList violates the internal + // balancing invariant in a release build. + canvas.restore_to_count(initial_save_count); } /// Render a drawlist to a fresh raster surface and return its premultiplied diff --git a/model-v2/engine/tests/cache.rs b/model-v2/engine/tests/cache.rs index c0e99cc860..ba8a794759 100644 --- a/model-v2/engine/tests/cache.rs +++ b/model-v2/engine/tests/cache.rs @@ -21,7 +21,7 @@ fn opts() -> ResolveOptions { } fn ctx() -> PaintCtx { - PaintCtx { font: None } + PaintCtx::new(None) } /// root + a handful of free shapes (one rotated) at known world positions. @@ -41,9 +41,9 @@ fn scene() -> Document { let (h3, p3) = mk(360.0, 120.0, 90.0, 50.0, 0.0, ShapeDesc::Ellipse); let e = b.add(0, h3, p3); let mut doc = b.build(); - doc.get_mut(a).fill = Some("#4A90D9".into()); - doc.get_mut(c).fill = Some("#E2574C".into()); - doc.get_mut(e).fill = Some("#57B894".into()); + doc.get_mut(a).fills = Paints::solid("#4A90D9".into()); + doc.get_mut(c).fills = Paints::solid("#E2574C".into()); + doc.get_mut(e).fills = Paints::solid("#57B894".into()); doc } diff --git a/model-v2/engine/tests/drawlist.rs b/model-v2/engine/tests/drawlist.rs index 85576cb40a..631af0587a 100644 --- a/model-v2/engine/tests/drawlist.rs +++ b/model-v2/engine/tests/drawlist.rs @@ -1,8 +1,6 @@ -//! ENG-2.1 · the drawlist is a pure, deterministic projection whose -//! traversal and item vocabulary reproduce the spike painter exactly. This -//! guards the LOGIC (order, pruning, derived/root skip, color resolution, -//! verbatim world); pixel identity on the real starter scene is proven in -//! the spike A/B (step 6). +//! ENG-2.1 · the drawlist is a pure, deterministic projection. These data +//! tests guard ordering, balanced scopes, pruning, authored paint transfer, +//! and verbatim resolver transforms. use anchor_engine::drawlist::{build, ItemKind}; use anchor_lab::model::*; @@ -17,11 +15,29 @@ fn opts() -> ResolveOptions { fn tag(k: &ItemKind) -> &'static str { match k { + ItemKind::BeginOpacity { .. } => "opacity-begin", + ItemKind::EndOpacity => "opacity-end", + ItemKind::BeginClipRect { .. } => "clip-begin", + ItemKind::EndClip => "clip-end", ItemKind::RectFill { .. } => "rectfill", ItemKind::RectStroke { .. } => "rectstroke", - ItemKind::Oval { .. } => "oval", - ItemKind::Line { .. } => "line", - ItemKind::TextRun { .. } => "text", + ItemKind::OvalFill { .. } => "ovalfill", + ItemKind::OvalStroke { .. } => "ovalstroke", + ItemKind::LineStroke { .. } => "linestroke", + ItemKind::TextFill { .. } => "textfill", + ItemKind::TextStroke { .. } => "textstroke", + } +} + +fn solid_stroke(color: Color, width: f32, align: StrokeAlign) -> Stroke { + Stroke { + paints: Paints::solid(color), + width, + align, + cap: StrokeCap::Butt, + join: StrokeJoin::Miter, + miter_limit: 4.0, + dash_array: None, } } @@ -44,14 +60,14 @@ fn scene() -> (Document, NodeId, NodeId) { desc: ShapeDesc::Rect, }, ); - b.add( + let ellipse = b.add( 0, Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)), Payload::Shape { desc: ShapeDesc::Ellipse, }, ); - b.add( + let line = b.add( 0, Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(2.0)), Payload::Shape { @@ -63,14 +79,14 @@ fn scene() -> (Document, NodeId, NodeId) { Header::new(SizeIntent::Auto, SizeIntent::Auto), Payload::Group, ); - b.add( + let group_child = b.add( g, Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(20.0)), Payload::Shape { desc: ShapeDesc::Rect, }, ); - b.add( + let text = b.add( 0, Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(20.0)), Payload::Text { @@ -80,7 +96,7 @@ fn scene() -> (Document, NodeId, NodeId) { ); let mut hidden_h = Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)); hidden_h.active = false; - b.add( + let hidden = b.add( 0, hidden_h, Payload::Shape { @@ -89,7 +105,13 @@ fn scene() -> (Document, NodeId, NodeId) { ); let mut doc = b.build(); - doc.get_mut(f).fill = Some("ff0000".into()); + doc.get_mut(f).fills = Paints::solid("#ff0000".into()); + for id in [r, ellipse, group_child, hidden] { + doc.get_mut(id).fills = Paints::solid("#4A90D9".into()); + } + doc.get_mut(f).strokes = vec![solid_stroke("#C9CED4".into(), 1.0, StrokeAlign::Inside)]; + doc.get_mut(line).strokes = vec![solid_stroke("#4A90D9".into(), 2.0, StrokeAlign::Center)]; + doc.get_mut(text).fills = Paints::solid(Color::BLACK); (doc, f, r) } @@ -100,7 +122,7 @@ fn traversal_order_and_pruning() { let list = build(&doc, &resolved); let tags: Vec<&str> = list.items.iter().map(|it| tag(&it.kind)).collect(); - // frame(fill,stroke), rect, ellipse, line, group-child rect, text; + // frame(fill,stroke), rect, ellipse, line stroke, group-child rect, text; // root & group emit no ink, hidden shape is pruned. assert_eq!( tags, @@ -108,37 +130,37 @@ fn traversal_order_and_pruning() { "rectfill", "rectstroke", "rectfill", - "oval", - "line", + "ovalfill", + "linestroke", "rectfill", - "text" + "textfill" ] ); } #[test] -fn colors_resolve_like_the_painter() { +fn paint_stacks_materialize_without_kind_fallbacks() { let (doc, _f, _r) = scene(); let resolved = resolve(&doc, &opts()); let list = build(&doc, &resolved); // Frame fill = the node's #ff0000, opaque. - let ItemKind::RectFill { argb, .. } = list.items[0].kind else { + let ItemKind::RectFill { ref paints, .. } = list.items[0].kind else { panic!("expected frame fill first"); }; - assert_eq!(argb, 0xFFFF_0000); + assert_eq!(*paints, Paints::solid("#FF0000".into())); - // Frame stroke = the fixed chrome color. - let ItemKind::RectStroke { argb, .. } = list.items[1].kind else { + // Frame stroke = only the authored stack; there is no engine fallback. + let ItemKind::RectStroke { ref stroke, .. } = list.items[1].kind else { panic!("expected frame stroke second"); }; - assert_eq!(argb, 0xFFC9_CED4); + assert_eq!(stroke.paints, Paints::solid("#C9CED4".into())); - // The bare rect (no fill set) = the shape default. - let ItemKind::RectFill { argb, .. } = list.items[2].kind else { + // The rect carries the concrete stack authored by the scene builder. + let ItemKind::RectFill { ref paints, .. } = list.items[2].kind else { panic!("expected rect fill third"); }; - assert_eq!(argb, 0xFF4A_90D9); + assert_eq!(*paints, Paints::solid("#4A90D9".into())); } #[test] @@ -156,3 +178,142 @@ fn build_is_deterministic() { let resolved = resolve(&doc, &opts()); assert_eq!(build(&doc, &resolved), build(&doc, &resolved)); } + +#[test] +fn fill_children_and_strokes_are_wrapped_by_balanced_scopes() { + let mut b = DocBuilder::new(); + let mut parent_header = Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(80.0)); + parent_header.opacity = 0.5; + let parent = b.add( + 0, + parent_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: true, + }, + ); + let child = b.add( + parent, + Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(20.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut doc = b.build(); + doc.get_mut(parent).fills = Paints::solid("#FF0000".into()); + doc.get_mut(parent).strokes = vec![solid_stroke("#0000FF".into(), 4.0, StrokeAlign::Outside)]; + doc.get_mut(child).fills = Paints::solid("#00FF00".into()); + + let resolved = resolve(&doc, &opts()); + let list = build(&doc, &resolved); + assert_eq!( + list.items + .iter() + .map(|item| tag(&item.kind)) + .collect::>(), + [ + "opacity-begin", + "rectfill", + "clip-begin", + "rectfill", + "clip-end", + "rectstroke", + "opacity-end", + ] + ); + assert!(list + .items + .iter() + .all(|item| { !matches!(&item.kind, ItemKind::RectStroke { .. }) || item.node == parent })); +} + +#[test] +fn repeated_strokes_keep_geometry_and_paint_order() { + let mut b = DocBuilder::new(); + let rect = b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(30.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut doc = b.build(); + let mut lower = solid_stroke("#FF0000".into(), 8.0, StrokeAlign::Outside); + lower + .paints + .push(Paint::Solid(SolidPaint::new("#00FF00".into()))); + let upper = solid_stroke("#0000FF".into(), 2.0, StrokeAlign::Inside); + doc.get_mut(rect).strokes = vec![lower.clone(), upper.clone()]; + + let resolved = resolve(&doc, &opts()); + let list = build(&doc, &resolved); + let strokes = list + .items + .iter() + .filter_map(|item| match &item.kind { + ItemKind::RectStroke { stroke, .. } => Some(stroke), + _ => None, + }) + .collect::>(); + assert_eq!(strokes, [&lower, &upper]); + assert_eq!(strokes[0].paints.len(), 2); +} + +#[test] +fn frame_and_line_have_no_implicit_ink() { + let mut b = DocBuilder::new(); + b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let line = b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(0.0)), + Payload::Shape { + desc: ShapeDesc::Line, + }, + ); + let mut doc = b.build(); + // Even a programmatic compatibility fill does not become line ink. + doc.get_mut(line).fills = Paints::solid("#FF0000".into()); + let resolved = resolve(&doc, &opts()); + assert!(build(&doc, &resolved).items.is_empty()); +} + +#[test] +fn ineffective_strokes_emit_no_item_and_visible_paints_keep_order() { + let mut b = DocBuilder::new(); + let rect = b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(30.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut doc = b.build(); + let zero_width = solid_stroke("#FF0000".into(), 0.0, StrokeAlign::Inside); + let mut empty = solid_stroke("#FF0000".into(), 3.0, StrokeAlign::Inside); + empty.paints = Paints::default(); + let mut inactive = SolidPaint::new("#00FF00".into()); + inactive.active = false; + let mut visible = solid_stroke("#0000FF".into(), 2.0, StrokeAlign::Inside); + visible.paints = Paints::new([ + Paint::Solid(inactive), + Paint::Solid(SolidPaint::new("#0000FF".into())), + ]); + doc.get_mut(rect).strokes = vec![zero_width, empty, visible]; + + let resolved = resolve(&doc, &opts()); + let list = build(&doc, &resolved); + let [item] = list.items.as_slice() else { + panic!("exactly one effective stroke should materialize"); + }; + let ItemKind::RectStroke { stroke, .. } = &item.kind else { + panic!("effective rect stroke"); + }; + assert_eq!(stroke.paints, Paints::solid("#0000FF".into())); +} diff --git a/model-v2/engine/tests/grida_xml.rs b/model-v2/engine/tests/grida_xml.rs new file mode 100644 index 0000000000..aa7eb07f79 --- /dev/null +++ b/model-v2/engine/tests/grida_xml.rs @@ -0,0 +1,106 @@ +//! Consumer contract for Draft 0 `.grida.xml`: the public parser's document +//! reaches the existing pure stages and the one frame entry without an +//! XML-specific engine API. The fixture is deliberately probe-friendly. + +use anchor_engine::drawlist::{self, Item, ItemKind}; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::model::{Color as ModelColor, Paints}; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{ + image::CachingHint, surfaces, AlphaType, Color, ColorType, IPoint, Image, ImageInfo, +}; + +const SOURCE: &str = include_str!("../rig/fixtures/nested-rects.grida.xml"); +const WIDTH: i32 = 96; +const HEIGHT: i32 = 80; + +fn options() -> ResolveOptions { + ResolveOptions { + viewport: (WIDTH as f32, HEIGHT as f32), + ..Default::default() + } +} + +fn assert_rect_fill(item: &Item, world: Affine, width: f32, height: f32, argb: u32) { + assert_eq!(item.world, world); + assert_eq!( + item.kind, + ItemKind::RectFill { + w: width, + h: height, + paints: Paints::solid(ModelColor(argb)), + } + ); +} + +fn rgba_at(image: &Image, x: i32, y: i32) -> [u8; 4] { + let info = ImageInfo::new((1, 1), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut rgba = [0u8; 4]; + assert!( + image.read_pixels( + &info, + &mut rgba, + 4, + IPoint::new(x, y), + CachingHint::Disallow, + ), + "read RGBA probe at ({x}, {y})" + ); + rgba +} + +#[test] +fn draft0_nested_rects_materialize_in_the_drawlist() { + let doc = grida_xml::parse(SOURCE).expect("Draft 0 fixture parses"); + let resolved = resolve(&doc, &options()); + let list = drawlist::build(&doc, &resolved); + + // The structural envelope and implicit document root emit no ink. + // The authored container and nested rectangles emit only their authored + // fills. There is no engine-invented frame border. + assert_eq!(list.items.len(), 3); + assert_rect_fill(&list.items[0], Affine::IDENTITY, 96.0, 80.0, 0xFFFF_FFFF); + assert_rect_fill( + &list.items[1], + Affine::translate(16.0, 16.0), + 64.0, + 48.0, + 0xFFFF_0000, + ); + assert_rect_fill( + &list.items[2], + Affine::translate(32.0, 28.0), + 24.0, + 16.0, + 0xFF00_00FF, + ); +} + +#[test] +fn draft0_nested_rects_render_through_the_frame_entry() { + let doc = grida_xml::parse(SOURCE).expect("Draft 0 fixture parses"); + let mut surface = surfaces::raster_n32_premul((WIDTH, HEIGHT)).expect("raster surface"); + surface.canvas().clear(Color::WHITE); + + let paint_ctx = PaintCtx::new(None); + let (_, list, _) = frame::render( + surface.canvas(), + &doc, + &options(), + &Affine::IDENTITY, + &paint_ctx, + ); + assert_eq!(list.items.len(), 3, "the frame used the expected drawlist"); + + // Force RGBA8888 readback so the assertions do not depend on native N32 + // channel order. Every point is well inside a solid region, away from AA. + let image = surface.image_snapshot(); + assert_eq!(rgba_at(&image, 8, 8), [255, 255, 255, 255]); + assert_eq!(rgba_at(&image, 20, 20), [255, 0, 0, 255]); + assert_eq!(rgba_at(&image, 40, 36), [0, 0, 255, 255]); + assert_eq!(rgba_at(&image, 70, 52), [255, 0, 0, 255]); + assert_eq!(rgba_at(&image, 88, 72), [255, 255, 255, 255]); +} diff --git a/model-v2/engine/tests/paints.rs b/model-v2/engine/tests/paints.rs new file mode 100644 index 0000000000..e5c3e07092 --- /dev/null +++ b/model-v2/engine/tests/paints.rs @@ -0,0 +1,256 @@ +//! Focused data and raster-probe coverage for ordered Grida paint stacks. +//! Native `.grida.xml` has no independent visual oracle, so these are not +//! reftests: model/order is asserted as data and rendering at stable interior +//! points is asserted with probes. + +use anchor_engine::drawlist::{DrawList, ItemKind}; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::model::Paint; +use anchor_lab::resolve::ResolveOptions; +use skia_safe::{ + image::CachingHint, surfaces, AlphaType, Color, ColorType, IPoint, Image, ImageInfo, +}; + +fn render(source: &str, width: i32, height: i32, ctx: &PaintCtx) -> (Image, DrawList) { + let doc = grida_xml::parse(source).expect("paint fixture parses"); + let mut surface = surfaces::raster_n32_premul((width, height)).expect("raster surface"); + surface.canvas().clear(Color::WHITE); + let options = ResolveOptions { + viewport: (width as f32, height as f32), + ..Default::default() + }; + let (_, list, _) = frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, ctx); + (surface.image_snapshot(), list) +} + +fn rgba_at(image: &Image, x: i32, y: i32) -> [u8; 4] { + let info = ImageInfo::new((1, 1), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut rgba = [0u8; 4]; + assert!(image.read_pixels( + &info, + &mut rgba, + 4, + IPoint::new(x, y), + CachingHint::Disallow, + )); + rgba +} + +#[test] +fn drawlist_keeps_visible_paints_in_bottom_to_top_order() { + let source = r##" + + + + + + +"##; + let (_, list) = render(source, 40, 40, &PaintCtx::new(None)); + let paints = list + .items + .iter() + .find_map(|item| match &item.kind { + ItemKind::RectFill { paints, .. } => Some(paints), + _ => None, + }) + .expect("rect fill item"); + assert_eq!(paints.len(), 2); + assert!(matches!(paints[0], Paint::Solid(_))); + assert!(matches!(paints[1], Paint::LinearGradient(_))); +} + +#[test] +fn later_paints_composite_above_earlier_paints() { + let source = r##" + + + + + + + + + + +"##; + let (image, _) = render(source, 100, 50, &PaintCtx::new(None)); + let blue_over_red = rgba_at(&image, 25, 25); + assert!((127..=128).contains(&blue_over_red[0]), "{blue_over_red:?}"); + assert_eq!(blue_over_red[1], 0); + assert!((127..=128).contains(&blue_over_red[2]), "{blue_over_red:?}"); + assert_eq!(rgba_at(&image, 75, 25), [255, 0, 0, 255]); +} + +#[test] +fn bottom_blend_mode_does_not_leak_into_later_paints() { + let source = r##" + + + + + + + +"##; + let (image, _) = render(source, 40, 40, &PaintCtx::new(None)); + let pixel = rgba_at(&image, 20, 20); + assert!((63..=64).contains(&pixel[0]), "{pixel:?}"); + assert_eq!(pixel[1], 0); + assert!((127..=128).contains(&pixel[2]), "{pixel:?}"); +} + +#[test] +fn linear_and_radial_gradients_use_the_local_unit_box() { + let source = r##" + + + + + + + + +"##; + let (image, _) = render(source, 100, 150, &PaintCtx::new(None)); + let left = rgba_at(&image, 5, 20); + let right = rgba_at(&image, 95, 20); + assert!(left[0] > left[2], "left={left:?}"); + assert!(right[2] > right[0], "right={right:?}"); + + let center = rgba_at(&image, 50, 100); + let edge = rgba_at(&image, 2, 100); + assert!(center[0] > edge[0], "center={center:?} edge={edge:?}"); +} + +#[test] +fn gradient_opacity_modulates_only_its_paint() { + let source = r##" + + + + + +"##; + let (image, _) = render(source, 40, 40, &PaintCtx::new(None)); + let pixel = rgba_at(&image, 20, 20); + for channel in &pixel[..3] { + assert!((127..=128).contains(channel), "{pixel:?}"); + } + assert_eq!(pixel[3], 255); +} + +#[test] +fn gradient_transform_is_composed_in_unit_space_before_box_scale() { + let source = r##" + + + + + + + + +"##; + let (image, _) = render(source, 100, 90, &PaintCtx::new(None)); + for x in [10, 49, 60, 75, 95] { + assert_eq!( + rgba_at(&image, x, 20), + rgba_at(&image, x, 70), + "unit-space transform diverged at x={x}" + ); + } +} + +#[test] +fn linear_gradient_tile_modes_have_distinct_outside_domain_behavior() { + let source = r##" + + + + + + + + + + + + + + +"##; + let (image, _) = render(source, 100, 80, &PaintCtx::new(None)); + let clamp = rgba_at(&image, 12, 10); + let repeated = rgba_at(&image, 12, 30); + let mirror = rgba_at(&image, 12, 50); + let decal = rgba_at(&image, 12, 70); + assert!(clamp[0] > 248 && clamp[2] < 8, "clamp={clamp:?}"); + assert!(repeated[2] > repeated[0], "repeated={repeated:?}"); + assert!(mirror[0] > mirror[2], "mirror={mirror:?}"); + assert_eq!(decal, [0, 255, 0, 255]); +} + +#[test] +fn paint_visibility_and_blend_mode_are_per_entry() { + let source = r##" + + + + + + + + + + +"##; + let (image, _) = render(source, 100, 40, &PaintCtx::new(None)); + assert_eq!(rgba_at(&image, 20, 20), [0, 255, 0, 255]); + let multiplied = rgba_at(&image, 80, 20); + assert!( + multiplied[0] < 8 && multiplied[1] < 8 && multiplied[2] < 8, + "{multiplied:?}" + ); +} + +#[test] +fn sweep_and_diamond_gradient_shader_paths_render() { + let source = r##" + + + + + + + + +"##; + let (image, _) = render(source, 130, 60, &PaintCtx::new(None)); + assert_ne!(rgba_at(&image, 45, 30), rgba_at(&image, 30, 45)); + let center = rgba_at(&image, 100, 30); + let edge = rgba_at(&image, 72, 30); + assert!(center[0] > edge[0], "center={center:?} edge={edge:?}"); +} + +#[test] +fn image_fit_is_resolved_from_host_resources() { + const RID: &str = "fixture://border-diamonds"; + const IMAGE: &[u8] = include_bytes!("../../../fixtures/images/border-diamonds.png"); + let source = format!( + r##" + + + + +"## + ); + let mut ctx = PaintCtx::new(None); + ctx.insert_encoded(RID, IMAGE).unwrap(); + let (image, _) = render(&source, 370, 90, &ctx); + assert_eq!(rgba_at(&image, 10, 45), [0, 255, 0, 255]); + assert_ne!(rgba_at(&image, 200, 45), [0, 255, 0, 255]); +} diff --git a/model-v2/engine/tests/strokes.rs b/model-v2/engine/tests/strokes.rs new file mode 100644 index 0000000000..e63de1871d --- /dev/null +++ b/model-v2/engine/tests/strokes.rs @@ -0,0 +1,351 @@ +//! Focused data and pixel-probe coverage for authored stroke applications and +//! the display-list scopes they interact with. Native `.grida.xml` has no +//! external visual oracle, so these are data/probe tests rather than reftests. + +use anchor_engine::drawlist::{DrawList, ItemKind}; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::model::{Paint, StrokeAlign, StrokeJoin}; +use anchor_lab::resolve::ResolveOptions; +use skia_safe::{ + image::CachingHint, surfaces, AlphaType, Color, ColorType, FontMgr, IPoint, Image, ImageInfo, +}; + +fn render(source: &str, width: i32, height: i32, ctx: &PaintCtx) -> (Image, DrawList) { + render_on(source, width, height, Color::WHITE, ctx) +} + +fn render_on( + source: &str, + width: i32, + height: i32, + clear: Color, + ctx: &PaintCtx, +) -> (Image, DrawList) { + let doc = grida_xml::parse(source).expect("stroke fixture parses"); + let mut surface = surfaces::raster_n32_premul((width, height)).expect("raster surface"); + surface.canvas().clear(clear); + let options = ResolveOptions { + viewport: (width as f32, height as f32), + ..Default::default() + }; + let (_, list, _) = frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, ctx); + assert_eq!( + surface.canvas().save_count(), + 1, + "display-list scopes leaked canvas state" + ); + (surface.image_snapshot(), list) +} + +fn rgba_at(image: &Image, x: i32, y: i32) -> [u8; 4] { + let info = ImageInfo::new((1, 1), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut rgba = [0u8; 4]; + assert!(image.read_pixels( + &info, + &mut rgba, + 4, + IPoint::new(x, y), + CachingHint::Disallow, + )); + rgba +} + +#[test] +fn drawlist_keeps_repeated_strokes_and_each_ordered_paint_stack() { + let source = r##" + + + + + + + + + + + +"##; + let (_, list) = render(source, 60, 60, &PaintCtx::new(None)); + let strokes = list + .items + .iter() + .filter_map(|item| match &item.kind { + ItemKind::RectStroke { stroke, .. } => Some(stroke), + _ => None, + }) + .collect::>(); + assert_eq!(strokes.len(), 2); + assert_eq!(strokes[0].width, 8.0); + assert_eq!(strokes[0].align, StrokeAlign::Outside); + assert_eq!(strokes[0].join, StrokeJoin::Round); + assert_eq!(strokes[0].dash_array.as_deref(), Some(&[8.0, 4.0][..])); + assert!(matches!(strokes[0].paints[0], Paint::Solid(_))); + assert!(matches!(strokes[0].paints[1], Paint::LinearGradient(_))); + assert_eq!(strokes[1].width, 2.0); + assert_eq!(strokes[1].align, StrokeAlign::Inside); +} + +#[test] +fn container_clip_applies_to_children_but_not_parent_strokes() { + let source = r##" + + + + + + + +"##; + let (image, list) = render(source, 100, 100, &PaintCtx::new(None)); + assert_eq!(rgba_at(&image, 2, 40), [255, 255, 255, 255]); + assert_eq!(rgba_at(&image, 17, 40), [255, 0, 0, 255]); + assert_eq!(rgba_at(&image, 22, 40), [0, 0, 255, 255]); + assert_eq!(rgba_at(&image, 40, 40), [0, 255, 0, 255]); + + let tags = list + .items + .iter() + .map(|item| match &item.kind { + ItemKind::BeginClipRect { .. } => "clip-begin", + ItemKind::EndClip => "clip-end", + ItemKind::RectStroke { .. } => "stroke", + ItemKind::RectFill { .. } => "fill", + _ => "other", + }) + .collect::>(); + assert_eq!( + tags, + ["fill", "clip-begin", "fill", "clip-end", "stroke", "stroke"] + ); +} + +#[test] +fn node_opacity_composites_overlapping_children_as_one_group() { + let source = r##" + + + + +"##; + let (image, list) = render(source, 60, 40, &PaintCtx::new(None)); + let single = rgba_at(&image, 10, 20); + let overlap = rgba_at(&image, 30, 20); + assert_eq!(single, overlap, "group overlap must not accumulate alpha"); + // Skia's premultiplied save-layer round-trip lands at 126 on this raster + // backend; the equality with the overlap pixel is the semantic assertion. + assert!((125..=128).contains(&overlap[0]), "{overlap:?}"); + assert_eq!(overlap[0], overlap[1]); + assert_eq!(overlap[1], overlap[2]); + assert!(matches!( + list.items.first().map(|item| &item.kind), + Some(ItemKind::BeginOpacity { opacity: 0.5 }) + )); + assert!(matches!( + list.items.last().map(|item| &item.kind), + Some(ItemKind::EndOpacity) + )); +} + +#[test] +fn node_opacity_preserves_the_backdrop_for_descendant_blend_modes() { + let source = r##" + + + + + +"##; + let (image, _) = render(source, 40, 40, &PaintCtx::new(None)); + let pixel = rgba_at(&image, 20, 20); + assert!( + (127..=129).contains(&pixel[0]), + "multiply must still see the gray backdrop: {pixel:?}" + ); + assert!((62..=65).contains(&pixel[1]), "{pixel:?}"); + assert!((62..=65).contains(&pixel[2]), "{pixel:?}"); + assert_eq!(pixel[3], 255); +} + +#[test] +fn empty_opacity_group_does_not_recomposite_a_translucent_backdrop() { + let source = r##" + + + + +"##; + let (image, _) = render_on(source, 20, 20, Color::TRANSPARENT, &PaintCtx::new(None)); + let pixel = rgba_at(&image, 10, 10); + assert_eq!(pixel[..3], [0, 0, 0]); + assert_eq!( + pixel[3], 128, + "an empty group must leave its backdrop intact" + ); +} + +#[test] +fn stroke_alignment_controls_inside_center_and_outside_coverage() { + let source = r##" + + + + + + + + + + + +"##; + let (image, _) = render(source, 140, 60, &PaintCtx::new(None)); + assert_eq!(rgba_at(&image, 18, 30), [255, 255, 255, 255]); + assert_eq!(rgba_at(&image, 22, 30), [255, 0, 0, 255]); + assert_eq!(rgba_at(&image, 58, 30), [0, 0, 255, 255]); + assert_eq!(rgba_at(&image, 65, 30), [0, 255, 0, 255]); + assert_eq!(rgba_at(&image, 98, 30), [255, 0, 255, 255]); + assert_eq!(rgba_at(&image, 102, 30), [0, 255, 0, 255]); +} + +#[test] +fn line_cap_and_dash_geometry_have_stable_interior_probes() { + let source = r##" + + + + + + + + + + + +"##; + let (image, _) = render(source, 90, 80, &PaintCtx::new(None)); + assert_eq!(rgba_at(&image, 16, 15), [255, 255, 255, 255]); + assert_eq!(rgba_at(&image, 16, 40), [0, 0, 255, 255]); + assert_eq!(rgba_at(&image, 15, 65), [0, 170, 0, 255]); + assert_eq!(rgba_at(&image, 25, 65), [255, 255, 255, 255]); + assert_eq!(rgba_at(&image, 35, 65), [0, 170, 0, 255]); +} + +#[test] +fn inside_ellipse_dash_starts_at_the_rightmost_point() { + let source = r##" + + + + + +"##; + let (image, _) = render(source, 100, 100, &PaintCtx::new(None)); + assert_eq!(rgba_at(&image, 87, 55), [255, 0, 0, 255]); + assert_eq!(rgba_at(&image, 55, 13), [255, 255, 255, 255]); +} + +#[test] +fn miter_and_bevel_joins_differ_at_the_outer_corner() { + let source = r##" + + + + + + + + +"##; + let (image, _) = render(source, 110, 60, &PaintCtx::new(None)); + assert_eq!(rgba_at(&image, 16, 16), [255, 0, 0, 255]); + assert_eq!(rgba_at(&image, 66, 16), [255, 255, 255, 255]); +} + +#[test] +fn gradient_strokes_use_the_node_paint_box_not_the_stroke_bounds() { + let source = r##" + + + + + + + + + + + + +"##; + let (image, _) = render(source, 120, 80, &PaintCtx::new(None)); + let thin = rgba_at(&image, 50, 12); + let wide = rgba_at(&image, 50, 52); + for channel in 0..4 { + assert!( + thin[channel].abs_diff(wide[channel]) <= 1, + "thin={thin:?} wide={wide:?}" + ); + } + assert!(rgba_at(&image, 15, 12)[0] > rgba_at(&image, 15, 12)[2]); + assert!(rgba_at(&image, 105, 12)[2] > rgba_at(&image, 105, 12)[0]); +} + +#[test] +fn line_gradient_uses_a_centered_one_pixel_box_for_its_degenerate_axis() { + let source = r##" + + + + + + + +"##; + let (image, _) = render(source, 100, 40, &PaintCtx::new(None)); + let left = rgba_at(&image, 15, 20); + let right = rgba_at(&image, 85, 20); + assert!(left[0] > left[2], "left={left:?}"); + assert!(right[2] > right[0], "right={right:?}"); +} + +#[test] +fn image_and_text_strokes_use_the_existing_paint_pipeline() { + const RID: &str = "fixture://border-diamonds"; + const IMAGE: &[u8] = include_bytes!("../../../fixtures/images/border-diamonds.png"); + const INTER: &[u8] = + include_bytes!("../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); + let source = format!( + r##" + + + + + + I + +"## + ); + let typeface = FontMgr::new() + .new_from_data(INTER, None) + .expect("bundled Inter typeface"); + let mut ctx = PaintCtx::new(Some(typeface)); + ctx.insert_encoded(RID, IMAGE).unwrap(); + let (image, list) = render(&source, 180, 100, &ctx); + assert_ne!(rgba_at(&image, 50, 8), [255, 255, 255, 255]); + assert_eq!(rgba_at(&image, 50, 50), [255, 255, 255, 255]); + assert!(list + .items + .iter() + .any(|item| matches!(&item.kind, ItemKind::TextStroke { .. }))); + let mut red_pixels = 0; + for y in 0..100 { + for x in 100..180 { + let [r, g, b, _] = rgba_at(&image, x, y); + red_pixels += usize::from(r > 180 && g < 100 && b < 100); + } + } + assert!(red_pixels > 0, "text stroke emitted no red glyph pixels"); +} diff --git a/model-v2/engine/tests/text.rs b/model-v2/engine/tests/text.rs new file mode 100644 index 0000000000..affc96ccff --- /dev/null +++ b/model-v2/engine/tests/text.rs @@ -0,0 +1,152 @@ +//! Focused data and raster probes for the resolved text-line topology. The +//! bundled font keeps these backend observations deterministic; they are probe +//! tests, not external-reference reftests. + +use anchor_engine::drawlist::{build, DrawList, ItemKind}; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{ + image::CachingHint, surfaces, AlphaType, Color, ColorType, FontMgr, IPoint, Image, ImageInfo, +}; + +const INTER: &[u8] = + include_bytes!("../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); + +fn paint_ctx() -> PaintCtx { + let typeface = FontMgr::new() + .new_from_data(INTER, None) + .expect("bundled Inter typeface"); + PaintCtx::new(Some(typeface)) +} + +fn render(source: &str, width: i32, height: i32) -> (Image, DrawList) { + let doc = grida_xml::parse(source).expect("text fixture parses"); + let mut surface = surfaces::raster_n32_premul((width, height)).expect("raster surface"); + surface.canvas().clear(Color::WHITE); + let options = ResolveOptions { + viewport: (width as f32, height as f32), + ..Default::default() + }; + let (_, list, _) = frame::render( + surface.canvas(), + &doc, + &options, + &Affine::IDENTITY, + &paint_ctx(), + ); + (surface.image_snapshot(), list) +} + +fn rgba_at(image: &Image, x: i32, y: i32) -> [u8; 4] { + let info = ImageInfo::new((1, 1), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut rgba = [0u8; 4]; + assert!(image.read_pixels( + &info, + &mut rgba, + 4, + IPoint::new(x, y), + CachingHint::Disallow, + )); + rgba +} + +fn count_pixels( + image: &Image, + width: i32, + ys: std::ops::Range, + predicate: impl Fn([u8; 4]) -> bool, +) -> usize { + let mut count = 0; + for y in ys { + for x in 0..width { + count += usize::from(predicate(rgba_at(image, x, y))); + } + } + count +} + +#[test] +fn drawlist_materializes_shared_wrapping_and_explicit_empty_lines() { + let source = "aa bb cc\nx\n"; + let doc = grida_xml::parse(source).unwrap(); + let resolved = resolve(&doc, &ResolveOptions::default()); + let list = build(&doc, &resolved); + let lines = list + .items + .iter() + .find_map(|item| match &item.kind { + ItemKind::TextFill { lines, .. } => Some(lines), + _ => None, + }) + .expect("text fill item"); + assert_eq!( + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + ["aa bb", "cc", "x", ""] + ); + assert_eq!( + lines.iter().map(|line| line.baseline_y).collect::>(), + [8.5, 20.5, 32.5, 44.5] + ); +} + +#[test] +fn fill_and_repeated_strokes_share_one_text_line_topology() { + let source = r##"aa bb cc"##; + let doc = grida_xml::parse(source).unwrap(); + let resolved = resolve(&doc, &ResolveOptions::default()); + let list = build(&doc, &resolved); + let topologies = list + .items + .iter() + .filter_map(|item| match &item.kind { + ItemKind::TextFill { lines, .. } | ItemKind::TextStroke { lines, .. } => Some(lines), + _ => None, + }) + .collect::>(); + assert_eq!(topologies.len(), 3); + assert!(topologies[1..] + .iter() + .all(|lines| std::sync::Arc::ptr_eq(topologies[0], lines))); +} + +#[test] +fn constrained_text_paints_each_soft_wrapped_line() { + let source = "HI HI HI"; + let (image, _) = render(source, 40, 32); + let dark = |[r, g, b, _]: [u8; 4]| r < 80 && g < 80 && b < 80; + assert!(count_pixels(&image, 40, 0..12, dark) > 0); + assert!(count_pixels(&image, 40, 12..25, dark) > 0); + assert_eq!(count_pixels(&image, 40, 25..32, dark), 0); +} + +#[test] +fn text_stroke_combines_nonempty_lines_around_an_explicit_empty_line() { + let source = "I\n\nI"; + let (image, list) = render(source, 50, 70); + let red = |[r, g, b, _]: [u8; 4]| r > 160 && g < 100 && b < 100; + assert!(count_pixels(&image, 50, 4..24, red) > 0); + assert_eq!(count_pixels(&image, 50, 25..42, red), 0); + assert!(count_pixels(&image, 50, 42..65, red) > 0); + + let lines = list + .items + .iter() + .find_map(|item| match &item.kind { + ItemKind::TextStroke { lines, .. } => Some(lines), + _ => None, + }) + .expect("text stroke item"); + assert_eq!( + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + ["I", "", "I"] + ); +} diff --git a/model-v2/models/a.md b/model-v2/models/a.md index 5ddab1bffd..9e075e2487 100644 --- a/model-v2/models/a.md +++ b/model-v2/models/a.md @@ -149,7 +149,7 @@ SurfaceStyle { fills: [Paint], strokes: [Paint], stroke_width, stroke_style, | -------- | ------------------------------------------------------------------ | -------------------------- | --------------------------------------------- | | `frame` | `LayoutBehavior` (§3.1) + `SurfaceStyle` + `clips_content: bool` | declared (Auto = hug) | yes | | `tray` | `SurfaceStyle` | declared | yes (canvas organization; no clip, no layout) | -| `shape` | `ShapeDescriptor` (§3.2) + `SurfaceStyle` | declared | no | +| `shape` | `ShapeDescriptor` (§3.2) + `SurfaceStyle` | declared | yes (free-positioned in the declared box) | | `image` | `ResourceRef` + `ImageFit` + `SurfaceStyle` | declared | no | | `text` | content + `TextStyle` + align + overflow (`max_lines`, `ellipsis`) | measured | no | | `embed` | `format: markdown \| html` + source + `SurfaceStyle` | measured | no | @@ -206,6 +206,13 @@ editable geometry, hence a measured box. `shape/Path` (render-only, normalized) vs `vector` (editable, measured) is the same split the format draft already makes. +A shape's child list is composition, not measurement or layout. The +parametric primitive paints first; ordered children then paint in the shape's +local box using free bindings. Descendant edits never rewrite or resize the +primitive and never change the shape's parent-layout contribution. A shape +with text is therefore an ordinary `shape → text` subtree, not a separate +`ShapeWithText` payload. + Stroke endpoint markers live in `SurfaceStyle`-adjacent stroke props and are honored only on open geometry (`Line`, open `Path`, open network) — `ignored-by-rule` elsewhere. @@ -316,7 +323,10 @@ Resolution never writes back to the document. --- -## 7. Worked examples (H1 — the XML quartet) +## 7. Worked examples (H1 — historical XML sketches) + +These model sketches predate both the frozen E3 TextIr grammar and Draft 0 +`.grida.xml`. They are not parseable source for either language. ```xml From eb22e7ec414ab99f8ab99733bb90b7f7aeb813ec Mon Sep 17 00:00:00 2001 From: Universe Date: Sat, 11 Jul 2026 08:21:41 +0900 Subject: [PATCH 11/33] test(model-v2): add canonical Grida XML showcase --- model-v2/engine/README.md | 112 ++++- .../rig/examples/dynamic-slide.grida.xml | 30 ++ .../engine/rig/examples/rich-fills.grida.xml | 35 ++ .../rig/examples/rich-strokes.grida.xml | 19 + .../examples/source-becomes-surface.grida.xml | 398 ++++++++++++++++++ model-v2/engine/tests/grida_xml_examples.rs | 119 ++++++ 6 files changed, 703 insertions(+), 10 deletions(-) create mode 100644 model-v2/engine/rig/examples/dynamic-slide.grida.xml create mode 100644 model-v2/engine/rig/examples/rich-fills.grida.xml create mode 100644 model-v2/engine/rig/examples/rich-strokes.grida.xml create mode 100644 model-v2/engine/rig/examples/source-becomes-surface.grida.xml create mode 100644 model-v2/engine/tests/grida_xml_examples.rs diff --git a/model-v2/engine/README.md b/model-v2/engine/README.md index 04a97131f5..4b094acce9 100644 --- a/model-v2/engine/README.md +++ b/model-v2/engine/README.md @@ -23,12 +23,97 @@ cd model-v2/engine && cargo test # the trace arm must keep compiling cargo check --features trace -# the gate: shots byte-identical + replay determinism + bench budgets -# (needs the spike built first — it owns the golden pixels) +# the legacy re-host gate: replay determinism, differential/cache checks, +# benchmark budgets, and the pre-Draft-0 screenshot oracle +# (needs the spike built first — it owns the legacy golden pixels) (cd ../a/spike-canvas && cargo build --release) cargo run --release --bin gate ``` +## Draft 0 `.grida.xml` ingestion + +This engine consumes the Draft 0 proof through the model crate's public, +pure `anchor_lab::grida_xml::parse(&str)` boundary. There is deliberately no +XML-specific engine API: a host reads the file once, parses it into the same +`Document` value used everywhere else, then passes that value to +`frame::render`. The engine library performs no path I/O and never reparses a +document in the frame loop. + +The source boundary materializes the Draft 0 XML-facing property registry: +versioned envelope, direct node taxonomy, responsive bindings and constraints, +free/flex layout, nested primitive-local children, canonical compact and +structured fills, all existing gradient variants, RID-backed image paints, +per-paint visibility/opacity/blend modes, and repeatable authored strokes whose +independent geometries each own ordered `Paints`. + +The drawlist and painter execute the same topology: node fill, clipped children, +then repeated parent strokes. Lines have no fill or implicit ink; containers +receive no invented border. Stroke alignment, caps, joins, miter limits, +dashes, rich gradient/image paints, conservative visual bounds, subtree +opacity, and descendant-only container clips all remain explicit model and +display-list state. Degenerate paint-box axes use Draft 0's centered one-pixel +coordinate fallback. + +This remains a proving engine rather than a claim that every future RFD area is +complete. Current limits are: + +- resolution and paint share explicit-line and constrained-wrap topology, but + the lab still measures with its deterministic `0.6/1.2` metric while Skia + paints a host typeface; a real shared shaper and glyph-ink bounds remain an + engine promotion task; +- derived group/lens flex-slot growth still needs an explicit + slot-versus-geometry model rule; +- image-paint free transforms, tiling, filters, and quarter-turns are outside + Draft 0 XML; component reuse and durable source identity remain future work; +- `PaintCtx` is an already-resolved executor cache: the low-level infallible + painter emits no pixels for an unregistered image RID. A host claiming strict + materialization must preflight resources; `grida_xml_render` does so and + reports authored plus resolved locations; +- the lab's ordered `Vec` implements the accepted extension, while the + production scene/archive model still has one stroke geometry per node. + +Checked-in files use the canonical grammar. The minimal consumer fixture and +pixel probes live at `rig/fixtures/nested-rects.grida.xml` and +`tests/grida_xml.rs`. `rig/examples/dynamic-slide.grida.xml` demonstrates flex, +a direct ellipse used as a circle, and primitive/text composition. +`rig/examples/rich-fills.grida.xml` demonstrates ordered paint stacks, while +`rig/examples/rich-strokes.grida.xml` demonstrates independent repeated stroke +geometry. `rig/examples/source-becomes-surface.grida.xml` is the complete +editorial showcase: every Draft 0 element and property family, all four +gradient variants, image paints, clipping, responsive bindings, and native +multi-stroke composition in one scene. + +The thin host binary renders a file to PNG. It defaults to a 1280x720 viewport; +pass explicit positive dimensions for responsive inputs: + +```sh +cargo run --bin grida_xml_render -- \ + rig/examples/dynamic-slide.grida.xml target/grida-xml-dynamic-slide.png + +cargo run --bin grida_xml_render -- \ + rig/examples/rich-fills.grida.xml target/grida-xml-rich-fills.png 720 300 + +cargo run --bin grida_xml_render -- \ + rig/examples/rich-strokes.grida.xml target/grida-xml-rich-strokes.png 720 320 + +cargo run --bin grida_xml_render -- \ + rig/examples/source-becomes-surface.grida.xml \ + target/grida-xml-source-becomes-surface.png 1600 1000 + +cargo run --bin grida_xml_render -- \ + rig/fixtures/nested-rects.grida.xml target/grida-xml-nested-rects.png 96 80 +``` + +The CLI owns filesystem access, resource bases, image decoding, a +platform-default typeface, the white raster background, and PNG encoding. +Relative image RIDs resolve against the input file's directory and every +visible image referenced by a fill or stroke is decoded before the first +frame; missing or invalid resources fail with both authored and resolved +locations. The host calls +`grida_xml::parse` exactly once and renders through `frame::render`. It refuses +resolver error/ignored reports instead of writing a fallback image; replay +remains on its existing wire contract. + How the engine proves it is _fast_ (the four measurement axes — automated work & correctness, the auxiliary human-in-the-loop feel channel, and the software-unmeasurable input→photon limit) is its own doctrine: @@ -43,8 +128,9 @@ ALIGNED / ADOPTED / SOCKET — is [`DATA-MODEL.md`](./DATA-MODEL.md). | concern | module | contract | guarding test | | ------------------------------------ | ---------------- | ------------- | ---------------------------------------------------------------------------- | | stage purity + the oracle law | (whole pipeline) | ENG-0 | the gate's differential + determinism runs | +| Draft 0 source consumer seam | parse → frame | ENG-0 / S-2 | `tests/grida_xml.rs`, `tests/paints.rs`, `tests/strokes.rs`, `tests/text.rs` | | drawlist (pure, diffable projection) | `drawlist.rs` | ENG-2.1 | `tests/drawlist.rs` (order · pruning · color · verbatim world · determinism) | -| paint executor (only skia module) | `paint.rs` | ENG-2.1 | spike shots byte-identical (gate) | +| paint executor (only skia module) | `paint.rs` | ENG-2.1 | `tests/paints.rs`, `tests/strokes.rs`, `tests/text.rs` (pixel probes) | | one frame entry | `frame.rs` | ENG-2.4 | spike live loop + gate | | damage as data | `damage.rs` | ENG-2.2 | `tests/damage.rs` (identity empty · single-op locality) | | spatial read tier | `query.rs` | ENG-3 | `tests/query.rs` (`hit_point ≡ pick` over a grid) | @@ -58,17 +144,23 @@ ALIGNED / ADOPTED / SOCKET — is [`DATA-MODEL.md`](./DATA-MODEL.md). The model-crate side of the setup lives in [`../a/lab`](../a/lab): the typed `Op` + `apply` dispatcher + `DirtyClass` (`ops.rs`), the per-slot `generations` column (`model.rs`), the non-panicking `Resolved` opt accessors (`resolve.rs`), -and the optional `serde` feature (the op-log wire) — each additive, the 121-test -lab suite green throughout. +and the optional `serde` feature (the op-log wire) — each additive, with the +full lab suite green throughout. ## The re-host, concretely The spike's scene painter is deleted; it calls `drawlist::build` + -`paint::execute` (shots prove this byte-identical to the old painter, including -the rotated and cross-zero-flip scenes). Pick/hover go through `query`. All -gesture ops go through `apply` and are recorded in the `journal` (undo stays -document snapshots — ENG-5.5). `--record` writes `.replay` corpus files; the -panel shows the per-frame damage count. +`paint::execute`. Pick/hover go through `query`. All gesture ops go through +`apply` and are recorded in the `journal` (undo stays document snapshots — +ENG-5.5). `--record` writes `.replay` corpus files; the panel shows the +per-frame damage count. + +The gate's replay, differential/cache, and benchmark sections remain green. +Its four legacy screenshots currently report the deliberate Draft 0 semantic +delta: frames no longer receive invented ink, and authored parent strokes paint +after children. Those pre-change goldens have not been blessed or silently +rewritten; they need an explicit oracle rebaseline after the new painter order +is accepted for the spike corpus. ## Scope fence (named, not silent) diff --git a/model-v2/engine/rig/examples/dynamic-slide.grida.xml b/model-v2/engine/rig/examples/dynamic-slide.grida.xml new file mode 100644 index 0000000000..a566481a69 --- /dev/null +++ b/model-v2/engine/rig/examples/dynamic-slide.grida.xml @@ -0,0 +1,30 @@ + + + GRIDA XML · DRAFT 0 + Dynamic design, plain text. + A small authored tree can mix graphics, layout, and local composition. + + + Presentations + Compose slides as trees. + + 01 + + + + Interfaces + Flex keeps intent editable. + + 02 + + + + Agent-ready + Readable source, fewer traps. + + 03 + + + + + diff --git a/model-v2/engine/rig/examples/rich-fills.grida.xml b/model-v2/engine/rig/examples/rich-fills.grida.xml new file mode 100644 index 0000000000..42787b8939 --- /dev/null +++ b/model-v2/engine/rig/examples/rich-fills.grida.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + Ordered fills + solid + gradient + image + + + + + + + + + + Unit-space radial + + + + + + + + + + + + diff --git a/model-v2/engine/rig/examples/rich-strokes.grida.xml b/model-v2/engine/rig/examples/rich-strokes.grida.xml new file mode 100644 index 0000000000..3d76fb8b69 --- /dev/null +++ b/model-v2/engine/rig/examples/rich-strokes.grida.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + One shape · three strokes + + + diff --git a/model-v2/engine/rig/examples/source-becomes-surface.grida.xml b/model-v2/engine/rig/examples/source-becomes-surface.grida.xml new file mode 100644 index 0000000000..8760d3d2c5 --- /dev/null +++ b/model-v2/engine/rig/examples/source-becomes-surface.grida.xml @@ -0,0 +1,398 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GRIDA / XML + AUTHORING SURFACE · DRAFT 0 + 1600 × 1000 / LIVE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FILE-FIRST DESIGN IR + + + + + + + + MATERIAL, +NOT MYSTERY. + One inspectable tree for live interfaces, +presentations, and programmable graphics. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OPEN THE SOURCE > + + + + + + + + + + + + + + + + READ THE SCENE + + + + + + + + + + + + + + + + + + + + + + + + + + ORDERED PAINT + + + FLEX + FREE + + + MULTI-STROKE + + + AGENT-READY + + + + + + + + + + + + ONE TREE · RESPONSIVE BOXES · PRODUCTION PAINTS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IR + PAINT · LAYOUT · TYPE + + + + + + + + + + + + + + + + + + + + MATERIALIZED THROUGH THE SAME ENGINE PAINT MODEL + + + + + + + + + + + + + + + LIVE PIPELINE + SOURCE > RESOLVE > DRAWLIST > PAINT + + + + + + + + + PAINTS × ORDERED + + + + + + + + + + + + STROKES × NATIVE + + + + diff --git a/model-v2/engine/tests/grida_xml_examples.rs b/model-v2/engine/tests/grida_xml_examples.rs new file mode 100644 index 0000000000..953d1c402b --- /dev/null +++ b/model-v2/engine/tests/grida_xml_examples.rs @@ -0,0 +1,119 @@ +//! Checked-in `.grida.xml` examples are executable canonical source, not +//! illustrative pseudocode. Keeping them at the writer fixpoint prevents the +//! documentation corpus from drifting back to retired spellings. Each source +//! is also resolved and painted once with its checked-in resources. + +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::model::{Document, Paint, ResourceRef}; +use anchor_lab::resolve::{Report, ResolveOptions}; +use skia_safe::{surfaces, Color}; +use std::path::Path; + +const EXAMPLES: [(&str, &str, &str, (i32, i32)); 5] = [ + ( + "nested-rects", + "rig/fixtures/nested-rects.grida.xml", + include_str!("../rig/fixtures/nested-rects.grida.xml"), + (96, 80), + ), + ( + "dynamic-slide", + "rig/examples/dynamic-slide.grida.xml", + include_str!("../rig/examples/dynamic-slide.grida.xml"), + (1280, 720), + ), + ( + "rich-fills", + "rig/examples/rich-fills.grida.xml", + include_str!("../rig/examples/rich-fills.grida.xml"), + (720, 300), + ), + ( + "rich-strokes", + "rig/examples/rich-strokes.grida.xml", + include_str!("../rig/examples/rich-strokes.grida.xml"), + (720, 320), + ), + ( + "source-becomes-surface", + "rig/examples/source-becomes-surface.grida.xml", + include_str!("../rig/examples/source-becomes-surface.grida.xml"), + (1600, 1000), + ), +]; + +fn load_resources(doc: &Document, source_path: &Path, ctx: &mut PaintCtx) { + let base = source_path + .parent() + .expect("example has a parent directory"); + for id in 0..doc.capacity() as u32 { + let Some(node) = doc.get_opt(id) else { + continue; + }; + let paints = node + .fills + .iter() + .chain(node.strokes.iter().flat_map(|stroke| stroke.paints.iter())); + for paint in paints.filter(|paint| paint.visible()) { + let Paint::Image(image) = paint else { + continue; + }; + let ResourceRef::Rid(rid) = &image.image else { + panic!("checked-in examples use RID image resources"); + }; + if ctx.contains_image(rid) { + continue; + } + let path = base.join(rid); + let bytes = + std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + ctx.insert_encoded(rid.clone(), &bytes) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + } + } +} + +#[test] +fn checked_in_examples_are_canonical_writer_fixpoints() { + for (name, _, source, _) in EXAMPLES { + let doc = grida_xml::parse(source).unwrap_or_else(|error| panic!("{name}: {error}")); + let printed = grida_xml::print(&doc).unwrap_or_else(|error| panic!("{name}: {error}")); + assert_eq!(printed, source, "{name} is not canonical Draft 0 source"); + } +} + +#[test] +fn checked_in_examples_resolve_resources_and_render() { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + for (name, relative_path, source, (width, height)) in EXAMPLES { + let doc = grida_xml::parse(source).unwrap_or_else(|error| panic!("{name}: {error}")); + let mut ctx = PaintCtx::new(None); + load_resources(&doc, &manifest.join(relative_path), &mut ctx); + let mut surface = surfaces::raster_n32_premul((width, height)) + .unwrap_or_else(|| panic!("{name}: raster allocation failed")); + surface.canvas().clear(Color::WHITE); + let options = ResolveOptions { + viewport: (width as f32, height as f32), + ..Default::default() + }; + let (resolved, drawlist, _) = + frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, &ctx); + assert!( + !resolved.reports.iter().any(|report| matches!( + report, + Report::IgnoredByRule { .. } | Report::ErrorByRule { .. } + )), + "{name}: unresolved intent: {:?}", + resolved.reports + ); + assert!(!drawlist.items.is_empty(), "{name}: empty drawlist"); + assert_eq!( + surface.canvas().save_count(), + 1, + "{name}: leaked canvas state" + ); + } +} From 4cf9da7a327102e72918d4b662fd09c130c6892e Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 12 Jul 2026 00:24:05 +0900 Subject: [PATCH 12/33] docs(model-v2): frame animation model and prior art --- docs/wg/format/grida-xml-animation.md | 429 +++++++++++ docs/wg/format/index.md | 1 + .../research/motion-graphics/_category_.json | 3 + docs/wg/research/motion-graphics/index.md | 616 ++++++++++++++++ model-v2/engine/ANIMATION.md | 664 ++++++++++++++++++ 5 files changed, 1713 insertions(+) create mode 100644 docs/wg/format/grida-xml-animation.md create mode 100644 docs/wg/research/motion-graphics/_category_.json create mode 100644 docs/wg/research/motion-graphics/index.md create mode 100644 model-v2/engine/ANIMATION.md diff --git a/docs/wg/format/grida-xml-animation.md b/docs/wg/format/grida-xml-animation.md new file mode 100644 index 0000000000..2d3b59d8fd --- /dev/null +++ b/docs/wg/format/grida-xml-animation.md @@ -0,0 +1,429 @@ +--- +title: "Grida XML animation — day-one questions" +description: "Open RFD asking whether and how .grida.xml should carry declarative animation from its first stable version, using SVG and Web Animations as problem-shaping references without adopting a syntax or runtime model." +keywords: + - grida xml + - animation + - declarative animation + - timeline + - keyframes + - svg animation +tags: + - internal + - wg + - format-schema + - canvas + - authoring + - rendering + - svg +format: md +--- + +# Grida XML animation — day-one questions + +**Status:** Open RFD — question framing only. No syntax, timing model, or +runtime behavior in this document is adopted. + +## The question + +Should declarative animation be part of the first stable Grida XML language, +and, if so, what is the smallest coherent model that keeps animated documents +inspectable, seekable, editable, and portable? + +The question belongs beside the [Grida XML RFD](./grida-xml), because it is +about authored source rather than one renderer or editor. A general animation +runtime may eventually deserve its own canvas-level specification. This RFD +does not define that runtime; it asks what contract the authored language must +leave room for from day one. + +“Support from day one” may mean any of the following, and the distinction is +itself open: + +1. the first stable language fully parses and plays a minimal animation + subset; +2. the first stable language defines the source model, even if some processors + expose only a static frame; +3. the first stable language merely reserves the structural positions and + names needed for a later compatible addition. + +These are materially different commitments. This RFD exists so that the +static scene language does not accidentally choose among them by omission. + +## Why ask before the static language hardens? + +Animation is not only “values changing over time.” It affects several +foundational contracts: + +- whether a property has one value or distinct authored and sampled values; +- whether animation is nested with its target or stored in a separate + timeline graph; +- whether nodes need durable target identity; +- whether layout observes animated geometry; +- how multiple animations combine on one property; +- what a static renderer, thumbnailer, exporter, or agent sees; +- whether time- and event-dependent source remains deterministic and safe. + +Adding those distinctions after a stable static format may require a second +property model, a second identity scheme, or a second canonical source shape. +Raising them now does not require implementing all of animation now. + +## What SVG contributes to the question + +SVG is a useful reference because declarative animation is part of its element +tree rather than an unrelated movie format. SVG defines animation elements +such as `animate`, `set`, `animateTransform`, and `animateMotion`; SVG 2 also +recognizes that declarative animation may be enabled or disabled by a +processing mode. The [SVG animation chapter](https://www.w3.org/TR/SVG2/animate.html) +and [SVG 2 conformance processing modes](https://www.w3.org/TR/SVG2/conform.html) +are the primary references. + +The strongest lessons are model-level, not spelling-level: + +- **Base and animated values are different facts.** Editing the authored value + must not overwrite it with one sampled frame. Visual output at time `t` may + legitimately differ from source. +- **Timing and value interpolation are separate problems.** A timeline turns + time into progress; a value model turns progress into a property value. The + [Web Animations model](https://www.w3.org/TR/web-animations-1/) makes this + separation explicit. +- **Discrete change deserves first-class treatment.** SVG's `set` does not + pretend that every string, boolean, enum, or resource reference can be + interpolated. +- **Several effects may target the same property.** SVG/SMIL defines ordering, + replacement, addition, accumulation, and post-interval behavior rather than + relying on document order alone. +- **Motion is more than x/y interpolation.** A motion path has path geometry, + progress along the path, and orientation behavior. +- **Synchronization grows quickly.** Offset times, repeats, event starts, and + one animation beginning relative to another form a dependency graph, not a + collection of independent timers. + +The existing [Chromium SVG animation study](../research/chromium/svg/animation-and-smil.md) +records how those distinctions materialize in a browser. The +[SVG element model](../../reference/svg/element-model.md) records the +round-trip hazard: edit decisions concern the base value while rendering may +show an animated value. + +SVG also supplies warnings rather than templates. Its SMIL and CSS animation +systems can target the same element and require precedence rules. Event timing +and animation of reference-valued attributes enlarge the active-content and +security surface; see the [untrusted SVG rendering study](../research/untrusted-svg-rendering.md). +Grida XML should not inherit this complexity merely because the element names +are familiar. + +The cross-project +[motion graphics authoring landscape](../research/motion-graphics/index.md) +surveys how Lottie, After Effects, Blender, Rive, dotLottie, Cavalry, Apple +Motion, PowerPoint, and Keynote extend the property-animation kernel through +clips, nested time, procedural value sources, controllers, templates, and +authoring views. It is descriptive prior art, not part of this RFD. + +## Illustrative syntax probes, not proposals + +An SVG-like child element is one possible shape: + +```xml + + + +``` + +A structured keyframe child is another: + +```xml + + + + + + +``` + +A separate timeline graph is a third: + +```xml + + + + + + +``` + +None of these fragments is valid Grida XML today. They expose different +tradeoffs: + +| Shape | Attractive property | Immediate question | +| ------------------------- | ------------------------------------------- | --------------------------------------------------------------------------- | +| Animation child of target | Local, readable, SVG-like | How are animation property children distinguished from render children? | +| Structured keyframes | One grammar for arbitrary ramps | Is it unnecessarily verbose for the common two-value case? | +| Separate timeline graph | Central sequencing and cross-target editing | What durable target identity does `target` use? | +| Named reusable animation | Reuse across nodes or instances | Are overrides and instance-local time worth a definition system on day one? | + +The RFD should not select a spelling until the value, timing, targeting, and +composition questions below have answers. Syntax chosen first will conceal +rather than resolve those model decisions. + +## Open question set + +### 1. Base value and sampled value + +- Does every animatable property have an authored base value and a separate + sampled value? +- Is sampling a pure function of `(document, environment, time)`, or may prior + playback state affect the result? +- When no animation contributes, does the sampled value equal the base value + exactly? +- When an editor changes a property during playback, is it editing the base, + the selected keyframe, an animation-relative offset, or a new override? +- Can a canonical writer ever serialize a sampled frame, or only authored + animation intent? +- What does inspection show: base, sampled, both, or an explicit mode? + +The SVG base/current split suggests that conflating the two is unsafe. It does +not decide what Grida's public vocabulary should be. + +### 2. Timeline ownership + +- Is there exactly one document timeline, or can a subtree, component, + presentation page, or embedded asset own a timeline? +- What establishes time zero? +- Does a document autoplay, open paused at zero, or defer playback policy to + the host? +- Is document duration explicit, derived from effects, infinite, or absent? +- Are time values integer ticks, decimal seconds, or unit-bearing values such + as `600ms`? +- What precision and boundary rules make sampling deterministic across + implementations? +- Must arbitrary seeking produce the same result as continuous playback to + that time? + +### 3. Effect timing + +- Which day-one fields are indispensable: delay/begin, duration, iterations, + direction, easing, end delay, playback rate, and before/after fill behavior? +- Are SVG-like `fill="freeze|remove"` terms desirable, or too easily confused + with visual fill paints? +- Does an omitted duration mean invalid, instantaneous, inferred, or + indefinite? +- Are negative delays and fractional iterations allowed? +- What occurs exactly at begin, repeat, and end boundaries? +- Is an always-seekable offset-only model sufficient initially, or is + interval scheduling part of the minimum coherent kernel? + +### 4. Keyframes and value sources + +- Is a two-value `from`/`to` form canonical, shorthand, or absent in favor of + keyframes? +- Can a keyframe omit a value and use the underlying/base value? +- Are offsets required, inferred, or mixed? +- Is easing attached to the whole effect, to each segment, or both? +- Is there a distinct discrete `set` operation for non-interpolable values? +- Can values be relative (`by`, additive delta), or are all keyframes absolute? +- How are animation values typed and validated against their target property? + +### 5. Animatable property registry + +- Is every authored property animatable unless refused, or only properties + explicitly registered as animatable? +- Which initial categories are coherent: numbers, positions, sizes, colors, + opacity, transforms, paint properties, paths, text, and layout controls? +- Are `auto`, intrinsic sizes, spans, and other intent values interpolable, + discrete, or invalid in animation? +- Does animating layout intent re-run layout at each sample? +- Can hierarchy, node type, child order, component reference, or resource + identity change over time? +- Should properties that can cause I/O or change trust boundaries be + categorically non-animatable? + +The registry is a language contract. Leaving interpolation behavior to each +renderer would make one source file produce different designs. + +### 6. Interpolation semantics + +- Which color space and alpha model interpolate colors and gradient stops? +- How do angles choose a direction and handle multiple turns? +- Are transforms interpolated as matrix entries, decomposed components, or + typed transform operations? +- What happens when two transform lists have different operation shapes? +- What correspondence rules make two paths morphable? +- How do paint stacks or gradients with different layer/stop counts combine? +- For non-interpolable pairs, does the language reject the animation or fall + back to a defined discrete step? + +### 7. Multiple effects on one property + +- Does the later animation replace the earlier one, or is priority based on + begin time, source order, explicit priority, or timeline order? +- Are replace, add, and accumulate separate composition modes? +- Which value is the “underlying” value for each effect in a stack? +- Does repeated additive animation accumulate per iteration? +- How does a frozen/held effect interact with a later active effect? +- Can transitions and authored animations coexist, or should Grida have one + animation engine and one precedence model? + +SVG's animation sandwich and Web Animations' effect stack show that this is a +bedrock question, even if day-one syntax permits only replacement. + +### 8. Targeting and identity + +- Is an animation always nested under and implicitly targets its parent? +- Can it target another node, paint, gradient stop, text run, component + parameter, or effect? +- If cross-targeting exists, what stable authored identity names the target? +- Are property paths textual names, typed child selectors, or durable schema + identifiers? +- What happens when the target is deleted, renamed, moved, or instantiated + through a component? +- Does copying a subtree copy, retarget, share, or detach its animations? + +Avoiding external targeting initially reduces the identity burden, but may +make synchronization and timeline editing awkward. The tradeoff remains open. + +### 9. Transform and motion-path specialization + +- Is transform animation ordinary property animation, or does it need a typed + `animate-transform` form to preserve operation semantics? +- Is motion along a path representable as x/y/rotation keyframes, or does a + motion-path effect need its own path, distance, orientation, and anchor + model? +- Can a motion path reference scene geometry, or must it own immutable path + data? +- Does motion compose before or after the node's authored transform, + constraints, native rotation, and layout placement? +- Can layout observe motion, or is it always visual-only? + +### 10. Synchronization and triggers + +- Is day-one timing limited to absolute offsets from one timeline? +- May one animation begin relative to another animation's begin, repeat, or + end? +- Are event-triggered begins part of the authored language? +- If events exist, what event vocabulary exists in a non-DOM scene? +- Are scroll-, audio-, presentation-cue-, or data-driven timelines future + timeline types or day-one requirements? +- Can cyclic synchronization dependencies be represented, and if so how do + they resolve? +- Does deterministic export refuse event-driven animation, supply an event + trace, or select a fallback frame? + +SVG demonstrates the expressive value of sync-base and event timing, while +also demonstrating their implementation and security cost. + +### 11. Layout, rendering, and performance tiers + +- Is every animation sampled before measure/layout, between layout and paint, + or according to the property it targets? +- Which animations require re-measurement, re-layout, repaint, or only + transform/opacity recomposition? +- Is that classification normative so processors cannot disagree about + geometry? +- Can a processor lower eligible effects to a compositor without changing the + sampled result? +- What frame-rate-independent sampling contract applies when frames are + skipped? +- How are dynamic visual bounds, damage, hit testing, clipping, and culling + computed over time? + +Performance optimization is not a source-language feature, but the language +must not make the correct sampling order ambiguous. + +### 12. Resources, safety, and processing modes + +- May animation target image source, link destination, component reference, + font resource, or any other reference-valued property? +- Can a sampled value initiate resource loading or network access? +- Are event-triggered animations declarative-only, or can they invoke actions? +- Does Grida XML define an inert/static processing mode that ignores animation + elements, a paused mode that samples at a supplied time, and a playback mode? +- What is the required fallback frame when animation is unsupported or + disabled? +- Should reduced-motion preference be an environment input, authored + alternative, or both? + +SVG shows that “no script” is not by itself a complete safety boundary when +declarative animation can retarget references or react to events. + +### 13. Static consumers and export + +- What frame does a thumbnailer render when no time is supplied? +- Does static export choose time zero, a poster time, the base scene, or reject + an animated document without an explicit choice? +- How are finite animations exported to video, animated raster, slides, or a + frame sequence? +- Is frame rate source intent, export policy, or merely a sampling choice? +- Can an animated embedded document keep its own clock, and does a repeated + image fill restart or share that clock? + +### 14. Canonical source and authorability + +- Is there exactly one canonical representation of keyframes and timing? +- Are animation property names the same names used by static source? +- Where may animation property elements appear relative to fills and render + children? +- Does source order carry semantic priority, or only human-readable order? +- Which defaults can safely be omitted without making timing hard to inspect? +- Can a human or language model predict the state at a named time without + executing hidden rules? +- How are invalid targets, type mismatches, unsorted offsets, duplicate + offsets, cycles, and unsupported interpolation reported? + +### 15. Import, preservation, and interoperability + +- Does importing animated SVG preserve animation as authored intent, translate + only a supported subset, sample one frame, or refuse animated content? +- Can unsupported timing or composition be preserved losslessly without being + executed? +- Does exporting Grida animation to SVG target SVG animation elements, CSS + keyframes, a flattened frame sequence, or a capability-dependent choice? +- How are semantic losses reported rather than silently baked? +- Is “SVG-like” a source-syntax goal, a behavioral compatibility goal, or only + inspiration for the model split? + +### 16. Conformance and testing + +- What exact-time samples form a minimal conformance corpus? +- Must sampling a document at time `t` be byte- or value-equivalent whether + reached by seeking, forward playback, reverse playback, or dropped frames? +- Which boundary times test begin/end inclusion, repeats, easing endpoints, + discrete changes, and held values? +- How are cross-implementation tolerances defined for interpolated colors, + transforms, paths, and raster output? +- What test proves that playback never mutates authored base intent? + +## Candidate day-one scopes to compare + +These are comparison points, not recommendations. + +| Candidate | Stable on day one | Deferred | Main risk | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Reservation only | Structural slots/names and a declared static fallback | All executable animation | The reservation may encode the wrong future model | +| Minimal local effects | Parent-targeted replace animation; absolute begin, duration, finite repeat; numeric/color/opacity/transform values; deterministic seeking | Events, sync-base, motion paths, additive composition, reusable timelines | “Minimal” may still choose irreversible timing and interpolation defaults | +| SVG-shaped subset | `animate`, `set`, transform, and motion concepts with familiar timing fields | The long tail of SMIL timing | Familiar syntax may imply compatibility the behavior does not meet | +| Timeline/keyframe core | One abstract timeline/effect/keyframe model shared by markup, editor, and runtime | Specialized sugar and event timelines | More abstract and verbose for simple authored scenes | +| Full declarative system | Targeting, synchronization, composition, motion, and processing modes | Only scripting/API control | Excessive first-version complexity and a large active-content surface | + +The next RFD step is not to pick a tag name. It is to decide which row is the +smallest scope that preserves the invariants Grida cannot afford to retrofit. + +## Decision gates before any normative draft + +A normative animation proposal should not begin until it can answer, with +examples and counterexamples: + +1. What are the base-value and sampled-value tiers? +2. What timeline and deterministic-seeking model is canonical? +3. What is the day-one animatable property registry? +4. How do effects on the same property compose? +5. How are targets identified and copied? +6. Which animation constructs can affect layout, resources, or trust? +7. What does every static consumer render? +8. Which syntax is canonical, and why is it easier to author and diagnose than + the alternatives? +9. What is preserved, translated, sampled, or refused when importing SVG? +10. What conformance corpus would let a second implementation produce the same + sampled scene? + +Until those gates close, all animation-like XML fragments remain illustrative +only and a strict Grida XML reader should continue to reject them rather than +guess at semantics. diff --git a/docs/wg/format/index.md b/docs/wg/format/index.md index ee388a6111..881db8ccb3 100644 --- a/docs/wg/format/index.md +++ b/docs/wg/format/index.md @@ -18,6 +18,7 @@ Tracking docs for the Grida IR schema and how external formats map into it. | [Grida IR](./grida) | Canonical IR reference — node types, paint, layout | | [Grida XML](./grida-xml) | Open RFD for the authored, inspectable `.grida.xml` source | | [Grida XML properties](./grida-xml-properties) | XML property names, applicability, and design placeholders | +| [Grida XML animation](./grida-xml-animation) | Open day-one animation question set for `.grida.xml` | | [CSS](./css) | CSS → Grida IR property mapping and TODO tracker | | [HTML](./html) | HTML element → Grida IR node mapping | | [SVG](./svg) | SVG → usvg → Grida IR mapping and TODO tracker | diff --git a/docs/wg/research/motion-graphics/_category_.json b/docs/wg/research/motion-graphics/_category_.json new file mode 100644 index 0000000000..d76c96a1ac --- /dev/null +++ b/docs/wg/research/motion-graphics/_category_.json @@ -0,0 +1,3 @@ +{ + "label": "Motion graphics" +} diff --git a/docs/wg/research/motion-graphics/index.md b/docs/wg/research/motion-graphics/index.md new file mode 100644 index 0000000000..be6868db60 --- /dev/null +++ b/docs/wg/research/motion-graphics/index.md @@ -0,0 +1,616 @@ +--- +title: "Motion graphics authoring landscape" +description: "A qualitative study of animation models and authoring ergonomics across SVG and Web Animations, Lottie, After Effects, Blender, Rive, dotLottie, Cavalry, Apple Motion, PowerPoint, and Keynote." +keywords: + - motion graphics + - animation authoring + - keyframes + - timeline + - lottie + - after effects + - blender + - rive + - cavalry +tags: + - internal + - research + - authoring + - editor + - rendering +format: md +--- + +# Motion graphics authoring landscape + +**Study type:** Qualitative product, format, and workflow survey. This is not a +controlled usability study, a market-share analysis, or a normative animation +specification. + +## Scope and method + +This study asks two bounded questions: + +1. Which persistent animation concepts distinguish professional motion-graphics + systems from basic property animation? +2. Which apparent capabilities belong to the saved/evaluated model, and which + are editor ergonomics over that model? + +The comparison covers open web standards, interchange formats, timeline-based +compositors, general digital-content-creation tools, interactive vector +runtimes, procedural 2D tools, and presentation software. “Market” denotes +these product and workflow categories; it does not imply revenue, adoption, or +ranking claims. + +Sources are the projects' official specifications, manuals, and support +documentation, consulted in July 2026. The descriptions below use each +system's own terms. Product behavior outside the cited material was not assumed. + +## Landscape by role + +The systems occupy different positions in the path from authoring to delivery: + +| system | primary role in the landscape | characteristic model | +| -------------------- | --------------------------------------------- | ------------------------------------------------------------------ | +| SVG / Web Animations | open document and browser animation semantics | timeline + effect timing + typed property interpolation | +| Lottie | portable vector-animation interchange | frame-based compositions, layers, and animatable properties | +| Adobe After Effects | layered motion-graphics authoring/compositing | compositions, layers, property tracks, effects, and expressions | +| Blender | general DCC animation and rigging | Actions, F-Curves, NLA strips, drivers, and constraints | +| Rive | interactive vector content runtime | timelines, state machines, blend states, layouts, and data binding | +| dotLottie | packaged Lottie delivery and interactivity | multiple Lottie animations, themes, assets, and state machines | +| Cavalry | procedural 2D motion design | attributes, keyframes, Behaviours, Utilities, and generators | +| Apple Motion | real-time motion graphics and templates | keyframes, Behaviors, rigs, replicators, and published controls | +| PowerPoint / Keynote | presentation choreography | ordered effects, cues, and state-to-state slide transitions | + +This role distinction matters. An authoring project can retain controls, +expressions, caches, and editor state that an interchange file intentionally +omits. A runtime asset can retain interaction logic that a linear video export +does not need. + +## A convergent evaluation shape + +Despite different vocabulary, the systems repeatedly separate the same broad +stages: + +```text +authoring views + timeline · dope sheet · graph editor · state graph · canvas handles + | + v +persistent animation semantics + tracks · clips · local time · value sources · constraints · controllers + | + v +evaluation at time t + declared inputs + | + v +sampled scene state + | + v +layout / composition / raster / presentation +``` + +The key difference between basic animation and motion-graphics systems is not +the existence of an easing curve. It is how many persistent value sources can +be composed, reused, retimed, selected, parameterized, and rendered. + +## The established keyframe kernel + +### SVG and Web Animations + +SVG declarative animation provides `animate`, `set`, `animateTransform`, and +`animateMotion`. SVG processing modes can enable or disable declarative +animation rather than merely pausing it at zero. The model includes base and +animated values, interval timing, repeat behavior, motion paths, and multiple +contributions to a target. + +[Web Animations](https://www.w3.org/TR/web-animations-1/) generalizes the +browser model into timelines, animation effects, effect timing, playback +control, keyframes, and composite operations. It separates: + +- the timeline that supplies time; +- the timing model that converts time to effect progress; +- keyframe interpolation that converts progress to a property value; +- the effect stack that combines contributions with an underlying value. + +The [SVG processing-mode definition](https://www.w3.org/TR/SVG2/conform.html) +and [CSS motion-path model](https://www.w3.org/TR/motion-1/) also make two +boundaries explicit: animation can be a processor capability, and path motion +has anchor, distance, and orientation semantics beyond x/y interpolation. + +These standards provide a mature property-animation kernel. They do not +provide the complete composition, template, procedural, and authoring-project +models found in dedicated motion tools. + +### Lottie + +Lottie is a JSON-based interchange format for animated vector graphics. Its +top-level animation declares a frame rate, in/out points, dimensions, layers, +assets, markers, and replaceable slots. Layers add local start and visibility +times, parenting, transforms, masks, mattes, and precomposition references. +Precomposition layers support time stretch and an animatable time-remap +property. See the official [composition](https://lottie.github.io/lottie-spec/latest/specs/composition/) +and [layer](https://lottie.github.io/lottie-spec/latest/specs/layers/) +specifications. + +An animatable Lottie property switches between a static value and an ordered +keyframe array. Keyframes carry frame time, hold behavior, temporal easing +tangents, typed values, and—where applicable—spatial tangents. Scalar, vector, +position, color, gradient, and Bézier-shape properties specialize this common +shape. See [Lottie properties](https://lottie.github.io/lottie-spec/latest/specs/properties/). + +Lottie also persists motion-graphics structures that are absent from basic SVG +animation: nested compositions, null layers, track mattes, trim paths, shape +modifiers, auto-orient, and time remapping. Its shape model distinguishes path +geometry, styles, modifiers, grouping, and their order of application. See the +[Lottie shape model](https://lottiefiles.github.io/lottie-spec/specs/shapes/). + +The formal specification remains deliberately narrower than the historical +ecosystem. Lottie 1.0 covered features that were commonly implemented and +behaved consistently across players; its published feature list says that not +all features are covered. Expressions remain a commonly used extension outside +the normative format and can execute code. See the +[Lottie specification changelog](https://lottie.github.io/changelog/) and +[format security considerations](https://lottie.github.io/lottie-spec/dev/specs/format/). + +This makes Lottie an instructive interoperability boundary: the portable +subset is defined by shared renderer behavior, not by everything an upstream +authoring application can express. + +## Layered composition and reuse + +### Adobe After Effects + +After Effects organizes animation around compositions, ordered layers, and +animatable properties. Its Graph Editor distinguishes value graphs from speed +graphs, and its keyframe model distinguishes temporal interpolation from +spatial interpolation. Temporal modes include linear, Bézier variants, and +hold; spatial properties additionally support spatial paths and roving +keyframes. See [keyframe interpolation](https://helpx.adobe.com/uk/after-effects/using/keyframe-interpolation.html). + +A nested composition is both reusable content and a layer in another +composition. The nested source retains its own layer structure and timing; the +containing composition supplies another layer transform and can alter render +ordering. A network of nested compositions therefore forms a hierarchy of +local scene and time domains. See +[precomposing and nesting](https://helpx.adobe.com/uk/after-effects/using/precomposing-nesting-pre-rendering.html). + +Two template features expose distinctions that ordinary tracks do not cover: + +- **Essential Properties** expose selected source properties on a nested + composition. An instance can override a source value, keyframes, or an + expression without changing the reusable source. +- **Responsive Design — Time** marks protected regions whose duration remains + fixed while unprotected regions stretch when a nested composition or Motion + Graphics template changes duration. + +See [Essential Properties](https://helpx.adobe.com/uk/after-effects/using/essential-properties.html) +and [responsive-time regions](https://helpx.adobe.com/after-effects/using/responsive-design.html). + +After Effects also retains domain-specific motion structures: + +- shape-layer paths, fills, strokes, and ordered path operations including + trim, repeater, offset, wiggle, and boolean operations; +- text animator groups whose selectors distribute an effect across + characters, characters excluding spaces, words, or lines; +- expressions that can drive any keyframe-capable property and link properties + across layers or compositions; +- composition-level motion-blur parameters including shutter angle, shutter + phase, minimum samples, and an adaptive sample limit. + +These structures are described in the official documentation for +[shape operations](https://helpx.adobe.com/after-effects/desktop/drawing-painting-and-paths/shapes-and-shape-attributes/shape-attributes-paint-operations-path.html), +[text animators and selectors](https://helpx.adobe.com/after-effects/desktop/animating-text/text-animation/animating-text.html), +[expressions](https://helpx.adobe.com/after-effects/using/edit-expressions.html), +and [motion-blur sampling](https://helpx.adobe.com/sg/after-effects/using/assorted-animation-tools.html). + +The persistent model is consequently broader than a collection of keyframes. +It includes nested time, ordered geometry operators, selection functions over +generated sub-elements, dependent property values, and temporal rendering. + +### Blender + +Blender divides reusable animation from the objects that consume it: + +- an **F-Curve** describes one property's value as a function of time; +- an **Action** is a data-block containing animation channels and their + F-Curves; +- an **NLA strip** references an Action and places, scales, repeats, and blends + it in a larger timeline; +- **NLA tracks** sequence strips and allow several Actions to contribute at the + same time; +- a **Driver** derives one property from variables, built-in functions, or a + scripted expression, then may map the result through an F-Curve; +- a **Constraint** derives transforms or other constrained values, with an + ordered constraint stack. + +The [F-Curve introduction](https://docs.blender.org/manual/en/latest/editors/graph_editor/fcurves/introduction.html), +[Actions manual](https://docs.blender.org/manual/en/latest/animation/actions.html), +[NLA track documentation](https://docs.blender.org/manual/en/latest/editors/nla/tracks.html), +[Drivers introduction](https://docs.blender.org/manual/en/latest/animation/drivers/introduction.html), +and [constraint-stack documentation](https://docs.blender.org/manual/en/latest/animation/constraints/interface/stack.html) +describe these layers. + +The editors correspond to different views of that data: + +- the Dope Sheet presents a scene-wide, timing-oriented view of keys; +- the Action Editor focuses on one Action; +- the Graph Editor exposes F-Curve values, interpolation, easing, handles, + extrapolation, and modifiers; +- the NLA Editor edits clip placement, scale, repetition, influence, and blend; +- the Drivers Editor exposes property dependencies and their mapping function. + +Blender visually distinguishes unanimated properties, properties keyed at the +current frame, properties keyed elsewhere, values changed away from a keyed +sample, and driver-controlled values. This makes the source of a displayed +value inspectable from the ordinary property interface. See the +[animation state-color reference](https://docs.blender.org/manual/en/latest/animation/introduction.html). + +Blender's Bake Action operation samples the evaluated result after F-Curve +modifiers, drivers, and constraints and writes ordinary keyframes. Baking is +therefore an explicit boundary between a rich procedural project and a simpler +sampled representation. The same documentation notes that scripted drivers +outside a restricted expression subset use Python, run more slowly, and pose a +security risk for untrusted files. + +## Procedural and interactive value sources + +### Rive and dotLottie + +Rive separates Design mode from Animate mode. An artboard can own multiple +timeline animations and multiple state machines. Selecting a timeline presents +a timeline editor; selecting a state machine presents a graph editor. See +[Design versus Animate mode](https://rive.app/docs/editor/fundamentals/design-vs-animate-mode). + +A Rive state machine contains layers, states, transitions, conditions, and +inputs. Timeline animations become states; blend states combine several +timelines using numeric inputs; multiple state-machine layers can contribute to +the same artboard with declared layer precedence. Rive's current data-binding +model uses typed view-model properties that can drive state transitions, blend +weights, and bindable editor properties. See +[state-machine structure](https://rive.app/docs/editor/state-machine/state-machine), +[blend states](https://rive.app/docs/editor/state-machine/states), +[state-machine layers](https://rive.app/docs/editor/state-machine/layers), and +[data binding](https://rive.app/docs/editor/data-binding/overview). + +At runtime a state machine advances with elapsed time, evaluates animations, +transitions, and data changes, and can settle when no future change is pending. +External changes unsettle it. This differs from sampling a stateless linear +clip: the controller has persistent state in addition to timeline time. See +[state-machine playback](https://rive.app/docs/runtimes/state-machines). + +Rive also animates responsive layout. A layout container can define how its +size and child positions interpolate when content reflows, and children can +inherit the parent's transition parameters. This is derived motion over layout +results rather than a fixed coordinate track. See +[Rive layout animation](https://rive.app/docs/editor/layouts/layout-animation). + +dotLottie packages one or more Lottie animations with assets, fonts, themes, +and optional state machines. Its state machine format defines playback states, +global states, transitions, guards, inputs, actions, playback modes, speed, and +marker-selected segments. This places interaction and playback control around +linear Lottie animations instead of adding another property-keyframe grammar. +See the [dotLottie 2.0 specification](https://dotlottie.io/spec/2.0/). + +### Cavalry + +Cavalry combines keyframed Attributes with procedural Behaviours, Utilities, +generators, constraints, and connections between layers. Its Graph Editor can +show both ordinary keyframe curves and the inherited curve produced when, for +example, a Noise Behaviour drives an Attribute. See the +[Cavalry Graph Editor](https://cavalry.studio/docs/user-interface/menus/window-menu/scene-window/graph-editor/). + +The Graph Editor supports linear, Bézier, and step interpolation; curve +looping, loop-with-offset, and oscillation; curve ghosting; key alignment; and +time/value scaling. In-viewport motion paths expose spatial keyframes and +velocity controls. See +[Cavalry motion paths](https://cavalry.studio/docs/user-interface/menus/window-menu/viewport/motion-paths/). + +Animation Control remaps a complete animation curve to a normalized percentage +and permits another Attribute to drive that percentage. The Bake Animation +command samples procedural animation from Behaviours or Magic Easing into +keyframes. See [Animation Control](https://cavalry.studio/docs/nodes/utilities/animation-control/) +and the [Animation menu](https://cavalry.studio/docs/user-interface/menus/animation-menu/). + +The coexistence of previewable procedural curves and a bake operation makes +the distinction between editable value generators and sampled interchange +explicit. + +### Apple Motion + +Apple Motion presents keyframes and Behaviors as complementary animation +sources. Keyframes specify exact values at exact frames. Behaviors generate +values over a duration and include motion, simulation, parameter, text, audio, +camera, particle, and replicator families. Behaviors can affect object +properties, other objects, or parameters of other Behaviors, and their order of +operations with keyframes is significant. See the +[Motion User Guide](https://help.apple.com/motion/mac/) and +[Behavior application model](https://help.apple.com/motion/mac/5.0/help/English/en/motion/usermanual/chapter_9_section_3.html). + +Parameter Behaviors include operations such as audio response, averaging, +clamping, linking, quantization, randomization, rate, reverse, and wriggle. +Their results can be combined with keyframes under declared apply modes. See +[Parameter Behaviors](https://help.apple.com/motion/mac/5.0/help/English/en/motion/usermanual/chapter_9_section_10.html). + +Motion rigs expose a small set of widgets—sliders, pop-up menus, and +checkboxes—that can control many internal parameters. Published widgets and +parameters form the editing surface presented to Final Cut Pro users while the +template retains its full internal construction. See +[rigs and widgets](https://help.apple.com/motion/mac/5.0/help/English/en/motion/usermanual/chapter_10_section_1.html) +and [published template parameters](https://help.apple.com/motion/mac/5.0/help/English/en/motion/usermanual/chapter_11_section_15.html). + +Template timing uses markers to distinguish regions that may stretch, remain +fixed, or loop when a template is applied to media of another duration. The +manual recommends Behaviors for many template animations because they are less +dependent on specific keyframe times. See +[template animation and timing](https://help.apple.com/motion/mac/5.0/help/English/en/motion/usermanual/chapter_11_section_16.html). + +## Presentation-oriented authoring + +PowerPoint and Keynote reduce the amount of timeline manipulation required for +common presentation motion. + +PowerPoint's Morph transition derives an animation from corresponding objects +on adjacent slides. Authors duplicate or otherwise construct a destination +slide, then edit endpoint position, size, rotation, color, or content. +PowerPoint can infer correspondence and also permits explicit correspondence by +giving one object on each slide the same `!!`-prefixed name. See +[Morph](https://support.microsoft.com/en-us/powerpoint/training/use-the-morph-transition-in-powerpoint) +and [Morph correspondence rules](https://support.microsoft.com/en-us/powerpoint/morph-transition-tips-and-tricks). + +Within a slide, PowerPoint orders effects in an Animation Pane and offers cue +relationships—On Click, With Previous, and After Previous—plus duration, +delay, repeat, and rewind. Several effects can target one object and may run +concurrently. See +[animation start and speed](https://support.microsoft.com/en-US/PowerPoint/set-the-start-time-and-speed-of-an-animation-effect) +and [multiple effects](https://support.microsoft.com/en-us/powerpoint/apply-multiple-animation-effects-to-one-object). + +Keynote's Magic Move follows the same endpoint-state pattern: the transition is +most effective when adjacent slides contain a common object whose position or +appearance changes. See +[Keynote transitions](https://support.apple.com/guide/keynote/add-transitions-tanff5ae749e/mac). + +These workflows reveal two authoring abstractions that are distinct from a +curve editor: + +- **state-difference authoring**, where correspondence and endpoint changes + generate intermediate motion; +- **cue-relative sequencing**, where effects are ordered by user actions or + neighboring effects rather than only by absolute timestamps. + +## Motion semantics beyond ordinary keyframes + +The systems repeatedly persist the following concepts in addition to basic +property tracks: + +| concept | manifestations in the surveyed systems | +| ------------------------------ | ----------------------------------------------------------------------------------------------- | +| reusable animation unit | Blender Action · Rive timeline · nested AE composition · Lottie animation/precomposition | +| clip instance | Blender NLA strip · AE/Lottie precomposition layer · Rive state referencing a timeline | +| local time mapping | NLA strip scale/repeat · AE/Lottie start/stretch/time remap · responsive protected regions | +| multiple value contributions | Web Animations effect stack · NLA tracks · Rive layers/blends · Motion Behavior apply modes | +| derived property value | Blender Driver/Constraint · AE expression · Cavalry connection/Behaviour · Motion Link Behavior | +| generated-element selection | AE text selectors · shape repeaters · Cavalry per-character/group and falloff tools | +| ordered geometry operation | AE/Lottie trim/repeater/offset/wiggle/boolean shape operators | +| interaction controller | Rive state machine · dotLottie state machine · PowerPoint click cues | +| typed external parameter | Rive view model · AE Essential Properties · Motion published widgets | +| responsive spatial transition | Rive layout animation · PowerPoint/Keynote state-to-state morph | +| responsive temporal transition | AE protected regions · Motion template markers | +| temporal rendering | AE shutter angle/phase and sample limits | +| simplification boundary | Blender Bake Action · Cavalry Bake Animation · video/raster export | + +Some concepts can be flattened for a particular delivery target. Flattening +changes the artifact, however: a baked per-character reveal no longer adapts to +different text; baked layout motion no longer responds to reflow; baked +procedural noise no longer exposes its seed or parameters. + +## Authoring ergonomics + +### Property-local animation state + +The most immediate animation affordance is attached to an ordinary property, +not hidden in a separate timeline. After Effects exposes a stopwatch; Rive +shows a key icon on animatable inspector properties; Blender colors properties +according to whether they are keyed, changed, or driven. These affordances +answer three frequent questions without opening another editor: + +- Can this property animate? +- Does it have a key at the current time? +- Which mechanism currently controls its displayed value? + +### Multiple views over one channel model + +Timing and value shape are edited in separate views because they are different +tasks: + +- a Dope Sheet or layer-bar timeline emphasizes order, spacing, duration, and + synchronization; +- a Graph Editor emphasizes value, velocity, easing, tangent shape, and + extrapolation; +- an in-canvas motion path emphasizes spatial trajectory and spatial tangents; +- an NLA or clip editor emphasizes reuse, local time, repetition, and blend; +- a state graph emphasizes modes, transitions, conditions, and interaction. + +The views are not alternative saved animation systems. They expose different +projections of tracks, clips, paths, or controllers. + +### Selection and disclosure + +Professional timelines can contain many channels. The surveyed tools +use selection, filtering, hierarchy, disclosure triangles, solo/mute, and +“show animated” modes to control density. Blender separates a scene-wide Dope +Sheet from the single-Action editor; Cavalry loads animated Attributes based on +selected layers, Attributes, or keyframes; AE exposes nested property groups. + +This is an ergonomic response to a broad animatable-property model: the +underlying data can be general while the working set remains local. + +### Temporal and spatial manipulation + +After Effects and Lottie store temporal easing separately from spatial +tangents. Cavalry edits motion paths directly in the viewport while its Graph +Editor edits value curves. This avoids forcing “how fast along the path?” and +“what path?” into one control. + +Bulk curve operations are also common: align, scale, reverse, smooth, bake, +reduce, copy easing, change handle type, and loop. Blender includes multiple +smoothing and blending operations; Cavalry exposes transform boxes, ghost +curves, easing copy/paste, and loop modes. These are mostly authoring +operations over persistent curves rather than additional playback semantics. + +### Reuse without exposing internal complexity + +After Effects Essential Properties, Motion rigs, and Rive data binding all +separate an artifact's internal property graph from the controls intended for +another editor or application. A reusable animation is therefore not merely a +copied subtree: it has an intentional parameter surface, instance overrides, +and a relationship to its source. + +### Procedural inspection and baking + +Procedural systems are easier to author when their evaluated result remains +visible in familiar curve and canvas tools. Cavalry previews inherited +procedural curves in the Graph Editor. Blender displays driver-controlled +property state and provides a Drivers Editor. Both provide baking operations +that replace a dependency graph with sampled keys when a simpler representation +is needed. + +### Endpoint and cue authoring + +Presentation tools optimize for authors who know the desired states and +sequence but do not want to manipulate curves. Morph/Magic Move derives motion +from two scene states; On Click/With Previous/After Previous derives start time +from cue order. These are high-level authoring operations with an inspectable +result, not a replacement for lower-level timing and interpolation semantics. + +### Motion accessibility + +Interactive motion has an environmental dimension absent from traditional +linear video. The `prefers-reduced-motion` media feature reports a request to +remove or replace nonessential motion. WCAG guidance says interaction-triggered +nonessential motion should be disableable and automatically moving content may +need pause, stop, or hide controls. See +[Media Queries Level 5](https://www.w3.org/TR/mediaqueries-5/), +[WCAG animation from interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions), +and [WCAG pause, stop, hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide). + +This introduces host policy, alternative motion, and playback-control concerns +beyond the animation curve itself. + +## Tooling versus persisted semantics + +The survey supports a practical classification: + +| capability | classification | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| timeline, Dope Sheet, Graph Editor | editor views over persistent channels | +| auto-key/record | authoring operation that inserts or updates keys | +| snapping, easing presets, key alignment | authoring operations | +| curve ghosting and motion-path overlays | derived editor visualization | +| curve smoothing/reduction | authoring transformation; result persists as changed keys | +| bake | authoring/export transformation between rich evaluation and sampled keys | +| clip/Action/precomposition | persisted and evaluated semantics | +| local time, repeat, stretch, remap | persisted and evaluated semantics | +| effect/track blending | persisted and evaluated semantics | +| driver, constraint, expression, Behavior | persisted semantics when retained; removable only through baking | +| text selector, repeater, falloff | persisted semantics for content that remains dynamic | +| state machine and data binding | persisted controller semantics | +| responsive layout motion | persisted semantics when layout/content can change at runtime | +| motion blur and shutter | persisted render semantics or an explicitly external render profile | +| state-to-state Morph | may be compiled authoring output for fixed states; remains semantic when correspondence is evaluated live | + +The boundary is observable: if reopening, retargeting, changing dynamic +content, arbitrary-time evaluation, or rendering in another implementation +must preserve a capability, that capability cannot exist only in editor UI. + +## Cross-project capability comparison + +This table records the characteristic mechanism exposed by the cited systems. +An em dash means the study did not establish a comparable first-class +mechanism; it does not prove that no extension, plug-in, or host integration +exists. + +| system | property animation | reuse and local time | derived/procedural values | interaction/control | responsive/template mechanism | simplification/delivery boundary | +| -------------------- | ---------------------------- | ---------------------------------------- | ----------------------------------------- | --------------------------------- | ------------------------------------ | -------------------------------- | +| SVG / Web Animations | effects and typed properties | named keyframes; no nested clip model | CSS/script outside the declarative core | event timing and host APIs | processor and media-query policy | document processing mode | +| Lottie | typed animatable properties | precompositions, stretch, and time remap | shape modifiers; expressions extension | separate dotLottie state machine | slots and dotLottie themes | interchange asset | +| After Effects | property tracks | nested compositions and local time | expressions, effects, and shape operators | host/scripting integration | Essential Properties; protected time | render/export | +| Blender | F-Curves | Actions and NLA strips/tracks | drivers, constraints, and modifiers | external/game logic; rig controls | custom properties and rigs | Bake Action | +| Rive | timeline keys | timelines referenced by states | constraints, blends, and data binding | layered state machines | view models and layout animation | runtime/export | +| Cavalry | Attribute keyframes | Animation Control remapping | Behaviours, Utilities, and connections | — | — | Bake Animation | +| Apple Motion | parameter keyframes | reusable Behaviors and templates | Behaviors, rigs, and replicators | published host controls | published widgets and timing markers | convert/render/export | +| PowerPoint / Keynote | ordered effects | slides and transition/effect duration | transition presets and state morph | click and relative cues | object correspondence | video/export | + +The entries are not claims of full equivalence. For example, an NLA blend, a +Web Animations composite operation, and a Rive blend state all combine motion, +but they operate at different scopes and use different value rules. + +## Findings + +1. **The keyframe kernel is convergent.** Ordered time/value samples, + hold/linear/Bézier interpolation, easing, repetition, and typed property + interpolation recur across standards and tools. +2. **Professional motion projects retain more than keyframes.** Reusable clips, + nested local time, value-source graphs, selection functions, shape + operators, controllers, and render-time sampling are persistent semantics. +3. **Reuse introduces an instance model.** Actions, precompositions, timelines, + and templates separate reusable sources from placed instances, local time, + and overrides. +4. **Procedural authoring and portability coexist through baking.** Blender, + Cavalry, and video/render export expose a boundary between rich editable + evaluation and simpler sampled delivery. +5. **Dynamic content makes some baking lossy.** Per-character text selectors, + layout transitions, repeaters, and data-bound states cannot remain adaptive + after they are flattened to fixed element tracks. +6. **Linear animation and interaction are separate layers.** Rive and dotLottie + place state machines around reusable timeline animations rather than + replacing the timeline/keyframe model. +7. **Motion-graphics ergonomics are plural views, not plural truths.** Dope + sheets, graph editors, motion paths, NLA editors, and state graphs expose + different questions over shared persistent structures. +8. **Templates add semantic responsiveness.** Exposed parameters, instance + overrides, protected time regions, and duration markers allow one authored + artifact to survive new content and new durations. +9. **Presentation tools trade curve detail for endpoint and cue authoring.** + State correspondence and relative sequencing make common choreography + accessible without erasing the need for an evaluated intermediate result. +10. **Temporal rendering is distinct from temporal evaluation.** Motion blur + samples an evaluated scene over a shutter interval; it is not another + interpolation curve. +11. **Interactive motion carries accessibility policy.** Reduced-motion, + pause, and stop behavior depend on environment and host control as well as + authored motion. + +## Primary source map + +### Standards and interchange + +- [SVG 2 declarative-animation processing modes](https://www.w3.org/TR/SVG2/conform.html) +- [Web Animations](https://www.w3.org/TR/web-animations-1/) +- [CSS Motion Path](https://www.w3.org/TR/motion-1/) +- [Lottie Animation Community](https://lottie.github.io/) +- [Lottie 1.0 feature changelog](https://lottie.github.io/changelog/) +- [Lottie composition](https://lottie.github.io/lottie-spec/latest/specs/composition/) +- [Lottie properties](https://lottie.github.io/lottie-spec/latest/specs/properties/) +- [Lottie layers](https://lottie.github.io/lottie-spec/latest/specs/layers/) +- [dotLottie 2.0](https://dotlottie.io/spec/2.0/) + +### Authoring systems + +- [Adobe After Effects keyframe interpolation](https://helpx.adobe.com/uk/after-effects/using/keyframe-interpolation.html) +- [Adobe After Effects precompositions](https://helpx.adobe.com/uk/after-effects/using/precomposing-nesting-pre-rendering.html) +- [Adobe After Effects Essential Properties](https://helpx.adobe.com/uk/after-effects/using/essential-properties.html) +- [Adobe After Effects responsive time](https://helpx.adobe.com/after-effects/using/responsive-design.html) +- [Blender animation manual](https://docs.blender.org/manual/en/latest/animation/index.html) +- [Blender Graph Editor](https://docs.blender.org/manual/en/latest/editors/graph_editor/index.html) +- [Blender NLA Editor](https://docs.blender.org/manual/en/latest/editors/nla/index.html) +- [Rive editor and runtime documentation](https://rive.app/docs) +- [Cavalry documentation](https://cavalry.studio/docs/) +- [Apple Motion User Guide](https://help.apple.com/motion/mac/) +- [PowerPoint Morph](https://support.microsoft.com/en-us/powerpoint/training/use-the-morph-transition-in-powerpoint) +- [Keynote transitions](https://support.apple.com/guide/keynote/add-transitions-tanff5ae749e/mac) + +### Accessibility + +- [Media Queries Level 5 — reduced motion](https://www.w3.org/TR/mediaqueries-5/) +- [WCAG 2.2 — animation from interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions) +- [WCAG 2.2 — pause, stop, hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide) diff --git a/model-v2/engine/ANIMATION.md b/model-v2/engine/ANIMATION.md new file mode 100644 index 0000000000..d1276d0af4 --- /dev/null +++ b/model-v2/engine/ANIMATION.md @@ -0,0 +1,664 @@ +# ANIMATION — explicit time through the anchor engine + +**Status:** Open engine RFD. This document proposes an implementation shape +and the contracts that shape would need. No animation syntax, timing model, +property registry, or runtime API is adopted yet, and no implementation exists +in the engine today. + +This is the engine-side companion to the +[Grida XML animation RFD](https://grida.co/docs/wg/format/grida-xml-animation). +That RFD asks what animation means in authored source. This document asks how +an answer can pass through the engine without breaking its existing purity, +determinism, query, damage, and replay contracts. + +The proposal is subordinate to the current [engine contracts](../a/ENGINE.md) +and uses the existing [measurement doctrine](./MEASURE.md). If it conflicts +with either, the conflict must be resolved explicitly rather than hidden in an +animation-specific path. + +It is deliberately implementation-facing. It may name the current engine +stages and candidate Rust types; it does not define `.grida.xml` syntax or make +format decisions on the format RFD's behalf. + +## Short answer + +Animation should enter the engine as **an explicit sample-time input to a pure, +typed sampling stage**: + +```text +authored Document + authored animation model + | + v + compile / validate (not per frame) + | + v +Document + AnimationProgram + SampleTime + declared environment + | + v + sample + | + v + sparse typed SampledValues + | + v + resolve -> drawlist -> raster / composite + | + +----> query / hit test / damage +``` + +The host owns clocks, playback controls, and frame pacing. It maps those facts +to one explicit `SampleTime` for a frame. The engine never reads a wall clock +to determine visual state, never writes sampled values back into the authored +document, and never lets the painter sample animation independently. + +The first implementation should run the full reference pipeline after every +sample. Incremental sampling, scoped resolve, paint-only damage, and +compositor-only execution are later optimizations, each differential-tested +against that permanent reference path. + +## What exists today + +The anchor engine is static: + +- `frame::render` takes a `Document`, resolve options, a view, and paint + context, then runs `resolve -> build -> execute`; +- its uses of `std::time::Instant` measure stage duration only; there is no + semantic time input; +- `journal` and `replay` record document operations. ENG-5's “time as data” is + edit history, not visual animation time; +- the probe's `anim_*` cases mutate the source document between measured + frames. They are useful workloads, but they are not an animation model or + sampler; +- `damage::diff` compares resolved geometry columns. A paint-only animation + can change pixels while current damage remains empty. + +Therefore there was no dedicated “how to build animation” document before +this RFD. The existing sockets are useful, but none should be mistaken for an +implemented animation runtime. + +## Scope + +This RFD covers: + +- the boundary between authored animation and an executable program; +- deterministic sampling at an arbitrary time; +- how sampled values enter resolve, drawlist, query, and damage; +- host/engine responsibility for clocks and scheduling; +- reference and optimized execution paths; +- replay, oracle versioning, tests, and measurement. + +It does not decide: + +- XML element or attribute names; +- whether animation is nested under a target or stored in a timeline graph; +- duration, easing, repeat, fill behavior, synchronization, or event syntax; +- which properties are animatable; +- editor timeline UX; +- SVG import/export policy; +- autoplay or trust policy for a host application. + +Those are source-language or product questions. The engine architecture must +be able to host their eventual answers without silently inventing its own. + +## Vocabulary + +The names below separate facts that must not collapse into one value: + +- **base value** — the authored value before animation contributes; +- **sample time** — the explicit position on a timeline requested by the host; +- **authored animation** — format-neutral animation intent retained for + editing and serialization; +- **animation program** — a validated, typed execution form compiled from + authored animation; +- **track/effect** — one time-varying contribution to one target property; +- **sampled value** — a value produced for one property at one sample time; +- **sample overlay** — the sparse set of sampled values for a frame; +- **sampled scene** — the resolved scene produced from base values plus the + overlay; +- **impact class** — the phases a changed sampled value invalidates, such as + measure, layout, transform, bounds, paint, or resource state. + +Names are provisional. The separations are not: authored state, executable +state, sampled state, and resolved state have different lifetimes and owners. + +## Proposed engine laws + +The labels in this section are proposals, not additions to the accepted +contracts in `ENGINE.md`. + +### A-1 · explicit time `[PROPOSED]` + +Every visual sample is a function of declared inputs, including time. No +sampler, resolver, drawlist builder, painter, resource resolver, or query reads +an ambient clock. Instrumentation may use `Instant`; semantics may not. + +The representation of `SampleTime` is open. It should make boundary decisions +deterministic, which argues against an unqualified `f32` seconds value. Integer +ticks with a declared time scale, integer microseconds, and a rational value +remain candidates. + +### A-2 · authored state is immutable while sampling `[PROPOSED]` + +Sampling never mutates the source `Document` and never serializes a sampled +frame back as authored truth. Editing a base value or keyframe is an authored +operation; advancing playback is not. + +This rules out the probe's current mutation loop as the semantic +implementation. It remains a valid load generator until a real program exists. + +### A-3 · sampling is pure and arbitrarily seekable `[PROPOSED]` + +For the same document, program, environment, oracle versions, and sample time, +sampling produces bit-identical output. Sampling time `t` directly produces +the same result as playing continuously to `t`. + +Stateful playback helpers may cache prior samples, but their output must be +differentially equal to the stateless reference sampler. + +### A-4 · properties and values are typed `[PROPOSED]` + +An animation program targets a registered property key and carries the value +type that property accepts. The runtime does not use reflective string paths +to write arbitrary document fields. + +One registry should own, for each animatable property: + +- the base-value type and accessor; +- validation rules; +- interpolation and discrete-fallback rules; +- allowed composition operations; +- its impact class; +- equality and deterministic encoding behavior. + +The XML vocabulary may map source names to this registry, but must not create a +second set of interpolation semantics. + +### A-5 · one sample feeds every downstream consumer `[PROPOSED]` + +Resolve, drawlist construction, hit testing, bounds, culling, damage, export, +and inspection observe the same sampled values at the same time. Paint may not +sample a second time, and query may not read only the base document while paint +shows an animated result. + +### A-6 · the host owns playback and pacing `[PROPOSED]` + +The host owns wall-clock selection, pause/play, playback rate, seek, loop +policy, presentation scheduling, and the decision to request another frame. +The engine evaluates the time it is given. This extends ENG-2.4 rather than +creating a second frame loop inside animation. + +The engine may return scheduling facts—whether continuous effects are active +or the next exact time a discrete effect can change—but it does not call a +timer or request animation frames itself. + +### A-7 · reference paths remain permanent `[PROPOSED]` + +The full sampler, full resolver, full drawlist build, and full raster path are +the correctness oracle. Incremental and compositor paths must prove: + +```text +optimized(document, program, time, environment) + == reference(document, program, time, environment) +``` + +Equality is evaluated at the strongest applicable tier: sampled values, +resolved columns, drawlist, queries, damage coverage, and pixels. + +### A-8 · animation semantics are versioned oracles `[PROPOSED]` + +Interpolation, easing, effect composition, property registration, and boundary +rounding are content oracles. Replays and conformance artifacts must identify +the versions under which their expected samples were produced, just as the +engine already versions text and future path/image oracles. + +## The data boundary + +Animation needs three representations, not one catch-all tree. + +### 1. Authored animation + +The source/model layer retains editable intent: keyframes, timing, references, +and source ordering. The engine should not receive raw XML nodes and should +never parse `.grida.xml` in a frame. + +This representation does not exist in the model today. Adding it is a model +change and must be designed with the format RFD; the engine cannot honestly +implement animation by hiding an XML-shaped side table in `paint.rs`. + +### 2. Compiled `AnimationProgram` + +A format-neutral compile step resolves and validates what can be decided +without a sample time: + +- target identity and property keys; +- keyframe value types; +- normalized offsets and easing functions; +- effect ordering and composition modes; +- static dependency and synchronization graphs; +- property impact classes; +- resource handles that do not require per-frame discovery. + +Compilation should happen on load and after relevant authored edits, not on +every frame. Invalid targets, cycles, unsupported composition, incompatible +values, and malformed timing should produce structured diagnostics here rather +than disappear during paint. + +Whether compilation belongs to the model crate or an engine module remains +open. Its input and output must be format-neutral either way. + +### 3. Sparse `SampledValues` + +The reference sampler produces a sparse overlay keyed by resolved target +identity and typed property key. An absent entry means “use the base value.” +The overlay is ephemeral frame data, not a cloned or mutated `Document`. + +A sparse overlay makes the null-animation law cheap and explicit: + +```text +sample(empty_program, any_time) == empty_overlay +resolve(document, empty_overlay) == resolve(document_today) +``` + +The exact storage layout is open. A first implementation should favor a plain, +inspectable ordered value over a cache-friendly but opaque structure. SOA, +track-local caches, and change masks come only after measurements. + +## Identity is a blocker, not an implementation detail + +An executable track needs a stable target. The current `NodeId` is an engine +slot identity and must not silently become the serialized animation identity. +Parse-assigned IDs are sufficient for some in-memory tests, but not obviously +for source edits, subtree copies, component instances, cross-target timelines, +or durable replay. + +Before implementation, the format/model work must answer at least: + +- whether nested animation may target only its containing node; +- whether cross-target references exist; +- what survives parse/print, copy/paste, and component instantiation; +- how a compiled target is invalidated when authored identity changes; +- whether a target may be a paint layer, gradient stop, text range, or only a + scene node. + +The engine may compile durable authored identity to a generational runtime key, +but it must preserve the distinction between the two domains. + +## Sampling and resolve order + +The general reference order is: + +1. sample typed effects at the requested time; +2. compose contributions with the authored base value; +3. expose the resulting overlay to resolve; +4. resolve layout, transforms, bounds, clips, and other derived state; +5. build one drawlist from that sampled resolved state; +6. execute the drawlist; +7. serve queries from that same sampled resolved state. + +Sampling must precede resolve for layout-affecting properties. A paint-only or +compositor-eligible property may later skip work, but that is an optimization +selected from its impact class, not a different semantic path. + +This exposes the first concrete incompatibility with the current model API: +`anchor_lab::resolve(&Document, &ResolveOptions)` has no sampled-value input. +Layout-aware animation requires either a format-neutral overlay input or an +equivalent typed value-provider seam in the model resolver. That model change +must be agreed explicitly; the engine should not mutate a temporary document +to avoid it. + +## A candidate frame seam + +The following is an architectural probe, not an accepted Rust API: + +```rust +pub struct FrameInput<'a> { + pub document: &'a Document, + pub animation: Option<&'a AnimationProgram>, + pub sample_time: SampleTime, + pub resolve: &'a ResolveOptions, + pub view: &'a Affine, + pub resources: &'a ResourceSnapshot, +} + +pub struct FrameOutput { + pub sampled: SampleSummary, + pub resolved: Resolved, + pub drawlist: DrawList, + pub damage: Damage, + pub schedule: AnimationSchedule, + pub stats: FrameStats, +} +``` + +`frame::render` remains the sole orchestration seam. The important change is +not these field names; it is that one explicit sample and one declared +environment flow through the whole frame. + +The static call path should remain an exact special case. A null program must +not subtly alter geometry, ordering, queries, pixels, or performance-sensitive +allocation behavior. + +## Property impact and invalidation + +Every registered property needs a conservative, testable impact class. A +candidate vocabulary extends the current M/L/T/B dirty vocabulary: + +| impact | examples, subject to the property RFD | required reference work | +| --------- | ------------------------------------- | ------------------------------------------------ | +| measure | font size, text content | measure and dependent layout | +| layout | width, gap, alignment | affected layout scope and downstream transforms | +| transform | translation, rotation, scale | world transform and descendant bounds | +| bounds | stroke width, blur radius | visual bounds, culling, and damage | +| paint | color, opacity, gradient stop | drawlist/pixel change even if geometry is stable | +| resource | image or font reference | explicit resource state and dependent phases | + +The examples are illustrative; the format RFD decides what is animatable. +Impact may be a bitset because one property can affect several phases. + +The sampler should report the exact set of changed `(target, property)` pairs +between two requested samples. Later incremental stages may consume that set. +Day one may ignore it and run the full pipeline, but the data should be visible +to probes and tests. + +## Composition belongs in the sampler + +Several effects may contribute to one property. Base-value selection, +replacement, addition, accumulation, source/effect order, held values, and +discrete fallback therefore belong to one typed composition step before +resolve. + +Even if the first language permits only one replace effect per property, the +program should represent “replace” explicitly. It should not make source order +an accidental composition algorithm that later becomes impossible to change. +Unsupported composition modes should fail compilation with a useful diagnostic. + +## Transform and motion + +Transform animation must resolve at a declared position relative to layout, +the authored local transform, ancestor transforms, and any future motion path. +The sampler cannot simply hand a final matrix to paint if hit testing and child +geometry are expected to move with it. + +Matrix-entry interpolation, transform-list interpolation, decomposed +interpolation, and motion-path orientation are still format/model decisions. +Whichever is selected becomes a versioned typed interpolator and must feed the +same resolved transform used by query and drawlist construction. + +## Paint and resource animation + +Paint animation must project the engine's typed paint model rather than build a +parallel “animation color” abstraction. A track should target a paint property +through the same property registry used for base values—for example, a solid +paint's color or opacity—if and only if that property is admitted as animatable. + +Resource identity and resource readiness are explicit environment inputs. +Sampling must not initiate path I/O, network I/O, image decoding, or font +loading. An animation that changes a resource reference either selects among +already resolved handles under defined semantics or is rejected/deferred until +the resource model specifies it. + +Animated image media is a separate problem: choosing a frame inside a GIF or +video resource is not automatically the same mechanism as animating a scene +property. The two may share `SampleTime`, but should not be conflated by this +RFD. + +## Damage, caching, and compositor lowering + +The current geometry-only `damage::diff` is insufficient for animation. A +complete reference diff needs to cover: + +- changed sampled properties, including paint-only values; +- before and after visual bounds; +- drawlist item changes and ordering; +- resource readiness changes; +- disappearance and appearance at timing boundaries. + +The first correct implementation can conservatively repaint the viewport. +Once paint-aware damage exists, damage may narrow while remaining a forward +product of sample and resolve diffs. No painter invents damage after the fact. + +Cache keys must depend on the document/program generations, oracle versions, +resource snapshot, and relevant sampled values—not wall-clock time. Two +different times that produce the same sampled value should be able to reuse +the same derived result. + +Transform and opacity tracks may eventually be lowered to a compositor. That +path is legal only when it is observationally equivalent to the reference +sampled scene for pixels, bounds, query, and export. If the main thread and the +compositor can disagree about the sampled state, lowering has crossed the +engine's semantic boundary. + +## Scheduling and frame rate + +Source timing describes values as a function of time; it does not prescribe a +fixed frame rate. Interactive hosts sample near presentation opportunities. +Deterministic exporters choose an explicit sequence of times. Both must get the +same value at the same time. + +The compiled program may expose: + +- whether any effect changes continuously in an interval; +- the next boundary at which a discrete or inactive effect can change; +- whether the program is finished, held, indefinite, or awaiting an external + timeline input. + +Those facts help the host avoid needless frames. They are outputs, not hidden +timers. Event-, scroll-, audio-, or data-driven timelines require additional +declared timeline inputs; none are assumed by the wall-clock design above. + +## Queries and inspection + +All spatial queries take a sampled `Resolved` tier or a frame snapshot that +owns one. At time `t`, hit testing, marquee, snapping, culling, minimap, and +selection geometry must agree with what is painted at `t`. + +Inspection additionally needs both sides of the split: + +- authored/base values for editing and serialization; +- sampled values for explaining the visible result. + +The read API should make the choice explicit. A generic `get_value` that +sometimes returns base and sometimes sampled state would make editor behavior +dependent on playback state in invisible ways. + +## Journal and replay + +Document journals and animation playback record different facts: + +- a journal records edits to authored state, such as inserting a keyframe or + changing a duration; +- a playback trace records sample times or external timeline inputs; +- ordinary playback must not emit one document operation per displayed frame. + +For a deterministic animation repro, replay needs the initial document, +authored operations, oracle tags, resources/environment, and either: + +1. the exact sequence of requested sample times; or +2. playback-control events plus a fully specified clock-to-timeline mapping. + +The first is the simpler engine conformance artifact. The second is useful for +host pacing investigations but includes host behavior outside the pure sampler. + +The replay format will need animation-program and interpolation oracle tags +before animated fixtures can claim bit-identical results. + +## Reference implementation first + +The build order should preserve the engine's oracle law. + +### Phase 0 — install a null seam + +- define an explicit `SampleTime` input without choosing source syntax; +- pass an empty overlay through the frame pipeline; +- prove static fixtures, drawlists, queries, replays, and pixels are unchanged; +- keep the current static API as a thin compatibility call if useful. + +This phase is only legitimate after the sample-time representation is decided. +It should not invent XML animation. + +### Phase 1 — typed replace-only kernel + +- add a format-neutral authored animation model and stable target contract; +- compile to typed tracks outside the frame loop; +- implement a stateless reference sampler; +- start with a deliberately small registered property set whose base access, + interpolation, and impact are fully defined; +- run full resolve, full drawlist build, and full paint for every sample. + +Numbers, colors, opacity, and transforms are plausible candidates, not an +adopted list. Each must survive the registry and resolver design before entry. + +### Phase 2 — layout and paint completeness + +- feed sampled values into measure/layout through the agreed model seam; +- add paint-aware and bounds-aware damage; +- make all query APIs consume the sampled resolved tier; +- define missing-resource and resource-change behavior; +- add deterministic export sampling. + +### Phase 3 — richer semantics + +- add multiple-effect composition only after ordering is specified; +- add iterations, holds, synchronization, and non-document timelines only as + their source semantics become normative; +- add motion paths and richer typed interpolation behind versioned oracles. + +### Phase 4 — measured optimization + +- incremental track sampling and change masks; +- impact-scoped resolve and drawlist updates; +- paint-aware partial damage; +- compositor lowering for proven-eligible effects; +- scheduling hints and cache policies. + +Every item in this phase needs a differential test against phases 1–3 and a +measurement that shows why the complexity pays for itself. + +## Candidate module ownership + +No file layout is adopted, but the current engine suggests these strict seams: + +| concern | likely owner | +| ----------------------------- | ------------------------------------ | +| typed program and sampler | one initial `animation` module | +| frame orchestration | `frame` | +| sampled-value resolve seam | model resolver plus engine call site | +| sampled/resolved change data | `damage` | +| drawlist projection | `drawlist` | +| mechanical raster execution | `paint` | +| sampled spatial reads | `query` | +| conformance playback | `replay` | +| interpolation/version stamps | `oracle` | +| CPU work measurement | `probe` | +| real-window scheduling/pacing | host/compositor and frame log | + +`paint` is intentionally not the owner of animation semantics. The module +boundary should remain small at first: one coherent sampler/registry unit is +easier to specify and test than a directory of abstractions chosen before the +kernel exists. + +## Tests required before claiming support + +The reference path needs focused laws, not only videos or golden frames: + +1. **Null equivalence:** an empty program at any time equals today's static + pipeline at every observable tier. +2. **Source immutability:** sampling any sequence of times leaves authored + document bytes and semantic equality unchanged. +3. **Seek equivalence:** direct sample at `t` equals continuous playback to + `t`. +4. **Frame-rate independence:** different prior sample sequences produce the + same output at the same `t`. +5. **Boundary exactness:** begin, keyframe, repeat, and end boundaries select + the specified side exactly. +6. **Typed rejection:** invalid target/property/value combinations fail at + compile time with structured diagnostics. +7. **Composition order:** each admitted mode has table-driven base/effect + stack cases. +8. **Phase coherence:** query and paint observe the same sampled transform, + bounds, visibility, and order. +9. **Damage coverage:** every pixel-changing sample transition is covered, + including paint-only changes. +10. **Replay determinism:** the same document, program, environment, oracle + versions, and sample-time trace are bit-identical across runs. +11. **Optimization equivalence:** every incremental or compositor result + equals the full reference result. + +Visual reftests remain valuable for interpolation and composition, but must be +paired with sampled-value and resolved-tier assertions so a failure identifies +the semantic stage that diverged. + +## Measurement changes + +Once a real program exists, `probe` should stop calling direct document +mutation “animation” and measure the actual sampling seam. Candidate axes are: + +- number of authored tracks and number active at the sample time; +- percentage of nodes animated; +- impact class: paint, transform, bounds, layout, and measure; +- keyframe count and easing cost; +- same-time resampling, monotonic playback, and random seeking; +- full reference versus each optimization. + +`FrameStats` should expose sampling time separately. Program compilation should +have its own load/edit measurement and must not be folded into per-frame cost. +The existing frame log remains the tool for real-window pacing; a fast sampler +does not prove smooth presentation. + +## Open decisions before code + +Implementation should not begin until the owning RFD or model contract answers +the decisions needed for the selected first slice: + +1. What exact type and unit represents `SampleTime`? +2. What durable authored identity can an animation target? +3. What is the format-neutral authored animation model? +4. Which properties form the first typed registry, and what are their impact + classes? +5. What interpolation, boundary, easing, and discrete rules apply to them? +6. Is day-one composition replace-only, and how is conflicting source rejected + or ordered? +7. What overlay/value-provider seam may the model resolver consume? +8. How are resource availability and resource-valued properties represented? +9. What static sample does a non-playing processor request? +10. Which oracle versions are stamped into animation conformance and replay + artifacts? + +## Rejected shortcuts + +- **Mutate `Document` once per frame.** This destroys the base/sample split, + bloats journals, complicates editing, and makes direct seeking stateful. +- **Sample inside `paint`.** Layout, bounds, query, damage, and export would + disagree with pixels. +- **Use string property paths throughout the runtime.** Validation becomes + late and every subsystem can invent different type/interpolation behavior. +- **Read `Instant::now()` in engine semantics.** Tests, seek, export, and replay + become nondeterministic. `Instant` remains valid for measurement only. +- **Treat the current `anim_*` probe as the implementation.** It measures + repeated mutation cost and proves neither timing nor sampling semantics. +- **Start with compositor-only animation.** It optimizes one property class + before a sampled-scene oracle exists and risks visible/query disagreement. +- **Serialize sampled values as source.** It discards authored intent and makes + round trips depend on the time at which they occurred. +- **Call edit replay an animation timeline.** Operation history and visual + sampling are complementary inputs with different semantics. + +## Relationship to the source-language RFD + +The format RFD remains the authority for authored semantics. Its decisions +should eventually fill the open slots here: + +| source-language decision | engine consequence | +| ---------------------------- | ------------------------------------------------------- | +| target identity and nesting | program target keys and compile invalidation | +| time representation | `SampleTime` and boundary arithmetic | +| keyframes and easing | normalized typed tracks and interpolator oracles | +| property registry | typed base access, interpolation, impact classification | +| effect ordering/composition | contribution stack | +| timeline and synchronization | program dependency graph and scheduling facts | +| processing/static modes | explicit sample policy and refusal behavior | +| resources and trust | declared environment and validation | + +Conversely, the engine imposes two constraints the source design should treat +as day-one requirements: arbitrary-time deterministic sampling, and a clean +separation between authored base values and ephemeral sampled values. From 24b61028715bcaffc7b70f7cf642cbc2ee453668 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 12 Jul 2026 00:24:55 +0900 Subject: [PATCH 13/33] feat(model-v2): materialize Grida XML geometry and rich text --- docs/wg/format/grida-xml-properties.md | 282 ++-- docs/wg/format/grida-xml.md | 583 ++++++-- model-v2/a/lab/README.md | 11 + model-v2/a/lab/src/grida_xml.rs | 58 +- model-v2/a/lab/src/measure.rs | 329 +++++ model-v2/a/lab/src/model.rs | 421 +++++- model-v2/a/lab/src/resolve.rs | 129 +- model-v2/a/lab/src/svgout.rs | 85 +- model-v2/a/lab/src/textir.rs | 1205 +++++++++++++---- model-v2/a/lab/tests/draft0_resolution.rs | 64 +- .../a/lab/tests/grida_xml_attributed_text.rs | 326 +++++ model-v2/a/lab/tests/grida_xml_corners.rs | 219 +++ model-v2/a/lab/tests/grida_xml_properties.rs | 7 +- .../a/lab/tests/grida_xml_stroke_widths.rs | 215 +++ model-v2/a/lab/tests/grida_xml_strokes.rs | 10 +- model-v2/a/lab/tests/grida_xml_suite.rs | 10 +- model-v2/a/lab/tests/mm_laws.rs | 2 + .../rig/examples/dynamic-slide.grida.xml | 24 +- .../engine/rig/examples/rich-fills.grida.xml | 4 +- .../rig/examples/rich-strokes.grida.xml | 2 +- .../examples/source-becomes-surface.grida.xml | 34 +- model-v2/engine/src/bin/grida_xml_render.rs | 73 +- model-v2/engine/src/drawlist.rs | 137 +- model-v2/engine/src/paint.rs | 745 ++++++++-- model-v2/engine/tests/corners.rs | 278 ++++ model-v2/engine/tests/drawlist.rs | 34 +- model-v2/engine/tests/grida_xml.rs | 49 +- model-v2/engine/tests/paints.rs | 79 +- model-v2/engine/tests/rectangular_strokes.rs | 176 +++ model-v2/engine/tests/strokes.rs | 127 +- model-v2/engine/tests/support/mod.rs | 91 ++ model-v2/engine/tests/text.rs | 240 +++- 32 files changed, 5103 insertions(+), 946 deletions(-) create mode 100644 model-v2/a/lab/tests/grida_xml_attributed_text.rs create mode 100644 model-v2/a/lab/tests/grida_xml_corners.rs create mode 100644 model-v2/a/lab/tests/grida_xml_stroke_widths.rs create mode 100644 model-v2/engine/tests/corners.rs create mode 100644 model-v2/engine/tests/rectangular_strokes.rs create mode 100644 model-v2/engine/tests/support/mod.rs diff --git a/docs/wg/format/grida-xml-properties.md b/docs/wg/format/grida-xml-properties.md index 6b6aa7e40e..34a56b8b9a 100644 --- a/docs/wg/format/grida-xml-properties.md +++ b/docs/wg/format/grida-xml-properties.md @@ -87,7 +87,8 @@ diagnose them as unknown syntax. The table covers the production node set as well as XML-only source constructs. “Children” means render-node children, not typed property children such as -`fill`, `stroke`, or gradient `stop` elements. +`fill`, `stroke`, or gradient `stop` elements. `tspan` is listed explicitly as +a contextual source construct even though it is not a scene node. | XML element | Production concept | Children | Status | Source and use | | ----------------- | ------------------------------------- | --------- | --------------- | ------------------------------------------------------------------------- | @@ -97,7 +98,8 @@ The table covers the production node set as well as XML-only source constructs. | `rect` | Rectangle | Yes | **Draft 0** | SVG; direct primitive | | `ellipse` | Ellipse, ring, or arc | Yes | **Draft 0** | SVG base primitive; arc parameters are placeholders | | `line` | Line | Yes | **Draft 0** | SVG; stroke-only primitive with repeatable stroke geometries | -| `text` | Text span; later, attributed text | No | **Draft 0** | SVG/HTML; nested `span` syntax is not defined | +| `text` | Text or attributed-text node | No | **Draft 0** | Owns one text box, paragraph/default style, and node paints | +| `tspan` | `StyledTextRun` | No | **Draft 0** | Flat direct child of `text`; contextual run, never a scene node | | `lens` | Source transform wrapper | Yes | **Draft 0** | Grida source construct; resolves through group/transform semantics | | `image` | Image node outside paint channels | No | **Placeholder** | HTML/SVG; contextual with the existing image-paint element | | `path` | Raw, render-oriented SVG path | Yes | **Placeholder** | SVG; use `d`, not an implementation-shaped `data` attribute | @@ -113,10 +115,11 @@ The table covers the production node set as well as XML-only source constructs. | _none_ | Initial viewport container | Root | **Derived** | The `grida` envelope and render root materialize this host boundary | | _none_ | Error/import placeholder | No | **Derived** | Diagnostic result, never canonical authored content | -Attributed text should preferably remain `` with familiar HTML/SVG-like -`` children rather than introduce a second top-level text kind. That is -a direction, not grammar: whitespace, run boundaries, inheritance, and -per-run paint must be specified first. +Attributed text remains one `` node with direct, flat `` children. +`tspan` contains exact character data plus an optional literal-first `fill` +property; it has no scene identity, box, transform, opacity, layout, or render +children. `` is not an alias and a reader must never support both +spellings. ## Draft 0 attribute registry @@ -134,13 +137,17 @@ defaults, contradictions, and resolution behavior. | `min-width`, `max-width` | `container`, `rect`, `ellipse`, `line`, `text` | CSS | Width constraints | | `min-height`, `max-height` | `container`, `rect`, `ellipse`, `text`; never `line` | CSS | Height constraints | | `aspect-ratio` | `rect`, `ellipse` | CSS | Supplies one otherwise unresolved box axis | +| `corner-radius` | `container`, `rect` | CSS/Flutter | One or four circular or elliptical corner radii | +| `corner-smoothing` | `container`, `rect` | Design tools | Continuous smoothing of circular rounded corners | | `rotation` | All Draft 0 render elements | Flutter/design tools | Clockwise visual rotation in degrees | | `flip-x`, `flip-y` | All Draft 0 render elements | Design tools | Native visual reflection | | `opacity` | All Draft 0 render elements | CSS/SVG | Composites the node and descendants | | `hidden` | All Draft 0 render elements | HTML | Removes the subtree from layout and painting | -| `fill` | `container`, `rect`, `ellipse`, `text` | SVG | Canonical compact form for one ordinary solid fill | +| `fill` | `container`, `rect`, `ellipse`, `text`; contextual `tspan` | SVG | One-solid node fill or explicit run-fill override | | `clips` | `container` | Flutter/design tools | Clips descendant content to the container shape | -| `size` | `text` | Flutter | Draft 0 font size; see the `font-size` naming issue below | +| `font-size` | `text`, `tspan` | CSS/SVG | Positive finite logical-pixel default or run override | +| `font-weight` | `text`, `tspan` | CSS | Integer weight from 1 through 1000 | +| `font-style` | `text`, `tspan` | CSS | `normal` or `italic` | | `ops` | `lens` | CSS/SVG | Ordered 2D transform functions | | `layout` | `container` | CSS | Selects `none` or `flex` | | `direction` | `container` with `layout="flex"` | CSS | Main-axis direction | @@ -153,6 +160,25 @@ defaults, contradictions, and resolution behavior. | `grow` | In-flow child of a flex container | CSS/Flutter | Main-axis growth factor | | `align` | In-flow child of a flex container | CSS/Flutter | Per-child cross-axis override | +`font-size`, `font-weight`, and `font-style` default on `text` to `16`, `400`, +and `normal`. The same attributes on `tspan` override the owning `text` +default for that run; omission inherits from `text`, never a preceding run. +`size` and a generic `style` attribute are not Grida XML spellings. + +The [rounded box geometry contract](./grida-xml#rounded-box-geometry) defines +the exact grammar. In registry form, `corner-radius` is one or exactly four +non-negative finite horizontal radii, optionally followed by `/` and an +independent one-or-four-value vertical list. Four-value order is top-left, +top-right, bottom-right, bottom-left. `corner-smoothing` is finite in `[0, 1]`. +Both attributes default to zero and apply only to `container` and `rect`. + +The canonical writer shortens an all-equal axis list to one value, otherwise +emits all four; it omits the slash when every horizontal and vertical radius +pair is equal. It omits zero defaults, but preserves nonzero smoothing with +zero radii as dormant intent. Oversized ordinary radii use one edge-sum-based +proportional factor; nonzero smoothing uses the production profile's +per-corner half-short-side cap. Neither resolved form is rewritten in source. + ## Scene-backed property placeholders These tables reserve discussion slots for properties in the canonical scene @@ -160,16 +186,14 @@ model. They do not claim the proposed XML spelling is final. ### Identity, compositing, transform, and box geometry -| Candidate XML property | Valid on | Production concept | Inspiration | Status | -| ---------------------- | ---------------------------------------------------------- | ---------------------------------------- | ------------ | --------------- | -| `id` | Every authored render node | Durable user node identity | HTML/SVG | **Design** | -| `locked` | Every authored render node, if persisted | Editor lock metadata | Design tools | **Design** | -| `blend-mode` | Every compositing render node | Layer blend mode, including pass-through | CSS | **Placeholder** | -| `mask-type` | A node that acts as a mask source | Geometry, alpha, or luminance mask | CSS/SVG | **Design** | -| `transform` | Render nodes | Post-layout 2D affine transform | CSS/SVG | **Design** | -| `transform-origin` | Boxed render nodes | Post-layout transform pivot | CSS | **Design** | -| `corner-radius` | `container`, `rect`, `image`, polygonal and boolean shapes | Uniform or per-corner radius | CSS/Flutter | **Placeholder** | -| `corner-smoothing` | `container`, `rect`, `image` | Continuous/smoothed corners | Design tools | **Placeholder** | +| Candidate XML property | Valid on | Production concept | Inspiration | Status | +| ---------------------- | ---------------------------------------- | ---------------------------------------- | ------------ | --------------- | +| `id` | Every authored render node | Durable user node identity | HTML/SVG | **Design** | +| `locked` | Every authored render node, if persisted | Editor lock metadata | Design tools | **Design** | +| `blend-mode` | Every compositing render node | Layer blend mode, including pass-through | CSS | **Placeholder** | +| `mask-type` | A node that acts as a mask source | Geometry, alpha, or luminance mask | CSS/SVG | **Design** | +| `transform` | Render nodes | Post-layout 2D affine transform | CSS/SVG | **Design** | +| `transform-origin` | Boxed render nodes | Post-layout transform pivot | CSS | **Design** | `id` must map to durable authored identity, not the runtime's transient node handle. `transform` also cannot be admitted until its relationship to native @@ -221,20 +245,20 @@ resource behavior must be shared by scene images and image paints. The same Some production properties are ordered or structured values and should remain XML children rather than become mini-languages inside attributes. -| Property element | Valid parent | Status | Contract direction | -| -------------------- | ---------------------------------------------- | ----------- | --------------------------------------------------------------------------------- | -| `fill` | `container`, `rect`, `ellipse`, `text` | **Draft 0** | One expanded fill channel; ordered `Paints`, first bottommost | -| `stroke` | `container`, `rect`, `ellipse`, `line`, `text` | **Draft 0** | Repeatable geometry owning ordered `Paints`; empty only with non-default geometry | -| `solid` | `fill` or `stroke` | **Draft 0** | Typed solid paint | -| `gradient` | `fill` or `stroke` | **Draft 0** | Typed gradient family with required `kind` and `stop` children | -| `image` | `fill` or `stroke` | **Draft 0** | Contextual image paint | -| `stop` | `gradient` | **Draft 0** | Ordered offset/color pair | -| `effects` | Effect-capable node | **Design** | Typed effect values; ordering and multiplicity are unresolved | -| `span` | `text` | **Design** | Attributed run with inherited or overridden text properties | -| `network` | `vector` | **Design** | Editable vertices, Bézier segments, loops, regions, and region fills | -| `resources` | Document-level declaration scope | **Design** | Portable logical IDs and packaged/external resource policy | -| `component`, `use` | Document definitions and scene | **Design** | Reusable authored widgets and instances | -| Animation vocabulary | Document, node, or property scope | **Design** | Requires a dedicated timing and addressing RFD | +| Property element | Valid parent | Status | Contract direction | +| -------------------- | ----------------------------------------------- | ----------- | --------------------------------------------------------------------------------- | +| `fill` | `container`, `rect`, `ellipse`, `text`, `tspan` | **Draft 0** | Expanded ordered `Paints`; singular and literal-first when under `tspan` | +| `stroke` | `container`, `rect`, `ellipse`, `line`, `text` | **Draft 0** | Repeatable geometry owning ordered `Paints`; empty only with non-default geometry | +| `solid` | `fill` or `stroke` | **Draft 0** | Typed solid paint | +| `gradient` | `fill` or `stroke` | **Draft 0** | Typed gradient family with required `kind` and `stop` children | +| `image` | `fill` or `stroke` | **Draft 0** | Contextual image paint | +| `stop` | `gradient` | **Draft 0** | Ordered offset/color pair | +| `effects` | Effect-capable node | **Design** | Typed effect values; ordering and multiplicity are unresolved | +| `tspan` | `text` | **Draft 0** | Flat contextual run with exact non-empty character data and explicit overrides | +| `network` | `vector` | **Design** | Editable vertices, Bézier segments, loops, regions, and region fills | +| `resources` | Document-level declaration scope | **Design** | Portable logical IDs and packaged/external resource policy | +| `component`, `use` | Document definitions and scene | **Design** | Reusable authored widgets and instances | +| Animation vocabulary | Document, node, or property scope | **Design** | Requires a dedicated timing and addressing RFD | ### Paint attribute registry @@ -266,14 +290,14 @@ Each `stroke` is one geometry with direct typed paint children. An empty paint list is valid only when at least one geometry value differs from the target's defaults. These scoped attributes are normative in Draft 0: -| Attribute | Value | Valid targets | Purpose | -| ------------- | ------------------------------------------ | -------------------------------------- | -------------------------------------------- | -| `width` | non-negative finite number | every `stroke` | Uniform logical-pixel width | -| `align` | `inside`, `center`, or `outside` | every `stroke`; line must be `center` | Placement relative to the original outline | -| `cap` | `butt`, `round`, or `square` | `line` | Open-contour endpoint shape | -| `join` | `miter`, `round`, or `bevel` | `container`, `rect` | Joined-contour corner shape | -| `miter-limit` | positive finite number | `container`, `rect` | Miter-to-bevel cutoff ratio | -| `dash-array` | space-separated non-negative finite values | `container`, `rect`, `ellipse`, `line` | Repeating dash-gap lengths in logical pixels | +| Attribute | Value | Valid targets | Purpose | +| ------------- | ------------------------------------------ | ------------------------------------------------ | -------------------------------------------- | +| `width` | one or exactly four non-negative numbers | every `stroke`; four only on `container`, `rect` | Uniform or top-right-bottom-left width | +| `align` | `inside`, `center`, or `outside` | every `stroke`; line must be `center` | Placement relative to the original outline | +| `cap` | `butt`, `round`, or `square` | `line` | Open-contour endpoint shape | +| `join` | `miter`, `round`, or `bevel` | `container`, `rect` | Joined-contour corner shape | +| `miter-limit` | positive finite number | `container`, `rect` | Miter-to-bevel cutoff ratio | +| `dash-array` | space-separated non-negative finite values | `container`, `rect`, `ellipse`, `line` | Repeating dash-gap lengths in logical pixels | The production model already separates one stroke `Paints` stack from one stroke geometry. One XML `stroke`, using only attributes supported by its @@ -283,54 +307,105 @@ elements are an accepted extension to an ordered list of those pairs; the current production model still needs that multiplicity before it can materialize more than one without loss. +### Per-side width grammar + +One `width` number selects uniform stroke width. Exactly four numbers select +rectangular widths in top, right, bottom, left order and are valid only for a +`container` or `rect`. Two- and three-number expansion, commas, units, negative +values, and non-finite values are invalid. The omitted default is uniform `1`. + +Four equal positive values normalize to the uniform state; four zeros and a +scalar zero normalize to the no-width state. A canonical writer omits uniform +`1`, uses one number for every other uniform or no-width state, and writes all +four values only when they differ. Applicability is validated before this +normalization, so four values remain invalid on an ellipse, line, or text even +when equal. + +The four local-side widths produce one outer-minus-inner coverage ring with one +shared ordered paint stack. `inside`, `center`, and `outside` use outward and +inward fractions `(0, 1)`, `(1/2, 1/2)`, and `(1, 0)`, respectively. Each +corner's outer radius adds the outward fraction of its adjacent horizontal and +vertical widths; its inner radius subtracts the inward fractions and clamps +each result to zero. A zero or negative inner-box extent makes the inner +contour empty, saturating coverage to the outer contour without rewriting +source widths. + +Ordinary circular and elliptical corner radii and continuous `dash-array` +traversal remain valid. A zero-width side has no coverage but still advances +the dash metric. Per-side widths currently require zero corner smoothing, +`join="miter"`, and `miter-limit="4"`; other combinations are rejected rather +than accepted as state the rectangular renderer cannot honor. + ### Deferred stroke geometry -| Candidate XML property | Valid on | Production concept | Status | -| ---------------------- | ------------------------------------------- | -------------------------------------------- | --------------- | -| Per-side width grammar | `container`, `rect`, and future image boxes | Rectangular stroke widths | **Design** | -| Width-profile grammar | Future `vector` | Ordered normalized-position/half-width stops | **Design** | -| `dash-offset` | `stroke` | Phase offset into the dash cycle | **Design** | -| `marker-start` | Open path-like stroke geometry | Endpoint marker preset or reference | **Placeholder** | -| `marker-end` | Open path-like stroke geometry | Endpoint marker preset or reference | **Placeholder** | - -Per-side widths and variable-width profiles need nested or shorthand grammar -of their own; neither should be forced into an opaque comma-separated -attribute merely to make it look CSS-like. - -## Text property placeholders - -Uniform text maps naturally to CSS names. Rich text should use the same names -on future `span` children, with inheritance and override rules defined once. - -| Candidate XML property | Valid on | Production concept | Inspiration | Status | -| --------------------------- | -------------- | ----------------------------------------- | ----------- | --------------- | -| `font-family` | `text`, `span` | Font family | CSS | **Placeholder** | -| `font-size` | `text`, `span` | Font size | CSS/SVG | **Design** | -| `font-weight` | `text`, `span` | Font weight | CSS | **Placeholder** | -| `font-stretch` | `text`, `span` | Font width | CSS | **Placeholder** | -| `font-style` | `text`, `span` | Normal or italic style | CSS | **Placeholder** | -| `font-kerning` | `text`, `span` | Kerning switch | CSS | **Placeholder** | -| `font-optical-sizing` | `text`, `span` | Optical sizing mode | CSS | **Placeholder** | -| `font-feature-settings` | `text`, `span` | OpenType feature-tag values | CSS | **Design** | -| `font-variation-settings` | `text`, `span` | Variable-font axis values | CSS | **Design** | -| `letter-spacing` | `text`, `span` | Letter spacing | CSS | **Placeholder** | -| `word-spacing` | `text`, `span` | Word spacing | CSS | **Placeholder** | -| `line-height` | `text`, `span` | Fixed or proportional line height | CSS | **Placeholder** | -| `text-transform` | `text`, `span` | Case transformation | CSS | **Placeholder** | -| `text-align` | `text` | Horizontal paragraph alignment | CSS | **Placeholder** | -| `text-align-vertical` | Boxed `text` | Top, center, or bottom placement | Flutter | **Placeholder** | -| `max-lines` | `text` | Paragraph line clamp | Flutter | **Placeholder** | -| `text-overflow` | `text` | Clip, ellipsis, or authored marker string | CSS/Flutter | **Design** | -| `text-decoration-line` | `text`, `span` | Underline, overline, or line-through | CSS | **Placeholder** | -| `text-decoration-style` | `text`, `span` | Solid, double, dotted, dashed, or wavy | CSS | **Placeholder** | -| `text-decoration-color` | `text`, `span` | Decoration color override | CSS | **Placeholder** | -| `text-decoration-thickness` | `text`, `span` | Decoration thickness | CSS | **Placeholder** | -| `text-decoration-skip-ink` | `text`, `span` | Glyph-ink skipping | CSS | **Placeholder** | - -Draft 0 currently calls font size `size`. Before typography is expanded, the -RFD must either rename it to the more established `font-size` in a versioned -change or explicitly defend `size` as the sole canonical spelling. Accepting -both indefinitely would violate the one-spelling rule. +| Candidate XML property | Valid on | Production concept | Status | +| ---------------------- | ------------------------------ | -------------------------------------------- | --------------- | +| Width-profile grammar | Future `vector` | Ordered normalized-position/half-width stops | **Design** | +| `dash-offset` | `stroke` | Phase offset into the dash cycle | **Design** | +| `marker-start` | Open path-like stroke geometry | Endpoint marker preset or reference | **Placeholder** | +| `marker-end` | Open path-like stroke geometry | Endpoint marker preset or reference | **Placeholder** | + +Variable-width profiles need structured grammar of their own and should not be +forced into an opaque comma-separated attribute merely to look CSS-like. + +## Text attributes and property placeholders + +`text` owns the paragraph, default text style, and node paints. A direct, +flat `tspan` is only a contextual run and inherits every omitted run property +from the owning `text`, not from its preceding sibling. Draft 0 uses explicit +kebab-case attributes and defines no `style` mini-language. + +| Candidate XML property | Valid on | Production concept | Inspiration | Status | +| --------------------------- | --------------- | ----------------------------------------- | ----------- | --------------- | +| `font-family` | `text`, `tspan` | Font family | CSS | **Placeholder** | +| `font-size` | `text`, `tspan` | Positive finite font size | CSS/SVG | **Draft 0** | +| `font-weight` | `text`, `tspan` | Integer weight from 1 through 1000 | CSS | **Draft 0** | +| `font-stretch` | `text`, `tspan` | Font width | CSS | **Placeholder** | +| `font-style` | `text`, `tspan` | `normal` or `italic` | CSS | **Draft 0** | +| `font-kerning` | `text`, `tspan` | Kerning switch | CSS | **Placeholder** | +| `font-optical-sizing` | `text`, `tspan` | Optical sizing mode | CSS | **Placeholder** | +| `font-feature-settings` | `text`, `tspan` | OpenType feature-tag values | CSS | **Design** | +| `font-variation-settings` | `text`, `tspan` | Variable-font axis values | CSS | **Design** | +| `letter-spacing` | `text`, `tspan` | Letter spacing | CSS | **Placeholder** | +| `word-spacing` | `text`, `tspan` | Word spacing | CSS | **Placeholder** | +| `line-height` | `text`, `tspan` | Fixed or proportional line height | CSS | **Placeholder** | +| `text-transform` | `text`, `tspan` | Case transformation | CSS | **Placeholder** | +| `text-align` | `text` | Horizontal paragraph alignment | CSS | **Placeholder** | +| `text-align-vertical` | Boxed `text` | Top, center, or bottom placement | Flutter | **Placeholder** | +| `max-lines` | `text` | Paragraph line clamp | Flutter | **Placeholder** | +| `text-overflow` | `text` | Clip, ellipsis, or authored marker string | CSS/Flutter | **Design** | +| `text-decoration-line` | `text`, `tspan` | Underline, overline, or line-through | CSS | **Placeholder** | +| `text-decoration-style` | `text`, `tspan` | Solid, double, dotted, dashed, or wavy | CSS | **Placeholder** | +| `text-decoration-color` | `text`, `tspan` | Decoration color override | CSS | **Placeholder** | +| `text-decoration-thickness` | `text`, `tspan` | Decoration thickness | CSS | **Placeholder** | +| `text-decoration-skip-ink` | `text`, `tspan` | Glyph-ink skipping | CSS | **Placeholder** | + +The Draft 0 defaults are `font-size="16"`, `font-weight="400"`, and +`font-style="normal"`. `size` is invalid and is never accepted alongside or +as an alias for `font-size`; a canonical writer omits all three defaults on +`text`. Every other style row above remains unknown syntax until its status +advances; production defaults supply those unexposed `TextStyleRec` fields +during materialization. + +Character data and direct `tspan` data concatenate in document order into one +production `AttributedString`. Run boundaries are derived as UTF-8 byte +offsets; authors do not spell them. Runs cover the entire string contiguously +and merge when complete style and paint-override state match. Empty `tspan` is +invalid, while an empty `text` uses the production `0..0` default-run special +case. + +Run fills project `StyledTextRun.fills` rather than a parallel color field. +Omitted run fill maps to `None` and node-fill fallback. The `fill` attribute +maps to an explicit one-solid stack. A singular literal-first `` child +uses the existing ordered paint grammar; `` maps to an explicit empty +stack, and non-empty typed children remain bottom-to-top. Attribute and child +are mutually exclusive. Gradient and image coordinates use the resolved full +text-node paint box, never a per-`tspan` fragment box. + +HTML tags (`b`, `strong`, `i`, `em`, and similar), ``, and SVG `tspan` +positioning are intentionally not aliases. The production run model contains +visual style, not the semantic/accessibility annotation needed to preserve +HTML meaning, and Grida run geometry does not independently position chunks. CSS `vertical-align` is intentionally not proposed for paragraph-box vertical placement: its web meaning concerns inline/baseline alignment. Flutter's @@ -366,8 +441,7 @@ choosing a name alone. | ----------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Editable vector network | Vertices, cubic tangents, segments, loops, filled regions | Stable local IDs or indices; open/closed contours; region fill stacks; validation | | Variable-width stroke | Ordered `(u, half-width)` samples | Nested stops versus compact list; interpolation; endpoints; base-width interaction | -| Per-side box geometry | Four stroke widths and four elliptical corner radii | CSS shorthand reuse versus explicit children; normalization; mixed units | -| Attributed text | Text plus styled byte ranges and per-run paints | `` nesting; Unicode offsets; whitespace; inheritance; overlapping runs | +| Run strokes | Per-run stroke paints, width, and alignment | Reconcile singular production geometry with repeatable XML stroke topology | | Masks | Geometry, alpha, and luminance mask nodes | Which sibling is the mask; scope; consumption; stacking; clipping interaction | | Effects | Typed singleton and repeated effect slots | Cross-type order; multiplicity; compositing bounds; visibility and blend metadata | | Image resources and placement | Logical IDs, hashes, fit, affine placement, tiling | Packaging; base URI; network policy; intrinsic size; missing-resource behavior | @@ -389,18 +463,26 @@ This registry does not itself change the canonical scene or archive contracts. The following seams constrain conformance or require the explicit extension named by the Draft 0 RFD: -| Seam | Why it matters to Grida XML | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The current scene contract stores one stroke geometry with one ordered `Paints`, while Draft 0 repeats them. | One stroke maps directly; multiple strokes require an ordered list and must not be merged or hidden as duplicate scene nodes. | -| The scene model can hold malformed gradients that Draft 0 deliberately cannot spell. | A writer rejects too few stops, invalid or descending offsets, and coincident linear endpoints instead of repairing, sorting, clamping, or perturbing them. | -| Scene semantics and packed-archive coverage do not yet cover the same node set. | An authored element must not promise `.grida` round-trip until both contracts represent it. | -| Raw-path fill rules and post-layout transform origins are not uniform across scene and archive contracts. | `fill-rule`, `transform`, and `transform-origin` remain deferred until one meaning survives both rendering and persistence. | -| Draft 0 `hidden` removes a subtree from layout and paint. | A materializer must preserve that stronger behavior even when a target model names visibility differently. | -| Solid-paint alpha is stored in color, while Draft 0 exposes common paint `opacity`. | The documented 8-bit quantization is a boundary rule, not a parallel solid-opacity property. | -| Draft 0 uses `size` for text while established style vocabularies use `font-size`. | The language must choose one canonical spelling before expanding typography. | -| Responsive bindings and per-child alignment carry more source intent than a resolved Cartesian scene. | A resolved scene can consume their result but must not be mistaken for a lossless rewrite of the authored source. | -| Names, locks, durable identity, guides, and connections belong to different persistence concerns. | `id`, `name`, and `locked` need explicit authored-versus-editor rules; a root fill already covers visual background without deciding editor-only metadata. | -| `lens` operations and native flips may lower through more general transforms. | Materialization can preserve pixels without promising a lossless inverse decomposition; source round-trip requires an intent-preserving representation. | +| Seam | Why it matters to Grida XML | +| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The current scene contract stores one stroke geometry with one ordered `Paints`, while Draft 0 repeats them. | One stroke maps directly; multiple strokes require an ordered list and must not be merged or hidden as duplicate scene nodes. | +| The durable stroke-width boundary collapses four equal sides to a uniform or no-width state. | Draft 0 uses the same normalization and never emits an all-equal four-value canonical form. | +| Production per-side coverage ignores corner smoothing and non-miter join choice. | Draft 0 rejects nonzero smoothing, round or bevel joins, and non-default miter limits on a per-side stroke rather than preserving lossy state. | +| Production smooth corners circularize elliptical radius pairs. | Draft 0 rejects nonzero smoothing when any `rx` and `ry` differ; smoothed ellipses remain unavailable pending lossless renderer support. | +| Production smooth paths begin dash traversal at the top-edge midpoint. | Draft 0 consistently begins every rectangle at the top-left curve's top-edge join; geometry is identical, but dashed smoothed strokes have a different phase. | +| The scene model can hold malformed gradients that Draft 0 deliberately cannot spell. | A writer rejects too few stops, invalid or descending offsets, and coincident linear endpoints instead of repairing, sorting, clamping, or perturbing them. | +| Scene semantics and packed-archive coverage do not yet cover the same node set. | An authored element must not promise `.grida` round-trip until both contracts represent it. | +| Raw-path fill rules and post-layout transform origins are not uniform across scene and archive contracts. | `fill-rule`, `transform`, and `transform-origin` remain deferred until one meaning survives both rendering and persistence. | +| Draft 0 `hidden` removes a subtree from layout and paint. | A materializer must preserve that stronger behavior even when a target model names visibility differently. | +| Solid-paint alpha is stored in color, while Draft 0 exposes common paint `opacity`. | The documented 8-bit quantization is a boundary rule, not a parallel solid-opacity property. | +| Production `AttributedString` stores flat contiguous UTF-8 byte ranges. | XML concatenates mixed text in document order and derives ranges at character boundaries; authored offsets and nested runs are neither needed nor accepted. | +| The packed archive collapses an empty run-fill vector to an absent override. | Draft 0 distinguishes explicit `` no ink from omitted node-fill fallback; packing must reject it or preserve presence before claiming round-trip. | +| Production attributed paint currently resolves run fills against `(width, width)`. | Draft 0 requires the resolved full text-node width and height, matching node fills; the square paint box is an implementation bug, not XML semantics. | +| `StyledTextRun` has one optional stroke stack plus one width and alignment. | It cannot losslessly project repeatable independent XML stroke geometries, so Draft 0 rejects run strokes rather than merging or hiding them. | +| Production attributed runs carry visual style but no semantic/accessibility annotation. | Draft 0 rejects HTML semantic tags instead of pretending bold or italic styling preserves their meaning. | +| Responsive bindings and per-child alignment carry more source intent than a resolved Cartesian scene. | A resolved scene can consume their result but must not be mistaken for a lossless rewrite of the authored source. | +| Names, locks, durable identity, guides, and connections belong to different persistence concerns. | `id`, `name`, and `locked` need explicit authored-versus-editor rules; a root fill already covers visual background without deciding editor-only metadata. | +| `lens` operations and native flips may lower through more general transforms. | Materialization can preserve pixels without promising a lossless inverse decomposition; source round-trip requires an intent-preserving representation. | When the language, runtime, and archive disagree, a processor must limit its claimed subset or make the model change explicit. It must never create a diff --git a/docs/wg/format/grida-xml.md b/docs/wg/format/grida-xml.md index 356985e362..6e8e1d40ed 100644 --- a/docs/wg/format/grida-xml.md +++ b/docs/wg/format/grida-xml.md @@ -46,16 +46,18 @@ It is its own vocabulary, not an SVG profile or an HTML serialization. Draft 0 is deliberately small. It establishes the document boundary, the box and nesting model, a minimal primitive set, ordered typed paint, multiple -stroke geometries, text boxes, free bindings, and flex layout. Later drafts -can add effects, vector geometry, resource declarations, typography, and +stroke geometries, per-side box strokes, rounded box geometry, text boxes with +flat attributed runs, free bindings, and flex layout. Later drafts can add +effects, vector geometry, resource declarations, advanced typography, and reuse facilities without changing the central tree model. ## Design requirements 1. **One obvious tree.** Outside explicitly typed property subtrees such as - `fill` and `stroke`, element nesting is scene nesting. Source order is - painter order among sibling nodes, stroke geometries, and paints within a - channel. A node's children live in that node's local coordinate space. + `fill` and `stroke`, and the contextual `tspan` text-run element, element + nesting is scene nesting. Source order is painter order among sibling nodes, + stroke geometries, and paints within a channel. A node's children live in + that node's local coordinate space. 2. **Composition instead of combined node kinds.** Every Draft 0 render element except `text` can contain children. A rectangle containing text is written as a `rect` containing a `text` element, not as a special @@ -77,6 +79,10 @@ reuse facilities without changing the central tree model. remain structured. Each stroke geometry owns the same ordered paint value, while repetition is a declared extension to the current production single-geometry scene model. +8. **Flat, explicit attributed text.** `text` owns the paragraph, default text + style, and node paint. A direct `tspan` child changes explicit kebab-case + run properties for only its own characters. It is neither a scene node nor + an embedded HTML or SVG positioning language. ## File identity @@ -128,20 +134,23 @@ instructions are not; text uses ordinary escaped XML character data. ### Core node taxonomy -| Element | Box source | Children | Meaning | -| ----------- | ------------------------------- | -------- | --------------------------------------------------- | -| `container` | Declared; `auto` may hug | Yes | Boxed composition and the sole flex-layout owner | -| `rect` | Declared | Yes | Parametric rectangle realized in its box | -| `ellipse` | Declared | Yes | Parametric ellipse realized in its box | -| `line` | Declared width; zero-height box | Yes | Horizontal local line, orientable by rotation | -| `text` | Measured under box constraints | No | Unicode text flowed into an auto or fixed text box | -| `group` | Derived from child bounds | Yes | Logical subtree with an explicit local origin | -| `lens` | Derived from child bounds | Yes | Group whose ordered operations affect painting only | - -`text` is the only leaf in Draft 0. Every other node accepts an ordered list -of render-node children, including each primitive element. A paintable parent -paints its fill first, then its children in document order, then its own -strokes. Later siblings paint above earlier siblings. +| Element | Box source | Render children | Meaning | +| ----------- | ------------------------------- | --------------- | --------------------------------------------------- | +| `container` | Declared; `auto` may hug | Yes | Boxed composition and the sole flex-layout owner | +| `rect` | Declared | Yes | Parametric rectangle realized in its box | +| `ellipse` | Declared | Yes | Parametric ellipse realized in its box | +| `line` | Declared width; zero-height box | Yes | Horizontal local line, orientable by rotation | +| `text` | Measured under box constraints | No | Unicode text with optional flat attributed runs | +| `group` | Derived from child bounds | Yes | Logical subtree with an explicit local origin | +| `lens` | Derived from child bounds | Yes | Group whose ordered operations affect painting only | + +`text` accepts no render-node children. It alone may contain direct, flat +`tspan` contexts in addition to character data and its leading paint +properties. `tspan` is not a render node and does not have a box, identity, +transform, opacity, layer position, or child scene. Every other node accepts an +ordered list of render-node children, including each primitive element. A +paintable parent paints its fill first, then its children in document order, +then its own strokes. Later siblings paint above earlier siblings. `shape` is a reserved element name, not a Draft 0 render element. It is held for a possible future custom-shape definition. This reservation assigns no @@ -155,7 +164,7 @@ child: ```xml - Continue + Continue ``` @@ -185,7 +194,7 @@ still unknown syntax to a Draft 0 reader. ## Common authored attributes -Only attributes applicable to a node may appear on that node. +Only attributes applicable to an element may appear on that element. | Attribute | Value | Default | Contract | | ------------------------- | ----------------------------------------- | ---------- | ---------------------------------------------------------------- | @@ -195,9 +204,14 @@ Only attributes applicable to a node may appear on that node. | `min-width`, `min-height` | non-negative finite number | none | Sets a lower bound on a non-derived box axis | | `max-width`, `max-height` | non-negative finite number | none | Sets an upper bound on a non-derived box axis | | `aspect-ratio` | positive finite `width:height` pair | none | Resolves one otherwise under-specified box axis | +| `corner-radius` | one or four radii, optionally elliptical | `0` | Rounds the outline of a `container` or `rect` | +| `corner-smoothing` | finite number in `[0, 1]` | `0` | Smooths circular corners on a `container` or `rect` | | `rotation` | finite number | `0` | Visual rotation about box center; about origin for derived kinds | | `flip-x`, `flip-y` | `true` or `false` | `false` | Reflects paint horizontally or vertically before rotation | | `fill` | `#RGB` or `#RRGGBB` | none | Canonical compact form for one ordinary solid fill | +| `font-size` | positive finite number | `16` | Default size on `text`; optional run override on `tspan` | +| `font-weight` | integer from `1` through `1000` | `400` | Default weight on `text`; optional run override on `tspan` | +| `font-style` | `normal` or `italic` | `normal` | Default style on `text`; optional run override on `tspan` | | `opacity` | finite number in `[0, 1]` | `1` | Composites the node and its descendants | | `hidden` | `true` or `false` | `false` | Removes the subtree from layout and painting | | `flow` | `in` or `absolute` | `in` | Opts a child into or out of a flex parent's flow | @@ -221,9 +235,11 @@ rotated; neither operation writes a matrix or negative extent into source. `fill="#fff"` means exactly one visible, fully opaque, normal-blend solid paint. It is not legacy syntax: it is the canonical compact form for the most -common fill. It cannot coexist with the structured `` channel. `fill` is -valid on `container`, `rect`, `ellipse`, and `text`; it is invalid on `line`, -`group`, and `lens`. +common fill. On a render node it cannot coexist with the structured `` +channel. Node `fill` is valid on `container`, `rect`, `ellipse`, and `text`; it +is invalid on `line`, `group`, and `lens`. Contextually, `fill` on `tspan` is a +single-solid run override rather than a second node paint channel. Omitting it +leaves the run attached to the `text` node's fill stack. ## Paint channels and vocabulary @@ -243,6 +259,11 @@ or an extra compositing layer. Typed paint children are ordered bottom to top: the first is painted first, and each later paint composites above the accumulated result. +A contextual `tspan` may carry one optional run-fill override using the same +`fill` attribute-or-channel partition and the same ordered `Paints` value. It +does not introduce a second paint abstraction. Omission means node-fill +fallback rather than an independent run default. + This is the canonical expanded form for a rich fill: ```xml @@ -266,7 +287,8 @@ A node's structural paint children must precede character data and render-node children. When both channels occur, the optional `fill` element comes first, followed by zero or more `stroke` elements. `container`, `rect`, `ellipse`, and `text` accept fills and strokes. `line` accepts strokes only. `group` and -`lens` accept neither. +`lens` accept neither. The contextual `tspan` exception accepts one `fill` as +its literal first child and no `stroke`. The direct paint vocabulary is: @@ -294,10 +316,14 @@ element's language default: | `rect`, `ellipse`, `text` | one opaque normal-blend black solid | | `line`, `group`, `lens` | not fillable | +`tspan` has no independent language-default paint stack. Omitted run fill +means `None` and therefore uses the owning `text` node's effective fill stack; +an explicit empty run fill means no glyph ink for that run. + `` differs from omission on primitives and text: it suppresses the default black paint. A reader also accepts a structured one-solid channel, but -there is one canonical writer representation. The writer applies these rules -in order: +there is one canonical writer representation. For a node fill, the writer +applies these rules in order: 1. If the fill stack equals the element default, omit both forms. 2. If it is exactly one visible, fully opaque, normal-blend solid, emit the @@ -343,35 +369,142 @@ multi-stroke keeps one shape, one layout identity, and one editable outline. ``` -The uniform Draft 0 stroke geometry is: +The Draft 0 stroke geometry is: -| Attribute | Value | Default | Valid targets | -| ------------- | ------------------------------------------ | ---------------------------- | -------------------------------------- | -| `width` | non-negative finite logical pixels | `1` | every `stroke` | -| `align` | `inside`, `center`, or `outside` | `inside`; `center` on `line` | every `stroke`; line must be `center` | -| `cap` | `butt`, `round`, or `square` | `butt` | `line` | -| `join` | `miter`, `round`, or `bevel` | `miter` | `container`, `rect` | -| `miter-limit` | positive finite number | `4` | `container`, `rect` | -| `dash-array` | space-separated non-negative finite values | absent, meaning solid | `container`, `rect`, `ellipse`, `line` | +| Attribute | Value | Default | Valid targets | +| ------------- | ------------------------------------------ | ---------------------------- | ------------------------------------------------ | +| `width` | one or four non-negative finite pixels | `1` | every `stroke`; four only on `container`, `rect` | +| `align` | `inside`, `center`, or `outside` | `inside`; `center` on `line` | every `stroke`; line must be `center` | +| `cap` | `butt`, `round`, or `square` | `butt` | `line` | +| `join` | `miter`, `round`, or `bevel` | `miter` | `container`, `rect` | +| `miter-limit` | positive finite number | `4` | `container`, `rect` | +| `dash-array` | space-separated non-negative finite values | absent, meaning solid | `container`, `rect`, `ellipse`, `line` | When present, `dash-array` contains at least one value and is not all zero. Odd-length arrays repeat once to make an even dash-gap cycle. An attribute on a target outside this table is an error rather than dormant state. A `line` is an open path and accepts only `align="center"`, because inside and outside are not -defined for it. Per-side widths, variable-width profiles, dash offset, and -endpoint markers are later vocabulary. +defined for it. Variable-width profiles, dash offset, and endpoint markers are +later vocabulary. + +#### Uniform and per-side width + +The `width` attribute is the lexical form of the existing stroke-width choice; +it is not a second geometry abstraction. One number is a uniform width. +Exactly four numbers are rectangular widths in top, right, bottom, left order: + +```xml + + + + + + + +``` -Stroke width is distributed relative to the source outline. `center` places -half the width on each side; `inside` places the full width toward the filled -region; `outside` places it away from the filled region. For text, that region -is each shaped glyph's filled outline, including the inverse direction around -counters. Line caps and rectangular joins use the declared `cap`, `join`, and -`miter-limit` values. +Every number is a non-negative finite logical-pixel value. Lists of two or +three numbers, commas, units, and empty lists are invalid. The four-value form +is valid only on `container` and `rect`, even when all four values happen to be +equal; applicability is checked before value normalization. Side names refer +to the node's untransformed local box and are not permuted in source by flips, +rotation, or ancestor transforms. + +Omitted `width` means the uniform default `1`. A reader normalizes four equal +positive values to the uniform state and four zero values to the no-width +state. A canonical writer omits uniform `1`, writes every other positive +uniform width as one number, writes a present no-width state as `width="0"`, +and writes four values only when they differ. Thus `width="3 3 3 3"` is +accepted on a box but canonicalizes to `width="3"`; `width="0 0 0 0"` +canonicalizes to `width="0"`. A zero-width painted stroke remains authored +intent and is not deleted merely because it has no current coverage. + +Each repeated `stroke` owns its width independently. A four-value width does +not alter, inherit from, or accumulate with any earlier stroke. Its direct +typed paints all share one resulting ring; Draft 0 does not attach separate +paint stacks to the four sides. + +Let `t`, `r`, `b`, and `l` be the resolved top, right, bottom, and left widths. +Alignment supplies the outward and inward fractions used to construct the +stroke's outer and inner contours: + +| Alignment | Outward fraction | Inward fraction | +| --------- | ---------------- | --------------- | +| `inside` | `0` | `1` | +| `center` | `1/2` | `1/2` | +| `outside` | `1` | `0` | + +The outer box expands each local side by its width times the outward fraction; +the inner box insets it by its width times the inward fraction. At a corner +with horizontal-adjacent width `h`, vertical-adjacent width `v`, and source +radius `(rx, ry)`, the two contour radii are: + +- outer: `(rx + outward * h, ry + outward * v)`; +- inner: `(max(0, rx - inward * h), max(0, ry - inward * v))`. + +For example, the top-left corner uses `h = l` and `v = t`; the bottom-right +uses `h = r` and `v = b`. Each outer and inner contour then applies the +ordinary rounded-rectangle non-overlap rule. These operations resolve +coverage only; they never rewrite the four authored widths or resize the +layout box. + +Responsive geometry can resolve smaller than its authored stroke widths, so +overconsumption is not a parse error. If either inner-box extent is zero or +negative after the insets, the inner contour is empty and coverage saturates +to the outer contour instead of producing inverted geometry. + +Per-side widths support every stroke alignment, ordinary circular or +elliptical corner radii, and `dash-array`. Dash traversal remains one +continuous clockwise contour. A zero-width side suppresses coverage on that +side but does not remove its length from the dash metric or restart the dash +pattern. + +Two current compatibility boundaries are strict rather than lossy: + +- a per-side width requires `corner-smoothing="0"`, because the existing + per-side ring cannot follow a smoothed source outline; +- it admits only the default `join="miter"` and `miter-limit="4"`, because + round and bevel joins and non-default miter limits are not independently + represented by the existing rectangular ring. + +These restrictions apply even when a conflicting value would currently have +no visible effect. A reader or writer must reject the combination rather than +retain state that rendering ignores. Uniform-width strokes retain the full +`join` and `miter-limit` grammar already defined above. + +##### Considered width syntax + +1. **One-or-four values on `width` — accepted.** It preserves the common + scalar form, makes rectangular width a surgical one-token-per-side edit, + and maps directly to the uniform-or-rectangular model choice. +2. **A base width plus named side overrides — rejected as canonical.** It + exposes unresolved fallback state and permits many equivalent spellings. +3. **Four `top-width`, `right-width`, `bottom-width`, and `left-width` + attributes — rejected.** They are explicit but disproportionately verbose + for four scalar values and would introduce a second width vocabulary. +4. **A nested `width` or `sides` element — rejected.** It adds structural + geometry among direct paint children without representing another model + object. +5. **CSS two- and three-value expansion — rejected.** Draft 0 requires either + one value or all four so no value is duplicated by an implicit positional + rule. +6. **A separate `widths` attribute — rejected.** Singular and plural names for + one model choice would weaken the one-spelling rule. + +Uniform stroke width is distributed relative to the source outline. `center` +places half the width on each side; `inside` places the full width toward the +filled region; `outside` places it away from the filled region. For text, that +region is each shaped glyph's filled outline, including the inverse direction +around counters. Line caps and uniform rectangular joins use the declared +`cap`, `join`, and `miter-limit` values. Dash traversal begins at the line's local start point, at a rectangle's top-left corner moving clockwise, and at an ellipse's rightmost point moving -clockwise. The first dash begins at that origin because Draft 0 has no dash -offset. Each distinct contour restarts the pattern. +clockwise. On a rounded rectangle, the rectangular origin becomes the point +where the top-left corner curve joins the top edge, still moving toward the +top-right. The first dash begins at that origin because Draft 0 has no dash +offset. Each distinct contour restarts the pattern. A zero-width side of a +per-side stroke still advances this traversal before the following side. Stroke elements are ordered in painter order: the first stroke is bottommost and the last is topmost. Paints within each stroke are independently ordered @@ -745,6 +878,80 @@ already determined and never invents a scale transform. Using an aspect ratio where neither axis can provide the other, or redundantly declaring it when both axes are already supplied, is an error. +### Rounded box geometry + +`container` and `rect` accept `corner-radius` and `corner-smoothing`. Both +properties modify the node's original rectangular outline; they do not create +a second shape, resize the layout box, or change the rectangular coordinate +space used by its paints. + +`corner-radius` contains one or exactly four non-negative finite logical-pixel +values. One value applies to every corner. Four values are ordered top-left, +top-right, bottom-right, bottom-left. An optional slash followed by an +independent one-or-four-value list makes the radii elliptical: values before +the slash are horizontal radii (`rx`), and values after it are vertical radii +(`ry`). Without a slash, each vertical radius equals its corresponding +horizontal radius. + +```xml + + + +``` + +Lists of two or three values are invalid; Draft 0 deliberately avoids the +positional expansion rules of CSS shorthand. A slash must have a valid list on +both sides and may appear only once. A canonical writer separates values with +one ASCII space and spells the separator as `/`. The default is four zero +radius pairs. If either effective component of a pair is zero, that corner is +square. + +Corner names refer to the node's untransformed local box. Native flips, +rotation, and ancestor transforms apply to the completed outline afterward; +they never rewrite or permute the authored top-left, top-right, bottom-right, +and bottom-left values. + +For an ordinary rounded outline (`corner-smoothing="0"`), overlapping authored +radii resolve with one proportional scale factor for all eight radius +components. Let `W` and `H` be the resolved width and height. The factor is the +minimum of `1` and each applicable edge ratio: + +- `W / (rx_top-left + rx_top-right)`; +- `W / (rx_bottom-left + rx_bottom-right)`; +- `H / (ry_top-left + ry_bottom-left)`; +- `H / (ry_top-right + ry_bottom-right)`. + +An edge ratio whose radius sum is zero imposes no constraint. Multiplying all +radii by this one factor prevents overlap while preserving their relative +proportions. Normalization is a resolved geometry operation: it never rewrites +the authored `corner-radius` value. + +`corner-smoothing` is a finite scalar in `[0, 1]`; `0` is the ordinary +elliptical rounded-rectangle outline. Nonzero values request progressively +smoothed corners. The current production smoothing renderer is circular-only: +it collapses each elliptical `(rx, ry)` pair to a circle. Draft 0 therefore +rejects a nonzero `corner-smoothing` when any corner has unequal horizontal and +vertical radii, rather than silently changing authored geometry. Smoothed +elliptical corners remain unavailable pending lossless renderer support. +Nonzero smoothing with four zero radius pairs is valid dormant intent and +remains serializable even though it has no visible effect. + +For a nonzero smoothing value, Draft 0 follows the existing production +smoothed-box profile instead: each circular radius is independently capped to +half the box's shorter side before constructing its curve. Unlike ordinary +edge-sum normalization, that cap may change a lone large corner and does not +rescale the other authored corners. It is resolved geometry, never a source +rewrite. Keeping this difference explicit projects the current Grida model and +renderer without pretending the ordinary and continuous-corner algorithms are +the same operation. + +The same resolved outline is the source for a node's fill, every repeated +stroke, and a container's descendant clip when `clips="true"`. Each stroke is +still derived independently from that original outline. Paint coordinates +continue to use the full rectangular node box, including transparent space +outside the rounded coverage; rounding or smoothing never shrinks or warps the +paint box. + ## Box semantics by element ### Container @@ -767,9 +974,10 @@ box, then add the container's left and top padding; therefore `x="0"` and A container may carry a fill and repeated strokes and may clip descendant paint with `clips="true"`. Its fill is painted first. Its children are then -painted under the descendant clip, if enabled; after that clip is removed, the -container's strokes are painted last. The default container fill is empty and -the default stroke list is empty. +painted under the descendant clip, if enabled; that clip uses the container's +resolved rounded or smoothed outline. After the descendant clip is removed, +the container's strokes are painted last. The default container fill is empty +and the default stroke list is empty. ### Primitive elements @@ -779,6 +987,8 @@ Draft 0 recognizes the direct primitive elements `rect`, `ellipse`, and - `rect` and `ellipse` require each axis to be supplied by either a fixed numeric size or a `span` binding. `aspect-ratio` may supply exactly one otherwise-unsupplied axis. +- `rect` may round and smooth its rectangular outline. These properties affect + its fill and strokes, but do not implicitly clip its children. - `line` requires either fixed numeric `width` or an x-axis `span`. Its authored and resolved height is zero, so `height` and a y-axis `span` are invalid. It is the horizontal segment from local `(0, 0)` to `(width, 0)` @@ -798,21 +1008,139 @@ resizing the primitive does not rewrite a child's authored bindings. ### Text -The character data directly inside `text` is its content. `text` cannot -contain render elements. It may begin with the structural `fill` property and -zero or more `stroke` properties, which are neither character content nor -render children. Formatting whitespace before and between those leading -properties is ignored. After the final property closes—or immediately after -the opening tag when there is no property—every character is text content, -including whitespace. A canonical writer therefore places the first intended -character immediately after the final property's closing tag, rather than -pretty-printing an unintended newline. Entity references are decoded as XML -character data. - -Text accepts `size`, a positive font size in logical pixels. Draft 0 leaves -font selection to the declared resolution environment; a resolved artifact -records the result of shaping with that environment rather than writing it -back into the source. +`text` is the scene node. It owns the text box, paragraph properties, default +text style, node fill stack, and repeated node stroke geometries. Its content +is one flat sequence of direct character data and contextual `tspan` runs: + +```text +text := node-fill? stroke* segment* +segment := character-data | tspan +tspan := run-fill? non-empty character-data +node-fill := fill +run-fill := fill +``` + +There may be at most one leading `fill`; `stroke` is repeatable. Once the first +character-data segment or `tspan` begins, no node property may follow. A +`tspan` must be a direct child of `text`, cannot itself contain `tspan`, and may +contain no element other than its optional singular leading `fill`. It is not +a scene node: `id`, `name`, `x`, `y`, `width`, `height`, `rotation`, `opacity`, +layout attributes, render children, and SVG chunk-positioning attributes such +as `dx`, `dy`, and per-chunk `rotate` are invalid on it. + +Whitespace is exact character content, not formatting, once content begins. +Formatting whitespace before and between leading node properties is ignored. +After the last leading property closes—or immediately after the opening tag +when there is no property—every direct character is content, including spaces, +tabs, and newlines before, between, or after `tspan` children. Every character +inside `tspan` is likewise content. XML entity references are decoded before +the string and ranges are formed. XML comments contribute no characters. An +empty `tspan` is invalid after XML decoding; a whitespace-only `tspan` is +non-empty and significant. This means pretty indentation inside mixed text +changes the design, so a canonical writer keeps intended content adjacent to +its tags: + +```xml +Ship boldly. +``` + +`text` establishes a complete default run style. Draft 0 exposes only these +typographic attributes on `text` and `tspan`: + +| Attribute | Value | `text` default | `tspan` behavior | +| ------------- | ------------------------------- | -------------- | -------------------------------- | +| `font-size` | positive finite logical pixels | `16` | overrides the default run size | +| `font-weight` | integer from `1` through `1000` | `400` | overrides the default run weight | +| `font-style` | `normal` or `italic` | `normal` | overrides the default run style | + +An omitted `tspan` attribute inherits from `text`, never from the preceding +run or sibling. The `size` spelling is invalid; `font-size` is the one +canonical spelling. Draft 0 leaves font-family selection and every unexposed +production text-style field to the declared resolution environment and its +defaults. A resolved artifact records the shaping result without writing it +back into source. A canonical writer omits all three default values on `text`. + +There is no `style` mini-language. `` is not an alias for `tspan`, and +HTML semantic or presentational elements such as `b`, `strong`, `i`, `em`, and +`small` are invalid. The production attributed-string model has no matching +semantic or accessibility annotation to preserve, so inferring meaning from +such tags would be lossy. Authors express the supported visual fact directly, +for example `font-weight="700"` or `font-style="italic"`. `tspan` borrows the +familiar name, not SVG's independently positioned text-chunk model. + +#### Considered rich-text syntax + +1. **`text` with direct, flat `tspan` runs — accepted.** It projects the flat + production attributed string and keeps run overrides explicit. +2. **HTML semantic or presentational tags — rejected as canonical.** A separate + import dialect may deliberately lower them to visual run properties, but + Grida XML cannot preserve semantics the production model does not own and a + canonical writer never emits them. +3. **Nested `tspan` — rejected in Draft 0.** Production runs are flat byte + ranges, and nested source boundaries cannot be reconstructed after adjacent + equivalent runs merge. A later version would need additional preserved + structure rather than pretending the nesting survived. +4. **`style="…"` — rejected.** A CSS-like declaration string would create a + second nested grammar with weaker validation and diagnostics than explicit + kebab-case attributes. + +#### Attributed-string materialization + +Materialization concatenates direct `text` character data and each `tspan`'s +character data in document order into one UTF-8 backing string. Authors never +spell run offsets. For each non-empty segment, the materializer derives an +inclusive start and exclusive end as UTF-8 byte offsets at character +boundaries. Direct character data receives the complete `text` default style; +`tspan` data receives that same complete style with its explicit overrides. +The resulting runs are ordered, contiguous, non-overlapping, and cover the +entire backing string. Adjacent runs are merged only when their complete style +and run-paint override state are identical. An empty `text` materializes to the +production empty-string special case with one default `0..0` run. + +Node `fill` remains the ordered fallback paint stack for the whole text node. +Direct text and a `tspan` without `fill` materialize with +`StyledTextRun.fills = None`, which means node-fill fallback. `fill="#RRGGBB"` +on `tspan` materializes as `Some([solid])`, preserving an explicit single-solid +override even when its pixels currently match the node fill. This is the +compact per-run fill spelling. + +A `tspan` may instead begin with one singular `` property containing the +same ordered typed `Paints` as a node fill. It must be the literal first child: +no character data, comment, or formatting whitespace may precede it. Once it +closes, every character is content. The `fill` attribute and property are +mutually exclusive, and no `fill` may follow character content. Omission maps +to `fills = None`, an empty `` maps to `Some([])` and therefore explicit +no ink, and a non-empty property maps to `Some(ordered paints)`. The canonical +writer uses the `fill` attribute for one ordinary solid override and uses the +property for explicit emptiness or every other stack, exactly matching the +node-fill partition: + +```xml +A gradient runmasked +``` + +The current packed `.grida` encoder/decoder collapses an empty run-fill vector +to an absent one. That persistence boundary cannot yet round-trip ``: +restoring the node fill would change explicit no ink into visible ink. A +converter must reject that state or declare a non-round-tripping subset until +the archive preserves presence independently from vector length; it must not +silently normalize `Some([])` to `None`. + +Structured run-fill gradient and image coordinates resolve against the +resolved full text-node paint box, exactly as node fills do. They do not +restart in each `tspan` fragment or use a fragment's glyph bounds. The current +production attributed renderer passes `(width, width)` as the paint box for +run fills and strokes; using width for height is an implementation +incompatibility to fix against the resolved text-node width and height, not a +distinct XML coordinate system. + +Node-level repeated strokes remain valid on `text` and apply to its shaped +glyph contours. Draft 0 defines no `tspan` stroke syntax. Production +`StyledTextRun` has only one optional stroke `Paints` stack and one optional +width/alignment geometry, while Grida XML stroke topology permits repeated, +independent geometries. Those models cannot losslessly project each other, so +a writer must reject a production run-stroke override and the language must +defer run strokes until that multiplicity seam is resolved. Text is a box, not a point label: @@ -915,9 +1243,9 @@ circle, and text nested directly under a primitive element: - 01 + 01 - Design responsive 2D content that wraps predictably. + Design responsive 2D content that wraps predictably. ``` @@ -961,7 +1289,7 @@ declared Draft 0 scene or reports a typed failure. It must reject: - attributes used on a node kind where they do not apply; - a typed paint outside `fill` or `stroke`, a `stop` outside `gradient`, a nested `fill` or `stroke`, a duplicate `fill`, or a structural paint child - placed after text or render children; + placed after text content, a `tspan`, or render children; - simultaneous use of the `fill` attribute and `fill` element on one node; - unknown paint tags or paint attributes, a generic `paint` envelope, an invalid paint blend mode, or a non-boolean paint visibility value; @@ -973,11 +1301,24 @@ declared Draft 0 scene or reports a typed failure. It must reject: whose geometry equals its target defaults, a stroke attribute invalid for its target, invalid stroke geometry, a non-centered line stroke, or a malformed dash array; +- a stroke width with neither one nor four values, a negative or non-finite + side, four values on a non-box target, or a per-side width combined with + nonzero corner smoothing, a non-miter join, or a non-default miter limit; - non-finite numbers, negative dimensions, or malformed colors; -- non-positive font sizes or aspect-ratio terms, negative growth, gap, or +- malformed `corner-radius` lists, negative radii, `corner-smoothing` outside + `[0, 1]`, either corner property on an inapplicable element, or nonzero + smoothing paired with any elliptical corner; +- a `tspan` outside `text`, a nested or empty `tspan`, a render element inside + `text`, or any element other than one leading `fill` inside `tspan`; +- simultaneous `fill` attribute and child on `tspan`, duplicate run `fill`, or + a run `fill` placed after any character data or comment; +- ``, HTML text tags, a `style` attribute, `size`, or SVG chunk-position + attributes on `tspan`; +- non-positive font sizes, non-integer or out-of-range font weights, unknown + font styles, non-positive aspect-ratio terms, negative growth, gap, or padding values, or opacity outside `[0, 1]`; -- render elements inside `text`, or non-whitespace character data inside a - non-text node; +- non-whitespace character data inside an element other than `text` or + `tspan`; - contradictory geometry such as a fixed width alongside an x-axis span. Unsupported future versions must fail as unsupported versions. A Draft 0 @@ -990,9 +1331,20 @@ state the expected grammar, and distinguish syntax, semantic validation, and resource resolution. For example, a misplaced image should say that image paints belong directly under `fill` or `stroke`; a gradient without `kind` should list the four accepted values; a descending stop should identify the -two offsets; an unsupported second stroke should identify the target model's -single-geometry limit; and a missing image file should report its authored -`src` rather than presenting the failure as malformed XML. +two offsets; a malformed corner-radius should identify the failing side of the +slash and require one or four values; a malformed per-side width should name +the failing top, right, bottom, or left value; a smoothing or join conflict +should identify the per-side stroke it cannot represent; an unsupported second +stroke should identify the target model's single-geometry limit; and a missing +image file should report its authored `src` rather than presenting the failure +as malformed XML. Useful width diagnostics include `stroke width takes 1 or +exactly 4 numbers in top right bottom left order; got 3`, `stroke width right +must be a non-negative finite number`, and `four-value stroke width is valid +only on and `. Useful text diagnostics include `empty +is invalid; put empty text on or remove the run`, `nested is +invalid; runs must be flat direct children of `, ` inside +must be the literal first child; no whitespace may precede it`, and `size is +not Grida XML; use font-size`. ## Conformance @@ -1007,8 +1359,9 @@ A conforming reader: this RFD. 2. **MUST** preserve node order, text content, and authored intent exactly at the semantic level, including stroke order, paint order, inactive paints, - equal-offset stops, empty fill stacks where omission changes semantics, and - empty strokes with non-default geometry. + equal-offset stops, empty fill stacks where omission changes semantics, + per-character resolved text style and run-fill override state, and empty + strokes with non-default geometry. 3. **MUST NOT** accept an unknown construct by ignoring or coercing it. 4. **MUST** distinguish a parse failure from a resolution failure. A well-formed intent document can still fail to resolve in an insufficient @@ -1020,22 +1373,43 @@ A conforming writer: 1. **MUST** emit `grida version="0"` with exactly one `container` child. 2. **MUST** emit the canonical Draft 0 node and attribute names, including - `container`, `rect`, `ellipse`, `line`, `width`, and `height` rather than - historical aliases. + `container`, `rect`, `ellipse`, `line`, `tspan`, `width`, `height`, and + `font-size` rather than historical aliases. 3. **MUST** apply the canonical fill partition: omit the default, use the `fill` attribute for one ordinary solid, and use the `fill` element for all - other non-empty stacks or required explicit emptiness. + other non-empty stacks or required explicit emptiness. The same partition + applies to an explicit `tspan` run-fill override. 4. **MUST** normalize a default empty stroke pair to omission, preserve an empty stroke with non-default geometry, and emit every remaining stroke in bottom-to-top order with its typed paints in bottom-to-top order. -5. **MUST** emit a gradient's required `kind`; it **MUST NOT** emit the four +5. **MUST** normalize equal per-side widths to the uniform or no-width state, + omit uniform width `1`, emit other uniform values as one number, and emit + exactly four top-right-bottom-left values only for unequal rectangular + widths. +6. **MUST** emit a gradient's required `kind`; it **MUST NOT** emit the four kind-specific gradient element names, ``, or a generic `paint` envelope. -6. **MUST** encode XML text and attribute values correctly and emit only +7. **MUST** encode XML text and attribute values correctly and emit only finite, valid values. -7. **MUST NOT** serialize resolved-only values as if they were source intent. -8. **SHOULD** produce stable, human-readable indentation and stable attribute - ordering, although neither is semantic in Draft 0. +8. **MUST** derive text run boundaries from the one backing UTF-8 string, merge + adjacent runs with identical complete style and paint-override state, emit + no default-valued typography attributes on `text`, emit + direct character data for each maximal run equal to the `text` default with + no run-fill override, and emit one flat `tspan` for every other maximal run. + A `tspan` carries only typographic attributes that differ from the `text` + default plus its fill override; it is never nested or empty, and authored + byte offsets are never emitted. +9. **MUST NOT** pretty-indent mixed text content, because inserted spaces or + newlines are semantic. It must also reject an unsupported production run + style or run stroke rather than silently dropping it. +10. **MUST** emit the shortest exact one-or-four-value representation for each + corner-radius axis, omit the slash when every `rx` equals its `ry`, and + omit each corner attribute when its value is the zero default. +11. **MUST NOT** serialize resolved-only values, including normalized effective + corner radii, as if they were source intent. +12. **SHOULD** produce stable, human-readable indentation outside mixed text + content and stable attribute ordering, although neither is semantic in + Draft 0. ### Resolver and renderer @@ -1051,20 +1425,33 @@ A conforming resolver and renderer: 4. **MUST** derive each stroke independently from the node's original outline and composite that stroke's visible paints in bottom-to-top order. It must not merge distinct stroke geometries into one paint stack. -5. **MUST** composite every visible paint in its declared bottom-to-top order, +5. **MUST** resolve rectangular stroke widths as one outer-minus-inner ring + using the declared local-side widths and alignment fractions. It must treat + an overconsumed inner box as an empty contour, include each side's actual + outward extent in visual bounds, and leave the layout box unchanged. +6. **MUST** use one proportional edge-sum factor for ordinary rounded-box + overlap normalization and the per-corner half-short-side cap for nonzero + smoothing. It must then use the same resulting outline as the source for + fill coverage, every stroke, and a container's descendant clip. The + rectangular paint box must remain unchanged. +7. **MUST** composite every visible paint in its declared bottom-to-top order, applying each paint's opacity and blend mode to that paint only. -6. **MUST** evaluate gradient geometry in the declared unit paint space and +8. **MUST** evaluate gradient geometry in the declared unit paint space and map it to the resolved paint box without rewriting source coordinates. -7. **MUST** resolve image identifiers through the declared resource +9. **MUST** resolve image identifiers through the declared resource environment and surface missing or undecodable resources as resolution failures rather than silently dropping their paint layers. -8. **MUST** include every effective stroke in resolved visual bounds without - changing the node's layout box. -9. **MUST** apply rotation and native flips as visual-only transforms and - apply clipping and opacity to the declared subtree. -10. **MUST NOT** mutate the source document as a side effect of resolving or +10. **MUST** shape `text` and its flat `tspan` segments as one backing string, + use the node fill when a run has no override, and preserve explicit empty + and ordered run-fill stacks. Every node and run fill uses the resolved full + text-node paint box; paint coordinates do not restart per run. +11. **MUST** include every effective stroke in resolved visual bounds without + changing the node's layout box. +12. **MUST** apply rotation and native flips as visual-only transforms and + apply clipping and opacity to the declared subtree. +13. **MUST NOT** mutate the source document as a side effect of resolving or rendering it. -11. **MUST** make the resolution environment explicit enough that a resolved +14. **MUST** make the resolution environment explicit enough that a resolved result can be attributed to a viewport, font set, and resource set. A processor that intentionally supports only part of Draft 0 may describe @@ -1073,12 +1460,12 @@ must not silently discard the unsupported remainder. ## Deferred requirements -Draft 0 does not define paths or vector networks, per-side or variable-width -strokes, dash offset, endpoint markers, effects, scene image nodes, resource -declaration or packaging syntax, advanced image placement, image filters, rich -color spaces, rich text runs, grid layout, animation, or durable node identity. -Their eventual addition must preserve the one-tree, local-space, intent-only -model established here. +Draft 0 does not define paths or vector networks, variable-width strokes, dash +offset, endpoint markers, effects, scene image nodes, resource declaration or +packaging syntax, advanced image placement, image filters, rich color spaces, +advanced typography, per-run strokes, semantic text annotations, grid layout, +animation, or durable node identity. Their eventual addition must preserve the +one-tree, local-space, intent-only model established here. Reusable paint definitions and references are also deferred. Inline paint values remain sufficient and canonical; a later reference system must define diff --git a/model-v2/a/lab/README.md b/model-v2/a/lab/README.md index 0d3af85cac..6e24f6f2cc 100644 --- a/model-v2/a/lab/README.md +++ b/model-v2/a/lab/README.md @@ -56,6 +56,17 @@ primitive tags are not accepted there. The proof parser also requires non-negative `grow`, `gap`, and `padding`; historical `textir` retains its experiment-era numeric behavior. +Grida XML text uses canonical `font-size`, numeric `font-weight`, and +`font-style="normal|italic"`; historical TextIr alone keeps `size`. Direct, +flat `` children lower to the production-shaped UTF-8 +`AttributedString` model and never become scene nodes. Omitted run fills fall +back to the text node's ordered paints, while `fill="#…"` and one literal-first +structured `` preserve explicit run overrides, including rich paint +stacks and explicit emptiness. The deterministic metric is run-aware for font +size; weight and italic remain metric-neutral but survive into paint. Run +strokes stay rejected until their single production geometry can reconcile +with Draft 0's repeatable stroke topology. + Fill-bearing nodes carry one ordered `Paints` value matching the production paint contract. `fill="#fff"` is the canonical ordinary singleton-solid form; singular `` owns every richer stack and explicit emptiness. Typed diff --git a/model-v2/a/lab/src/grida_xml.rs b/model-v2/a/lab/src/grida_xml.rs index a85e9c742a..41cb4c3b9d 100644 --- a/model-v2/a/lab/src/grida_xml.rs +++ b/model-v2/a/lab/src/grida_xml.rs @@ -117,6 +117,7 @@ fn semantic_node_eq( }) .cloned() .map(|mut stroke| { + stroke.width = stroke.width.normalized(); if let Some(values) = &mut stroke.dash_array { if values.len() % 2 == 1 { let repeated = values.clone(); @@ -127,8 +128,49 @@ fn semantic_node_eq( }) .collect::>() }; + // Invalid non-finite corner values must survive the structural self-check + // long enough for the source writer to return the focused validation + // error. Ordinary equality still applies to every finite value (`-0` and + // `0` remain semantically equal). + let corner_num_eq = + |a: f32, b: f32| a == b || (a.is_nan() && b.is_nan() && a.to_bits() == b.to_bits()); + let radius_eq = |a: crate::model::Radius, b: crate::model::Radius| { + corner_num_eq(a.rx, b.rx) && corner_num_eq(a.ry, b.ry) + }; + let corner_radius_eq = |a: crate::model::RectangularCornerRadius, + b: crate::model::RectangularCornerRadius| { + radius_eq(a.tl, b.tl) + && radius_eq(a.tr, b.tr) + && radius_eq(a.br, b.br) + && radius_eq(a.bl, b.bl) + }; + let payload_eq = |a: &crate::model::Payload, b: &crate::model::Payload| { + let (Some(a_text), Some(b_text)) = (a.as_text(), b.as_text()) else { + return a == b; + }; + if a_text.text != b_text.text || a_text.default_style != b_text.default_style { + return false; + } + let normalized = |value: crate::model::TextPayloadRef<'_>| { + let mut attributed = match value.runs { + Some(runs) => crate::model::AttributedString { + text: value.text.to_owned(), + runs: runs.to_vec(), + }, + None => crate::model::AttributedString::new(value.text, value.default_style), + }; + attributed.merge_adjacent_runs(); + attributed + }; + normalized(a_text) == normalized(b_text) + }; if a_node.header != b_node.header - || a_node.payload != b_node.payload + || !payload_eq(&a_node.payload, &b_node.payload) + || !corner_radius_eq(a_node.corner_radius, b_node.corner_radius) + || !corner_num_eq( + a_node.corner_smoothing.value(), + b_node.corner_smoothing.value(), + ) || a_node.fills != b_node.fills || canonical_strokes(a_node) != canonical_strokes(b_node) || a_node.children.len() != b_node.children.len() @@ -152,6 +194,18 @@ fn semantic_node_eq( /// render-root rule and the implicit viewport root make silently serializing a /// forest or a mutated document root lossy. pub fn print(doc: &Document) -> Result { + // Validate stroke scalars before the structural self-comparison below. + // IEEE NaN is not equal to itself, so derived equality would otherwise + // misreport an invalid side width as a corrupt scene tree. + for id in 0..doc.capacity() as NodeId { + let Some(node) = doc.get_opt(id) else { + continue; + }; + for stroke in &node.strokes { + textir::validate_stroke_for_write(stroke, &node.payload, node.corner_smoothing) + .map_err(PrintError::InvalidDocument)?; + } + } if !semantic_document_eq(doc, doc) { return Err(PrintError::InvalidDocument( "scene tree contains a dead, duplicate, cyclic, unreachable, or mis-parented node" @@ -167,6 +221,8 @@ pub fn print(doc: &Document) -> Result { let canonical_root = root.header == expected.header && root.payload == expected.payload + && root.corner_radius == expected.corner_radius + && root.corner_smoothing == expected.corner_smoothing && root.fills == expected.fills && root.strokes == expected.strokes && doc.parent_of(doc.root).is_none(); diff --git a/model-v2/a/lab/src/measure.rs b/model-v2/a/lab/src/measure.rs index dd8a1c7ca0..b96d82049f 100644 --- a/model-v2/a/lab/src/measure.rs +++ b/model-v2/a/lab/src/measure.rs @@ -12,6 +12,8 @@ //! (a word wider than the constraint overflows on its own line) //! - trailing spaces on a wrapped line are dropped +use crate::model::{TextPayloadRef, TextStyleRec}; + pub const CHAR_W: f32 = 0.6; pub const LINE_H: f32 = 1.2; @@ -112,9 +114,248 @@ pub fn measure_text(content: &str, font_size: f32, max_width: Option) -> (f (widest as f32 * cw, lines.len() as f32 * lh) } +/// One contiguous styled fragment on a materialized visual line. +/// +/// `run_index` is `None` for the historical uniform text payload and otherwise +/// identifies the source attributed run. Paint is intentionally not copied +/// into the measurement tier; the drawlist resolves that index against the +/// node-level fallback paints after line topology is fixed. +#[derive(Debug, Clone, PartialEq)] +pub struct TextFragmentLayout { + pub text: String, + pub x: f32, + pub advance: f32, + pub style: TextStyleRec, + pub run_index: Option, +} + +/// One visual line shared by measurement and draw-list materialization. +/// Different run sizes share a baseline; the largest run owns the line height. +#[derive(Debug, Clone, PartialEq)] +pub struct TextLineLayout { + pub text: String, + pub fragments: Vec, + pub width: f32, + pub height: f32, + pub baseline_y: f32, +} + +#[derive(Debug, Clone, Copy)] +struct StyledChar { + ch: char, + style: TextStyleRec, + run_index: usize, +} + +fn attributed_chars(text: TextPayloadRef<'_>) -> Vec { + let Some(runs) = text.runs else { + return Vec::new(); + }; + let mut run_index = 0usize; + text.text + .char_indices() + .map(|(byte, ch)| { + while run_index + 1 < runs.len() && byte >= runs[run_index].end as usize { + run_index += 1; + } + let run = runs.get(run_index); + StyledChar { + ch, + style: run.map_or(text.default_style, |run| run.style), + run_index, + } + }) + .collect() +} + +fn chars_width(chars: &[StyledChar]) -> f32 { + chars + .iter() + .map(|character| char_width(character.style.font_size)) + .sum() +} + +fn width_fits(candidate: f32, max_width: f32) -> bool { + if candidate <= max_width { + return true; + } + if !max_width.is_finite() || max_width < 0.0 { + return false; + } + candidate <= f32::from_bits(max_width.to_bits() + 1) +} + +/// Greedy ASCII-space wrapping for one explicit line. A word is indivisible; +/// separator spaces are retained while its following word fits and discarded +/// only when that word starts a new visual line. +fn wrap_styled_line(chars: &[StyledChar], max_width: Option) -> Vec> { + let Some(max_width) = max_width else { + return vec![chars.to_vec()]; + }; + let mut lines = Vec::new(); + let mut current = Vec::new(); + let mut current_width = 0.0; + let mut cursor = 0usize; + + while cursor < chars.len() { + let spaces_start = cursor; + while cursor < chars.len() && chars[cursor].ch == ' ' { + cursor += 1; + } + let pending_spaces = &chars[spaces_start..cursor]; + if cursor == chars.len() { + current.extend_from_slice(pending_spaces); + break; + } + + let word_start = cursor; + while cursor < chars.len() && chars[cursor].ch != ' ' { + cursor += 1; + } + let word = &chars[word_start..cursor]; + let candidate_width = current_width + chars_width(pending_spaces) + chars_width(word); + if !current.is_empty() && !width_fits(candidate_width, max_width) { + lines.push(std::mem::take(&mut current)); + current.extend_from_slice(word); + current_width = chars_width(word); + } else { + current.extend_from_slice(pending_spaces); + current.extend_from_slice(word); + current_width = candidate_width; + } + } + + lines.push(current); + lines +} + +fn materialize_styled_line( + chars: Vec, + default_style: TextStyleRec, + top: f32, +) -> TextLineLayout { + let font_size = chars + .iter() + .map(|character| character.style.font_size) + .reduce(f32::max) + .unwrap_or(default_style.font_size); + let height = font_size * LINE_H; + let baseline_y = top + font_size * 0.85; + let mut fragments: Vec = Vec::new(); + let mut line_text = String::new(); + let mut x = 0.0; + + for character in chars { + line_text.push(character.ch); + let advance = char_width(character.style.font_size); + if let Some(fragment) = fragments.last_mut().filter(|fragment| { + fragment.run_index == Some(character.run_index) && fragment.style == character.style + }) { + fragment.text.push(character.ch); + fragment.advance += advance; + } else { + fragments.push(TextFragmentLayout { + text: character.ch.to_string(), + x, + advance, + style: character.style, + run_index: Some(character.run_index), + }); + } + x += advance; + } + + TextLineLayout { + text: line_text, + fragments, + width: x, + height, + baseline_y, + } +} + +/// Materialize visual lines for either uniform or attributed text. +/// +/// The deterministic metric deliberately makes weight and italic style +/// advance-neutral. Font size alone controls character advance and line +/// height, which keeps the model's layout hand-computable while preserving +/// style in each fragment for the paint tier. +pub fn layout_text_payload( + text: TextPayloadRef<'_>, + max_width: Option, +) -> Vec { + if text.runs.is_none() { + let font_size = text.default_style.font_size; + let mut top = 0.0; + return layout_text_lines(text.text, font_size, max_width) + .into_iter() + .map(|line| { + let advance = line.chars().count() as f32 * char_width(font_size); + let height = font_size * LINE_H; + let baseline_y = top + font_size * 0.85; + top += height; + let fragments = if line.is_empty() { + Vec::new() + } else { + vec![TextFragmentLayout { + text: line.clone(), + x: 0.0, + advance, + style: text.default_style, + run_index: None, + }] + }; + TextLineLayout { + text: line, + fragments, + width: advance, + height, + baseline_y, + } + }) + .collect(); + } + + let mut explicit_lines: Vec> = vec![Vec::new()]; + for character in attributed_chars(text) { + if character.ch == '\n' { + explicit_lines.push(Vec::new()); + } else { + explicit_lines + .last_mut() + .expect("seeded explicit line") + .push(character); + } + } + + let mut top = 0.0; + let mut lines = Vec::new(); + for explicit_line in explicit_lines { + for visual_line in wrap_styled_line(&explicit_line, max_width) { + let line = materialize_styled_line(visual_line, text.default_style, top); + top += line.height; + lines.push(line); + } + } + lines +} + +/// Measure either uniform or attributed text using the exact visual-line +/// topology later copied into the drawlist. +pub fn measure_text_payload(text: TextPayloadRef<'_>, max_width: Option) -> (f32, f32) { + if text.runs.is_none() { + return measure_text(text.text, text.default_style.font_size, max_width); + } + let lines = layout_text_payload(text, max_width); + let width = lines.iter().map(|line| line.width).fold(0.0, f32::max); + let height = lines.iter().map(|line| line.height).sum(); + (width, height) +} + #[cfg(test)] mod tests { use super::*; + use crate::model::{AttributedString, StyledTextRun}; #[test] fn single_line_auto() { @@ -202,4 +443,92 @@ mod tests { "the immediately smaller authored width still floors" ); } + + #[test] + fn attributed_runs_use_size_for_metrics_and_preserve_style_boundaries() { + let small = TextStyleRec { + font_size: 10.0, + font_weight: 400, + font_style_italic: false, + }; + let large = TextStyleRec { + font_size: 20.0, + font_weight: 700, + font_style_italic: true, + }; + let attributed = AttributedString::from_runs( + "AA bb", + vec![ + StyledTextRun { + start: 0, + end: 3, + style: small, + fills: None, + }, + StyledTextRun { + start: 3, + end: 5, + style: large, + fills: None, + }, + ], + ) + .unwrap(); + let text = TextPayloadRef { + text: &attributed.text, + default_style: small, + runs: Some(&attributed.runs), + }; + + let lines = layout_text_payload(text, Some(30.0)); + assert_eq!( + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + ["AA", "bb"] + ); + assert_eq!(lines[0].fragments[0].run_index, Some(0)); + assert_eq!(lines[1].fragments[0].run_index, Some(1)); + assert_eq!(lines[0].height, 12.0); + assert_eq!(lines[1].height, 24.0); + assert_eq!(measure_text_payload(text, Some(30.0)), (24.0, 36.0)); + } + + #[test] + fn attributed_weight_and_italic_are_metric_neutral() { + let plain = TextStyleRec::from_font_size(12.0); + let styled = TextStyleRec { + font_size: 12.0, + font_weight: 900, + font_style_italic: true, + }; + let attributed = AttributedString::from_runs( + "ab", + vec![ + StyledTextRun { + start: 0, + end: 1, + style: plain, + fills: None, + }, + StyledTextRun { + start: 1, + end: 2, + style: styled, + fills: None, + }, + ], + ) + .unwrap(); + let text = TextPayloadRef { + text: &attributed.text, + default_style: plain, + runs: Some(&attributed.runs), + }; + let line = &layout_text_payload(text, None)[0]; + assert_eq!(line.fragments.len(), 2); + assert_eq!(line.fragments[0].advance, line.fragments[1].advance); + assert_eq!(line.width, 2.0 * char_width(12.0)); + } } diff --git a/model-v2/a/lab/src/model.rs b/model-v2/a/lab/src/model.rs index 3107af15b0..b8b7876afd 100644 --- a/model-v2/a/lab/src/model.rs +++ b/model-v2/a/lab/src/model.rs @@ -7,8 +7,9 @@ //! Lab simplifications, all declared: //! - node ids are `u32`; children are an ordered `Vec` on the parent //! (fractional-index ordering is not under test here); -//! - `SurfaceStyle` carries ordered fills and ordered stroke applications; -//! corners and effects remain outside the lab subset. +//! - surface style carries ordered fills and ordered stroke applications plus +//! production-shaped rectangular corner geometry; effects remain outside +//! the lab subset. use crate::math::Affine; use std::collections::BTreeMap; @@ -177,6 +178,85 @@ pub struct EdgeInsets { pub left: f32, } +/// One elliptical corner radius in local box coordinates. +/// +/// This deliberately mirrors the production Grida model: `rx` and `ry` stay +/// independent even though smooth corners currently require circular radii at +/// the renderer boundary. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Radius { + pub rx: f32, + pub ry: f32, +} + +impl Radius { + pub fn circular(value: f32) -> Self { + Self { + rx: value, + ry: value, + } + } + + pub fn is_zero(self) -> bool { + self.rx == 0.0 && self.ry == 0.0 + } + + pub fn is_circular(self) -> bool { + self.rx == self.ry + } +} + +/// Per-corner elliptical radii. Field names follow the production model; +/// serialization order is TL, TR, BR, BL. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct RectangularCornerRadius { + pub tl: Radius, + pub tr: Radius, + pub bl: Radius, + pub br: Radius, +} + +impl RectangularCornerRadius { + pub fn all(radius: Radius) -> Self { + Self { + tl: radius, + tr: radius, + bl: radius, + br: radius, + } + } + + pub fn circular(value: f32) -> Self { + Self::all(Radius::circular(value)) + } + + pub fn is_zero(self) -> bool { + self.tl.is_zero() && self.tr.is_zero() && self.bl.is_zero() && self.br.is_zero() + } + + pub fn is_circular(self) -> bool { + self.tl.is_circular() + && self.tr.is_circular() + && self.bl.is_circular() + && self.br.is_circular() + } +} + +/// Grida's normalized continuous-corner control. The source boundary validates +/// the production range `[0, 1]`; the model remains a plain value container. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct CornerSmoothing(pub f32); + +impl CornerSmoothing { + pub fn value(self) -> f32 { + self.0 + } + + pub fn is_zero(self) -> bool { + self.0 == 0.0 + } +} + impl EdgeInsets { pub fn all(v: f32) -> Self { EdgeInsets { @@ -218,6 +298,168 @@ pub enum LensOp { Matrix { m: [f32; 6] }, } +/// Production-shaped subset of Grida's complete text style record. +/// +/// The lab intentionally carries only the fields its deterministic text +/// metric and Draft 0 XML boundary currently understand. Adding a field here +/// commits both the source grammar and the resolver to preserving it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TextStyleRec { + pub font_size: f32, + pub font_weight: u32, + pub font_style_italic: bool, +} + +impl TextStyleRec { + pub const DEFAULT_FONT_SIZE: f32 = 16.0; + pub const DEFAULT_FONT_WEIGHT: u32 = 400; + + pub fn from_font_size(font_size: f32) -> Self { + Self { + font_size, + ..Self::default() + } + } +} + +impl Default for TextStyleRec { + fn default() -> Self { + Self { + font_size: Self::DEFAULT_FONT_SIZE, + font_weight: Self::DEFAULT_FONT_WEIGHT, + font_style_italic: false, + } + } +} + +/// One contiguous attributed-text run. +/// +/// Offsets are UTF-8 byte offsets into [`AttributedString::text`], matching +/// the production Rust and FlatBuffers contract. `fills: None` inherits the +/// text node's paint stack; `Some(Paints::default())` is an explicit empty +/// override and therefore remains distinct. +#[derive(Debug, Clone, PartialEq)] +pub struct StyledTextRun { + pub start: u32, + pub end: u32, + pub style: TextStyleRec, + pub fills: Option, +} + +/// Backing text plus a flat, complete partition into styled ranges. +#[derive(Debug, Clone, PartialEq)] +pub struct AttributedString { + pub text: String, + pub runs: Vec, +} + +impl AttributedString { + /// Construct uniform attributed text. Empty text carries production's + /// single `0..0` sentinel run so its default style remains representable. + pub fn new(text: impl Into, style: TextStyleRec) -> Self { + let text = text.into(); + let end = u32::try_from(text.len()).expect("attributed text exceeds u32 byte offsets"); + Self { + text, + runs: vec![StyledTextRun { + start: 0, + end, + style, + fills: None, + }], + } + } + + /// Validate and construct an attributed string from pre-built runs. + pub fn from_runs(text: impl Into, runs: Vec) -> Result { + let value = Self { + text: text.into(), + runs, + }; + value.validate()?; + Ok(value) + } + + /// Runs are a contiguous, non-overlapping UTF-8 partition of the text. + pub fn validate(&self) -> Result<(), String> { + let text_len = u32::try_from(self.text.len()) + .map_err(|_| "attributed text exceeds u32 byte offsets".to_string())?; + if self.runs.is_empty() { + return Err("attributed text requires at least one styled run".into()); + } + if self.runs[0].start != 0 { + return Err("the first styled run must start at byte offset 0".into()); + } + if self.runs.last().expect("checked nonempty").end != text_len { + return Err("the final styled run must end at the text byte length".into()); + } + for (index, run) in self.runs.iter().enumerate() { + let empty_sentinel = + self.text.is_empty() && self.runs.len() == 1 && run.start == 0 && run.end == 0; + if run.start >= run.end && !empty_sentinel { + return Err(format!( + "styled run {index} must have a non-empty byte range" + )); + } + if !self.text.is_char_boundary(run.start as usize) + || !self.text.is_char_boundary(run.end as usize) + { + return Err(format!( + "styled run {index} does not end on UTF-8 character boundaries" + )); + } + if let Some(next) = self.runs.get(index + 1) { + if run.end != next.start { + return Err(format!( + "styled runs {index} and {} are not contiguous", + index + 1 + )); + } + } + } + Ok(()) + } + + pub fn run_text(&self, run: &StyledTextRun) -> &str { + &self.text[run.start as usize..run.end as usize] + } + + /// Remove boundaries that have no model-level difference. Explicit fill + /// inheritance and explicit empty/painted overrides remain distinct. + pub fn merge_adjacent_runs(&mut self) { + if self.runs.len() < 2 { + return; + } + let mut merged = Vec::with_capacity(self.runs.len()); + merged.push(self.runs[0].clone()); + for run in &self.runs[1..] { + let previous = merged.last_mut().expect("seeded above"); + if previous.style == run.style && previous.fills == run.fills { + previous.end = run.end; + } else { + merged.push(run.clone()); + } + } + self.runs = merged; + } + + pub fn is_uniform_default(&self, default_style: TextStyleRec) -> bool { + self.runs + .iter() + .all(|run| run.style == default_style && run.fills.is_none()) + } +} + +/// Borrowed text view shared by the uniform and attributed payload variants. +#[derive(Debug, Clone, Copy)] +pub struct TextPayloadRef<'a> { + pub text: &'a str, + pub default_style: TextStyleRec, + /// `None` denotes the uniform text payload; attributed text always carries + /// its validated, complete run list. + pub runs: Option<&'a [StyledTextRun]>, +} + #[derive(Debug, Clone, PartialEq)] pub enum Payload { Frame { @@ -231,6 +473,10 @@ pub enum Payload { content: String, font_size: f32, }, + AttributedText { + attributed_string: AttributedString, + default_style: TextStyleRec, + }, Group, Lens { ops: Vec, @@ -257,11 +503,30 @@ impl Payload { match self { Payload::Frame { .. } => "frame", Payload::Shape { .. } => "shape", - Payload::Text { .. } => "text", + Payload::Text { .. } | Payload::AttributedText { .. } => "text", Payload::Group => "group", Payload::Lens { .. } => "lens", } } + + pub fn as_text(&self) -> Option> { + match self { + Payload::Text { content, font_size } => Some(TextPayloadRef { + text: content, + default_style: TextStyleRec::from_font_size(*font_size), + runs: None, + }), + Payload::AttributedText { + attributed_string, + default_style, + } => Some(TextPayloadRef { + text: &attributed_string.text, + default_style: *default_style, + runs: Some(&attributed_string.runs), + }), + _ => None, + } + } } /// A packed RGBA8 color, `0xAARRGGBB` — the numeric paint value. Browsers @@ -816,6 +1081,113 @@ impl StrokeJoin { } } +/// Concrete top/right/bottom/left stroke widths for rectangular outlines. +/// +/// The field shape mirrors Grida's production `RectangularStrokeWidth`; all +/// values are resolved logical pixels rather than optional source overrides. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RectangularStrokeWidth { + pub stroke_top_width: f32, + pub stroke_right_width: f32, + pub stroke_bottom_width: f32, + pub stroke_left_width: f32, +} + +impl RectangularStrokeWidth { + pub fn all(width: f32) -> Self { + Self { + stroke_top_width: width, + stroke_right_width: width, + stroke_bottom_width: width, + stroke_left_width: width, + } + } + + pub fn is_uniform(self) -> bool { + self.stroke_top_width == self.stroke_right_width + && self.stroke_right_width == self.stroke_bottom_width + && self.stroke_bottom_width == self.stroke_left_width + } + + pub fn is_none(self) -> bool { + self.stroke_top_width == 0.0 + && self.stroke_right_width == 0.0 + && self.stroke_bottom_width == 0.0 + && self.stroke_left_width == 0.0 + } + + pub fn max(self) -> f32 { + self.stroke_top_width + .max(self.stroke_right_width) + .max(self.stroke_bottom_width) + .max(self.stroke_left_width) + } + + pub fn values(self) -> [f32; 4] { + [ + self.stroke_top_width, + self.stroke_right_width, + self.stroke_bottom_width, + self.stroke_left_width, + ] + } +} + +/// Resolved stroke width geometry, projected from Grida's production model. +/// `Rectangular` is valid only for rectangular box outlines; source readers +/// normalize equal sides back to the corresponding scalar form. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum StrokeWidth { + #[default] + None, + Uniform(f32), + Rectangular(RectangularStrokeWidth), +} + +impl StrokeWidth { + pub fn normalized(self) -> Self { + match self { + StrokeWidth::None => StrokeWidth::None, + StrokeWidth::Uniform(0.0) => StrokeWidth::None, + StrokeWidth::Uniform(width) => StrokeWidth::Uniform(width), + StrokeWidth::Rectangular(widths) if widths.is_uniform() => { + StrokeWidth::Uniform(widths.stroke_top_width).normalized() + } + StrokeWidth::Rectangular(widths) => StrokeWidth::Rectangular(widths), + } + } + + pub fn is_none(self) -> bool { + match self { + StrokeWidth::None => true, + StrokeWidth::Uniform(width) => width == 0.0, + StrokeWidth::Rectangular(widths) => widths.is_none(), + } + } + + pub fn max(self) -> f32 { + match self { + StrokeWidth::None => 0.0, + StrokeWidth::Uniform(width) => width, + StrokeWidth::Rectangular(widths) => widths.max(), + } + } + + pub fn rectangular(self) -> RectangularStrokeWidth { + match self { + StrokeWidth::None => RectangularStrokeWidth::all(0.0), + StrokeWidth::Uniform(width) => RectangularStrokeWidth::all(width), + StrokeWidth::Rectangular(widths) => widths, + } + } +} + +impl From for StrokeWidth { + fn from(width: f32) -> Self { + StrokeWidth::Uniform(width) + } +} + /// One independently covered stroke application. The paint stack remains the /// existing ordered [`Paints`] model: entry zero is bottommost within this /// geometry, and repeated `Stroke` values are themselves painted in list @@ -823,7 +1195,7 @@ impl StrokeJoin { #[derive(Debug, Clone, PartialEq)] pub struct Stroke { pub paints: Paints, - pub width: f32, + pub width: StrokeWidth, pub align: StrokeAlign, pub cap: StrokeCap, pub join: StrokeJoin, @@ -844,12 +1216,13 @@ impl Stroke { | Payload::Shape { desc: ShapeDesc::Rect | ShapeDesc::Ellipse, } - | Payload::Text { .. } => StrokeAlign::Inside, + | Payload::Text { .. } + | Payload::AttributedText { .. } => StrokeAlign::Inside, Payload::Group | Payload::Lens { .. } => return None, }; Some(Stroke { paints: Paints::default(), - width: 1.0, + width: StrokeWidth::Uniform(1.0), align, cap: StrokeCap::Butt, join: StrokeJoin::Miter, @@ -860,7 +1233,7 @@ impl Stroke { pub fn geometry_is_default_for(&self, payload: &Payload) -> bool { Self::default_for(payload).is_some_and(|default| { - self.width == default.width + self.width.normalized() == default.width.normalized() && self.align == default.align && self.cap == default.cap && self.join == default.join @@ -870,7 +1243,33 @@ impl Stroke { } pub fn visible(&self) -> bool { - self.width > 0.0 && self.paints.iter().any(Paint::visible) + self.width.max() > 0.0 && self.paints.iter().any(Paint::visible) + } + + /// Whether the current proving renderer can materialize this stroke + /// without dropping geometry state. The XML boundary rejects these + /// combinations; this second fence keeps programmatically-built documents + /// from expanding bounds for pixels the drawlist cannot emit. + pub fn renderable_for(&self, payload: &Payload, corner_smoothing: CornerSmoothing) -> bool { + if !self.visible() { + return false; + } + match self.width.normalized() { + StrokeWidth::Rectangular(_) => { + matches!( + payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect + } + ) && corner_smoothing.is_zero() + && self.cap == StrokeCap::Butt + && self.join == StrokeJoin::Miter + && self.miter_limit == 4.0 + } + StrokeWidth::None => false, + StrokeWidth::Uniform(_) => true, + } } } @@ -938,6 +1337,8 @@ pub struct Node { pub header: Header, pub payload: Payload, pub children: Vec, + pub corner_radius: RectangularCornerRadius, + pub corner_smoothing: CornerSmoothing, pub fills: Paints, pub strokes: Vec, } @@ -1162,6 +1563,8 @@ impl DocBuilder { clips_content: false, }, children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), fills: Paints::default(), strokes: vec![], }, @@ -1189,6 +1592,8 @@ impl DocBuilder { header, payload, children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), fills: Paints::default(), strokes: vec![], }, diff --git a/model-v2/a/lab/src/resolve.rs b/model-v2/a/lab/src/resolve.rs index f33c92f88e..3dcceb4dd2 100644 --- a/model-v2/a/lab/src/resolve.rs +++ b/model-v2/a/lab/src/resolve.rs @@ -13,7 +13,7 @@ //! between the two modes. use crate::math::{rotated_aabb_size, Affine, RectF}; -use crate::measure::measure_text; +use crate::measure::measure_text_payload; use crate::model::*; use taffy::prelude::{auto, length, AvailableSpace, TaffyTree}; use taffy::style::{ @@ -217,15 +217,13 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 let mut h = span_h; let text_auto_height = h.is_none() && matches!(node.header.height, SizeIntent::Auto) - && matches!(&node.payload, Payload::Text { .. }); + && node.payload.as_text().is_some(); if w.is_none() { w = intent_extent_x(id, cx); } if h.is_none() && !text_auto_height { - // A span-resolved width IS a wrap constraint (census finding: the - // canonical Span{0,0} fill must re-wrap like Fixed/stretched widths). - h = intent_extent_y(id, span_w, cx); + h = intent_extent_y(id, cx); } // aspect_ratio resolves an under-specified axis only (G-5). @@ -261,7 +259,7 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 ); if text_auto_height { let node = cx.doc.get(id); - let Payload::Text { content, font_size } = &node.payload else { + let Some(text) = node.payload.as_text() else { unreachable!("text_auto_height is set only for text") }; let wrap_width = if span_w.is_some() @@ -275,7 +273,7 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 // floating-point floor can otherwise invent a wrap. None }; - h = Some(measure_text(content, *font_size, wrap_width).1); + h = Some(measure_text_payload(text, wrap_width).1); } let mut hv = h.unwrap_or_else(|| { @@ -367,29 +365,22 @@ fn intent_extent_x(id: NodeId, cx: &mut Ctx) -> Option { match node.header.width { SizeIntent::Fixed(v) => Some(v), SizeIntent::Auto => match &node.payload { - Payload::Text { content, font_size } => Some(measure_text(content, *font_size, None).0), + payload if payload.as_text().is_some() => { + Some(measure_text_payload(payload.as_text().expect("matched text"), None).0) + } Payload::Frame { .. } => Some(hug_size(id, cx).0), _ => None, }, } } -/// Natural height per kind; text height depends on the resolved width -/// (Fixed width or a Span-resolved width ⇒ wrap constraint; Auto ⇒ -/// unconstrained single line). The census found the Span arm missing — -/// Span{0,0} is the canonical free-context fill and must re-wrap. -fn intent_extent_y(id: NodeId, span_w: Option, cx: &mut Ctx) -> Option { +/// Natural height per kind. Auto-height text is deliberately absent here: it +/// remeasures after final width constraints in [`extent_of`]. +fn intent_extent_y(id: NodeId, cx: &mut Ctx) -> Option { let node = cx.doc.get(id); match node.header.height { SizeIntent::Fixed(v) => Some(v), SizeIntent::Auto => match &node.payload { - Payload::Text { content, font_size } => { - let constraint = span_w.or(match node.header.width { - SizeIntent::Fixed(w) => Some(w), - SizeIntent::Auto => None, - }); - Some(measure_text(content, *font_size, constraint).1) - } Payload::Frame { .. } => Some(hug_size(id, cx).1), _ => None, }, @@ -750,7 +741,7 @@ fn commit(id: NodeId, box_rect: RectF, cx: &mut Ctx) { Payload::Frame { layout, .. } => ChildProgram::Frame(*layout), Payload::Shape { .. } => ChildProgram::Free, Payload::Group | Payload::Lens { .. } => ChildProgram::Derived, - Payload::Text { .. } => ChildProgram::Leaf, + Payload::Text { .. } | Payload::AttributedText { .. } => ChildProgram::Leaf, }; match child_program { ChildProgram::Frame(layout) => { @@ -886,10 +877,7 @@ fn report_flow_fields_inert(id: NodeId, cx: &mut Ctx) { // Flex via taffy (per-container run) // --------------------------------------------------------------------------- -struct TextCtx { - content: String, - font_size: f32, -} +struct TextCtx(Payload); struct FlexOut { container: (f32, f32), @@ -971,7 +959,7 @@ fn flex_layout( style.max_size.height = length(v); } - let is_text = matches!(child.payload, Payload::Text { .. }); + let is_text = child.payload.as_text().is_some(); let basis: (f32, f32); let taffy_child = if contribute_aabb { // Fixed AABB contribution computed from resolved size only (§5). @@ -984,10 +972,7 @@ fn flex_layout( } else if is_text { // Measured kind: taffy drives re-measure at layout-imposed // extents (L-5) through the measure closure. - let (content, font_size) = match &child.payload { - Payload::Text { content, font_size } => (content.clone(), *font_size), - _ => unreachable!(), - }; + let text_payload = child.payload.clone(); match child.header.width { SizeIntent::Fixed(v) => style.size.width = length(v), SizeIntent::Auto => style.size.width = auto(), @@ -1006,7 +991,7 @@ fn flex_layout( } } basis = (0.0, 0.0); // unused for θ=0 slots - tree.new_leaf_with_context(style, TextCtx { content, font_size }) + tree.new_leaf_with_context(style, TextCtx(text_payload)) .unwrap() } else { let b = extent_of(*child_id, None, cx); @@ -1134,7 +1119,10 @@ fn flex_layout( AvailableSpace::Definite(w) => Some(w), _ => None, }); - let (w, h) = measure_text(&t.content, t.font_size, constraint); + let (w, h) = measure_text_payload( + t.0.as_text().expect("text measure context carries text"), + constraint, + ); taffy::geometry::Size { width: known.width.unwrap_or(w), height: known.height.unwrap_or(h), @@ -1192,38 +1180,61 @@ fn compose_world(id: NodeId, parent_world: Affine, cx: &mut Ctx) { } } +#[derive(Debug, Clone, Copy, Default)] +struct StrokeOutsets { + top: f32, + right: f32, + bottom: f32, + left: f32, +} + +impl StrokeOutsets { + fn include(&mut self, other: StrokeOutsets) { + self.top = self.top.max(other.top); + self.right = self.right.max(other.right); + self.bottom = self.bottom.max(other.bottom); + self.left = self.left.max(other.left); + } +} + /// Maximum outward coverage contributed by authored strokes in node-local /// coordinates. Layout remains based on the source box; only visual bounds -/// read this expansion. Open line strokes are centered by definition, so their -/// cap/join details are conservatively covered by half the width on every side. -fn effective_stroke_outset(node: &Node) -> f32 { +/// read this expansion. Repeated stroke geometries overlap independently, so +/// each edge takes their maximum outward reach rather than summing widths. +fn effective_stroke_outsets(node: &Node) -> StrokeOutsets { let is_line = matches!( &node.payload, Payload::Shape { desc: ShapeDesc::Line } ); - node.strokes + let mut outsets = StrokeOutsets::default(); + for stroke in node + .strokes .iter() - .filter(|stroke| stroke.visible()) - .map(|stroke| { - if is_line { - stroke.width / 2.0 - } else { - let base = match stroke.align { - StrokeAlign::Inside => 0.0, - StrokeAlign::Center => stroke.width / 2.0, - StrokeAlign::Outside => stroke.width, - }; - if matches!(node.payload, Payload::Text { .. }) && stroke.join == StrokeJoin::Miter - { - base * stroke.miter_limit - } else { - base - } + .filter(|stroke| stroke.renderable_for(&node.payload, node.corner_smoothing)) + { + let widths = stroke.width.rectangular(); + let mut factor = if is_line { + 0.5 + } else { + match stroke.align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => 0.5, + StrokeAlign::Outside => 1.0, } - }) - .fold(0.0, f32::max) + }; + if node.payload.as_text().is_some() && stroke.join == StrokeJoin::Miter { + factor *= stroke.miter_limit; + } + outsets.include(StrokeOutsets { + top: widths.stroke_top_width * factor, + right: widths.stroke_right_width * factor, + bottom: widths.stroke_bottom_width * factor, + left: widths.stroke_left_width * factor, + }); + } + outsets } fn intersect_aabbs(a: RectF, b: RectF) -> Option { @@ -1248,12 +1259,12 @@ fn compute_world_aabb(id: NodeId, cx: &mut Ctx) -> Option { cx.out.world[id as usize]?; // hidden subtree let world = cx.out.world[id as usize].unwrap(); let own_box = cx.out.box_in_parent[id as usize].unwrap(); - let stroke_outset = effective_stroke_outset(node); + let stroke_outsets = effective_stroke_outsets(node); let local_rect = RectF { - x: -stroke_outset, - y: -stroke_outset, - w: own_box.w + stroke_outset * 2.0, - h: own_box.h + stroke_outset * 2.0, + x: -stroke_outsets.left, + y: -stroke_outsets.top, + w: own_box.w + stroke_outsets.left + stroke_outsets.right, + h: own_box.h + stroke_outsets.top + stroke_outsets.bottom, }; let mut aabb = match node.payload { diff --git a/model-v2/a/lab/src/svgout.rs b/model-v2/a/lab/src/svgout.rs index 42967fbb38..a9f76c1002 100644 --- a/model-v2/a/lab/src/svgout.rs +++ b/model-v2/a/lab/src/svgout.rs @@ -64,28 +64,36 @@ fn svg_fill(node: &Node) -> Result { node.id ))); } - match node.fills.as_slice() { + svg_fill_paints(&node.fills, &format!("node {}", node.id)) +} + +fn svg_fill_paints(paints: &Paints, owner: &str) -> Result { + match paints.as_slice() { [] => Ok(SvgFill::None), [Paint::Solid(solid)] if solid.blend_mode == BlendMode::Normal => Ok(SvgFill::Solid { color: solid.color, active: solid.active, }), [Paint::Solid(_)] => Err(SvgError(format!( - "node {} uses a solid blend mode the SVG snapshot subset cannot represent", - node.id + "{owner} uses a solid blend mode the SVG snapshot subset cannot represent" ))), [_] => Err(SvgError(format!( - "node {} uses a rich paint the SVG snapshot subset cannot represent", - node.id + "{owner} uses a rich paint the SVG snapshot subset cannot represent" ))), paints => Err(SvgError(format!( - "node {} has {} paints; the SVG snapshot subset supports at most one solid", - node.id, + "{owner} has {} paints; the SVG snapshot subset supports at most one solid", paints.len() ))), } } +fn escape_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + pub fn render(doc: &Document, resolved: &Resolved, opts: &SvgOptions) -> Result { let mut out = String::new(); let _ = writeln!( @@ -127,6 +135,12 @@ fn paint( }; let world = &world; let b = resolved.box_of(id); + if !node.corner_radius.is_zero() || !node.corner_smoothing.is_zero() { + return Err(SvgError(format!( + "node {} uses corner geometry the SVG snapshot subset cannot represent losslessly", + node.id + ))); + } if node.header.opacity != 1.0 { return Err(SvgError(format!( "node {} uses subtree opacity the SVG snapshot subset cannot represent", @@ -204,12 +218,61 @@ fn paint( mat(world), font_size, fill, - content - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") + escape_text(content) ); } + Payload::AttributedText { + attributed_string, + default_style, + } => { + attributed_string + .validate() + .map_err(|error| SvgError(format!("node {}: {error}", node.id)))?; + let fill = fill.attributes("fill"); + let italic = if default_style.font_style_italic { + r#" font-style="italic""# + } else { + "" + }; + let _ = write!( + out, + r#" "#, + default_style.font_size, + mat(world), + default_style.font_size, + default_style.font_weight, + italic, + fill, + ); + for (index, run) in attributed_string.runs.iter().enumerate() { + let run_fill = run + .fills + .as_ref() + .map(|fills| svg_fill_paints(fills, &format!("node {} run {index}", node.id))) + .transpose()?; + let run_text = escape_text(attributed_string.run_text(run)); + let needs_tspan = run.style != *default_style || run_fill.is_some(); + if !needs_tspan { + let _ = write!(out, "{run_text}"); + continue; + } + let _ = write!( + out, + r#"{run_text}"); + } + let _ = writeln!(out, ""); + } Payload::Group | Payload::Lens { .. } => {} } diff --git a/model-v2/a/lab/src/textir.rs b/model-v2/a/lab/src/textir.rs index 23d7c098bb..6e50610449 100644 --- a/model-v2/a/lab/src/textir.rs +++ b/model-v2/a/lab/src/textir.rs @@ -103,6 +103,59 @@ fn parse_non_negative(s: &str, what: &str, strict: bool) -> Result Result<[f32; 4], ParseError> { + let values: Vec = s + .split_whitespace() + .map(|part| parse_non_negative(part, what, true)) + .collect::>()?; + match values.as_slice() { + [all] => Ok([*all; 4]), + [tl, tr, br, bl] => Ok([*tl, *tr, *br, *bl]), + _ => err(format!( + "{what} takes 1 or 4 numbers per axis in TL TR BR BL order" + )), + } +} + +fn parse_corner_radius(s: &str) -> Result { + if s.matches('/').count() > 1 { + return err("corner-radius takes at most one `/`"); + } + let (rx_source, ry_source) = match s.split_once('/') { + Some((rx, ry)) if rx.trim().is_empty() || ry.trim().is_empty() => { + return err("corner-radius needs radii on both sides of `/`"); + } + Some((rx, ry)) => (rx, Some(ry)), + None => (s, None), + }; + let rx = parse_corner_axis(rx_source, "corner-radius")?; + let ry = match ry_source { + Some(source) => parse_corner_axis(source, "corner-radius after `/`")?, + None => rx, + }; + Ok(RectangularCornerRadius { + tl: Radius { + rx: rx[0], + ry: ry[0], + }, + tr: Radius { + rx: rx[1], + ry: ry[1], + }, + br: Radius { + rx: rx[2], + ry: ry[2], + }, + bl: Radius { + rx: rx[3], + ry: ry[3], + }, + }) +} + fn parse_positive(s: &str, what: &str, strict: bool) -> Result { let value = parse_num(s, what)?; if strict && value <= 0.0 { @@ -579,6 +632,31 @@ fn is_legacy_gradient_tag(tag: &str) -> bool { ) } +fn parse_paint_element( + reader: &mut Reader<&[u8]>, + el: &BytesStart<'_>, + is_empty: bool, + channel_tag: &str, +) -> Result { + let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); + let attributes = collect_attributes(el)?; + match (tag.as_str(), is_empty) { + ("solid", true) => parse_solid(attributes), + ("image", true) => parse_image_paint(attributes), + ("gradient", false) => { + let stops = parse_gradient_stops(reader, "gradient")?; + parse_gradient(attributes, stops) + } + ("gradient", true) => err(" requires at least two stops"), + ("solid" | "image", false) => err(format!("<{tag}> must be empty; use <{tag} .../>")), + ("fill" | "stroke", _) => err(format!("nested <{tag}> is not allowed")), + _ if is_legacy_gradient_tag(&tag) => { + err("kind-specific gradient tags are not Draft 0; use ") + } + _ => err(format!("unknown paint element <{tag}> in <{channel_tag}>")), + } +} + fn parse_paint_channel( reader: &mut Reader<&[u8]>, channel_tag: &str, @@ -589,48 +667,10 @@ fn parse_paint_channel( Err(error) => return err(format!("xml in <{channel_tag}>: {error}")), Ok(Event::Eof) => return err(format!("unclosed <{channel_tag}>")), Ok(Event::Empty(el)) => { - let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); - let attributes = collect_attributes(&el)?; - let paint = match tag.as_str() { - "solid" => parse_solid(attributes)?, - "image" => parse_image_paint(attributes)?, - "gradient" => { - return err(" requires at least two stops"); - } - "fill" | "stroke" => { - return err(format!("nested <{tag}> is not allowed")); - } - _ if is_legacy_gradient_tag(&tag) => { - return err("kind-specific gradient tags are not Draft 0; use "); - } - _ => { - return err(format!("unknown paint element <{tag}> in <{channel_tag}>")); - } - }; - paints.push(paint); + paints.push(parse_paint_element(reader, &el, true, channel_tag)?); } Ok(Event::Start(el)) => { - let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); - let attributes = collect_attributes(&el)?; - let paint = match tag.as_str() { - "gradient" => { - let stops = parse_gradient_stops(reader, "gradient")?; - parse_gradient(attributes, stops)? - } - "solid" | "image" => { - return err(format!("<{tag}> must be empty; use <{tag} .../>")); - } - "fill" | "stroke" => { - return err(format!("nested <{tag}> is not allowed")); - } - _ if is_legacy_gradient_tag(&tag) => { - return err("kind-specific gradient tags are not Draft 0; use "); - } - _ => { - return err(format!("unknown paint element <{tag}> in <{channel_tag}>")); - } - }; - paints.push(paint); + paints.push(parse_paint_element(reader, &el, false, channel_tag)?); } Ok(Event::End(el)) => { let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); @@ -657,12 +697,25 @@ fn parse_paint_channel( } } +fn supports_fill(payload: &Payload) -> bool { + matches!( + payload, + Payload::Frame { .. } | Payload::Text { .. } | Payload::AttributedText { .. } + ) || matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse + } + ) +} + fn grida_xml_default_fills(payload: &Payload) -> Paints { match payload { Payload::Shape { desc: ShapeDesc::Rect | ShapeDesc::Ellipse, } - | Payload::Text { .. } => Paints::solid(Color::BLACK), + | Payload::Text { .. } + | Payload::AttributedText { .. } => Paints::solid(Color::BLACK), Payload::Frame { .. } | Payload::Shape { desc: ShapeDesc::Line, @@ -723,9 +776,49 @@ fn parse_dash_array(value: &str) -> Result, ParseError> { Ok(values) } +fn parse_stroke_width(value: &str, payload: &Payload) -> Result { + let parts: Vec<&str> = value.split_whitespace().collect(); + match parts.as_slice() { + [width] => { + Ok(StrokeWidth::Uniform(parse_non_negative(width, "stroke width", true)?).normalized()) + } + [top, right, bottom, left] => { + let supports_per_side = matches!(payload, Payload::Frame { .. }) + || matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Rect + } + ); + if !supports_per_side { + return err(format!( + "four-value stroke width is valid only on and , not <{}>", + payload.kind_name() + )); + } + let top = parse_non_negative(top, "stroke width top", true)?; + let right = parse_non_negative(right, "stroke width right", true)?; + let bottom = parse_non_negative(bottom, "stroke width bottom", true)?; + let left = parse_non_negative(left, "stroke width left", true)?; + Ok(StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: top, + stroke_right_width: right, + stroke_bottom_width: bottom, + stroke_left_width: left, + }) + .normalized()) + } + _ => err(format!( + "stroke width takes 1 or exactly 4 numbers in top right bottom left order; got {}", + parts.len() + )), + } +} + fn parse_stroke( mut attributes: BTreeMap, payload: &Payload, + corner_smoothing: CornerSmoothing, paints: Paints, ) -> Result { let mut stroke = Stroke::default_for(payload).ok_or_else(|| { @@ -737,7 +830,7 @@ fn parse_stroke( stroke.paints = paints; if let Some(value) = attributes.remove("width") { - stroke.width = parse_non_negative(&value, "stroke width", true)?; + stroke.width = parse_stroke_width(&value, payload)?; } if let Some(value) = attributes.remove("align") { stroke.align = parse_stroke_align(&value)?; @@ -791,16 +884,192 @@ fn parse_stroke( if is_line && stroke.align != StrokeAlign::Center { return err("a stroke must use align=\"center\""); } + if matches!(stroke.width, StrokeWidth::Rectangular(_)) { + if !corner_smoothing.is_zero() { + return err("per-side stroke width requires corner-smoothing=\"0\" in Draft 0"); + } + let default = Stroke::default_for(payload).expect("validated stroke target"); + if stroke.join != default.join || stroke.miter_limit != default.miter_limit { + return err( + "per-side stroke width requires the default join=\"miter\" and miter-limit=\"4\" in Draft 0", + ); + } + } reject_unknown_attribute(&attributes, "stroke")?; Ok(stroke) } +fn parse_font_weight(value: &str, tag: &str) -> Result { + let weight: u32 = value.trim().parse().map_err(|_| { + ParseError(format!( + "font-weight on <{tag}> must be an integer from 1 through 1000" + )) + })?; + if !(1..=1000).contains(&weight) { + return err(format!( + "font-weight on <{tag}> must be an integer from 1 through 1000" + )); + } + Ok(weight) +} + +fn parse_font_style(value: &str, tag: &str) -> Result { + match value { + "normal" => Ok(false), + "italic" => Ok(true), + _ => err(format!( + "font-style on <{tag}> must be `normal` or `italic`" + )), + } +} + +fn is_html_inline_semantic_tag(tag: &str) -> bool { + matches!( + tag, + "strong" | "em" | "b" | "i" | "code" | "mark" | "a" | "small" | "sub" | "sup" | "br" + ) +} + +fn html_inline_semantic_error(tag: &str) -> Result { + err(format!( + "HTML inline <{tag}> has no lossless attributed-text destination; use explicit styling{}", + if tag == "br" { " and a newline character" } else { "" } + )) +} + +#[derive(Debug, Clone, PartialEq)] +struct PendingTextSegment { + text: String, + style: TextStyleRec, + fills: Option, +} + +/// Parse one complete inline run. A tspan is source structure only: it does +/// not enter the scene-node stack and lowers directly to a flat byte range. +fn parse_tspan( + reader: &mut Reader<&[u8]>, + el: &BytesStart<'_>, + inherited_style: TextStyleRec, +) -> Result { + let mut attributes = collect_attributes(el)?; + let mut style = inherited_style; + if let Some(value) = attributes.remove("font-size") { + style.font_size = parse_positive(&value, "font-size on ", true)?; + } + if let Some(value) = attributes.remove("font-weight") { + style.font_weight = parse_font_weight(&value, "tspan")?; + } + if let Some(value) = attributes.remove("font-style") { + style.font_style_italic = parse_font_style(&value, "tspan")?; + } + let mut fills = attributes + .remove("fill") + .map(|value| parse_color(&value, "fill on ").map(Paints::solid)) + .transpose()?; + + if let Some(attribute) = ["x", "y", "dx", "dy", "rotate"] + .into_iter() + .find(|name| attributes.contains_key(*name)) + { + return err(format!( + "SVG positioning attribute `{attribute}` is not valid on ; Grida text runs carry style, not independent geometry" + )); + } + reject_unknown_attribute(&attributes, "tspan")?; + + let mut text = String::new(); + let mut fill_child_seen = false; + let mut event_before_fill = false; + loop { + match reader.read_event() { + Err(error) => return err(format!("xml in : {error}")), + Ok(Event::Eof) => return err("unclosed "), + Ok(Event::Text(value)) => { + let value = value + .unescape() + .map_err(|error| ParseError(format!("text in : {error}")))?; + // Mixed text is exact. Even whitespace-only XML character + // data is content, so a structural fill must precede it. + event_before_fill = true; + text.push_str(&value); + } + Ok(event @ (Event::Start(_) | Event::Empty(_))) => { + let is_empty = matches!(event, Event::Empty(_)); + let child = match event { + Event::Start(child) | Event::Empty(child) => child, + _ => unreachable!(), + }; + let child_tag = String::from_utf8_lossy(child.name().as_ref()).to_string(); + match child_tag.as_str() { + "fill" => { + if event_before_fill { + return err( + " inside must be the first child/event and precede all character content", + ); + } + if fill_child_seen { + return err("duplicate inside "); + } + if fills.is_some() { + return err( + " cannot use both the `fill` attribute and ", + ); + } + let fill_attributes = collect_attributes(&child)?; + reject_unknown_attribute(&fill_attributes, "fill")?; + fills = Some(if is_empty { + Paints::default() + } else { + parse_paint_channel(reader, "fill")? + }); + fill_child_seen = true; + } + "tspan" => return err("nested is not allowed; text runs are flat"), + "span" => return err(" is not canonical Grida XML; use "), + "stroke" => { + return err( + " inside is not supported until run stroke geometry matches the production model", + ) + } + tag if is_html_inline_semantic_tag(tag) => { + return html_inline_semantic_error(tag) + } + _ => { + return err(format!( + "<{child_tag}> is not allowed inside ; only a leading property and character data are valid" + )) + } + } + } + Ok(Event::End(end)) => { + let tag = String::from_utf8_lossy(end.name().as_ref()).to_string(); + if tag != "tspan" { + return err(format!("mismatched end tag in ")); + } + if text.is_empty() { + return err(" must contain at least one character"); + } + return Ok(PendingTextSegment { text, style, fills }); + } + Ok(Event::Comment(_)) => { + if !fill_child_seen { + event_before_fill = true; + } + } + Ok(Event::CData(_) | Event::Decl(_) | Event::PI(_) | Event::DocType(_)) => { + return err("unsupported XML event in ") + } + } + } +} + struct Pending { id: NodeId, tag: String, text_content: String, + text_segments: Vec, + default_text_style: Option, is_text: bool, - allows_children: bool, fill_seen: bool, stroke_seen: bool, legacy_fill_seen: bool, @@ -808,27 +1077,28 @@ struct Pending { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Syntax { +enum Dialect { TextIr, GridaXml, } pub fn parse(input: &str) -> Result { - parse_with_syntax(input, Syntax::TextIr) + parse_with_dialect(input, Dialect::TextIr) } /// Shared parser core for the first-class `.grida.xml` surface. Kept /// crate-private so the historical [`parse`] contract remains exactly the /// experiment grammar while `grida_xml` owns its public error vocabulary. pub(crate) fn parse_grida_xml(input: &str) -> Result { - parse_with_syntax(input, Syntax::GridaXml) + parse_with_dialect(input, Dialect::GridaXml) } -fn parse_with_syntax(input: &str, syntax: Syntax) -> Result { +fn parse_with_dialect(input: &str, dialect: Dialect) -> Result { let mut reader = Reader::from_str(input); + let grida_xml = dialect == Dialect::GridaXml; // Draft 0 preserves authored whitespace inside . Historical TextIr // keeps its experiment-era trimming behavior for compatibility. - reader.config_mut().trim_text(syntax == Syntax::TextIr); + reader.config_mut().trim_text(!grida_xml); let mut nodes: BTreeMap = BTreeMap::new(); let mut stack: Vec = vec![]; @@ -840,7 +1110,7 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result Result must contain exactly one render root"); } @@ -894,10 +1164,51 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result must be inside ")); } - if syntax == Syntax::GridaXml && matches!(tag.as_str(), "fill" | "stroke") { + if grida_xml && tag == "tspan" { + let parent = stack.last_mut().ok_or_else(|| { + ParseError(" must be a direct child of ".into()) + })?; + if !parent.is_text { + return err(format!( + " must be a direct child of , not <{}>", + parent.tag + )); + } + if is_empty { + return err(" must contain at least one character"); + } + let inherited_style = parent + .default_text_style + .expect("text pending state carries its default style"); + if !parent.text_content.is_empty() { + parent.text_segments.push(PendingTextSegment { + text: std::mem::take(&mut parent.text_content), + style: inherited_style, + fills: None, + }); + } + parent.content_started = true; + let segment = parse_tspan(&mut reader, &el, inherited_style)?; + stack + .last_mut() + .expect("parent remains open") + .text_segments + .push(segment); + continue; + } + if grida_xml + && stack.last().is_some_and(|parent| parent.is_text) + && is_html_inline_semantic_tag(&tag) + { + return html_inline_semantic_error(&tag); + } + if grida_xml && tag == "span" && stack.last().is_some_and(|parent| parent.is_text) { + return err(" is not canonical Grida XML; use "); + } + if grida_xml && matches!(tag.as_str(), "fill" | "stroke") { let attributes = collect_attributes(&el)?; let parent = stack.last().ok_or_else(|| { ParseError(format!( @@ -912,7 +1223,9 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result Result is not valid on <{parent_tag}>")); } reject_unknown_attribute(&attributes, "fill")?; @@ -969,7 +1275,8 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result Result is not Draft 0; use singular <{}>", if tag == "fills" { "fill" } else { "stroke" } )); } - if syntax == Syntax::GridaXml && is_legacy_gradient_tag(&tag) { + if grida_xml && is_legacy_gradient_tag(&tag) { return err( "kind-specific gradient tags are not Draft 0; use ", ); } - if syntax == Syntax::GridaXml && is_typed_paint_tag(&tag) { + if grida_xml && is_typed_paint_tag(&tag) { if tag == "image" { return err( "scene is not supported in Draft 0; place directly inside or for an image paint", @@ -1004,23 +1311,28 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result is a paint and must be a direct child of or " )); } - if syntax == Syntax::GridaXml && tag == "stop" { + if grida_xml && tag == "stop" { return err(" must be a direct child of a gradient paint"); } - if syntax == Syntax::GridaXml && tag == "frame" { + if grida_xml && tag == "frame" { return err(" belongs to historical textir; use "); } - if syntax == Syntax::GridaXml && tag == "shape" { + if grida_xml && tag == "shape" { return err(" is reserved in Draft 0; use , , or "); } - let is_authored_root = - syntax == Syntax::GridaXml && stack.is_empty() && render_root.is_none(); + let is_authored_root = grida_xml && stack.is_empty() && render_root.is_none(); if is_authored_root && tag != "container" { return err(format!( "the authored render root must be , found <{tag}>" )); } - if stack.last().is_some_and(|parent| !parent.allows_children) { + if stack.last().is_some_and(|parent| { + !nodes + .get(&parent.id) + .expect("open parent exists") + .payload + .accepts_children() + }) { let parent = stack.last().expect("checked above"); return err(format!( "<{}> cannot contain child elements; use for local composition", @@ -1031,19 +1343,23 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result ("frame", None), - (Syntax::GridaXml, "rect") => ("shape", Some(ShapeDesc::Rect)), - (Syntax::GridaXml, "ellipse") => ("shape", Some(ShapeDesc::Ellipse)), - (Syntax::GridaXml, "line") => ("shape", Some(ShapeDesc::Line)), + let (node_tag, direct_shape_kind) = match (dialect, tag.as_str()) { + (Dialect::GridaXml, "container") => ("frame", None), + (Dialect::GridaXml, "rect") => ("shape", Some(ShapeDesc::Rect)), + (Dialect::GridaXml, "ellipse") => ("shape", Some(ShapeDesc::Ellipse)), + (Dialect::GridaXml, "line") => ("shape", Some(ShapeDesc::Line)), _ => (tag.as_str(), None), }; let mut header = Header::new(SizeIntent::Auto, SizeIntent::Auto); let mut layout = LayoutBehavior::default(); let mut shape_kind: Option = direct_shape_kind; let mut font_size = 16.0f32; + let mut font_weight = TextStyleRec::DEFAULT_FONT_WEIGHT; + let mut font_style_italic = false; let mut lens_ops: Vec = vec![]; let mut clips = false; + let mut corner_radius = RectangularCornerRadius::default(); + let mut corner_smoothing = CornerSmoothing::default(); let mut legacy_fill: Option = None; let mut width_seen = false; let mut height_seen = false; @@ -1054,12 +1370,14 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result = None; let mut flex_only_attr: Option = None; let mut shape_only_attr: Option = None; let mut text_only_attr: Option = None; let mut lens_only_attr: Option = None; - let strict = syntax == Syntax::GridaXml; + let strict = grida_xml; for attr in el.attributes() { let attr = attr.map_err(|e| ParseError(format!("attr: {e}")))?; @@ -1084,70 +1402,70 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result { + "w" if !grida_xml => { if width_seen { return err(format!("duplicate width attribute on <{tag}>")); } width_seen = true; header.width = parse_size(&val, "width", false)?; } - "width" if syntax == Syntax::GridaXml => { + "width" if grida_xml => { if width_seen { return err(format!("duplicate width attribute on <{tag}>")); } width_seen = true; header.width = parse_size(&val, "width", true)?; } - "h" if syntax == Syntax::TextIr => { + "h" if !grida_xml => { if height_seen { return err(format!("duplicate height attribute on <{tag}>")); } height_seen = true; header.height = parse_size(&val, "height", false)?; } - "height" if syntax == Syntax::GridaXml => { + "height" if grida_xml => { if height_seen { return err(format!("duplicate height attribute on <{tag}>")); } height_seen = true; header.height = parse_size(&val, "height", true)?; } - "min-w" if syntax == Syntax::TextIr => { + "min-w" if !grida_xml => { header.min_width = Some(parse_non_negative(&val, "min-w", false)?) } - "min-width" if syntax == Syntax::GridaXml => { + "min-width" if grida_xml => { box_constraint_seen = true; header.min_width = Some(parse_non_negative(&val, "min-width", true)?) } - "max-w" if syntax == Syntax::TextIr => { + "max-w" if !grida_xml => { header.max_width = Some(parse_non_negative(&val, "max-w", false)?) } - "max-width" if syntax == Syntax::GridaXml => { + "max-width" if grida_xml => { box_constraint_seen = true; header.max_width = Some(parse_non_negative(&val, "max-width", true)?) } - "min-h" if syntax == Syntax::TextIr => { + "min-h" if !grida_xml => { header.min_height = Some(parse_non_negative(&val, "min-h", false)?) } - "min-height" if syntax == Syntax::GridaXml => { + "min-height" if grida_xml => { box_constraint_seen = true; header.min_height = Some(parse_non_negative(&val, "min-height", true)?) } - "max-h" if syntax == Syntax::TextIr => { + "max-h" if !grida_xml => { header.max_height = Some(parse_non_negative(&val, "max-h", false)?) } - "max-height" if syntax == Syntax::GridaXml => { + "max-height" if grida_xml => { box_constraint_seen = true; header.max_height = Some(parse_non_negative(&val, "max-height", true)?) } - "aspect" if syntax == Syntax::TextIr => { + "aspect" if !grida_xml => { let (a, b) = val .split_once(':') .ok_or_else(|| ParseError("aspect needs `w:h`".into()))?; header.aspect_ratio = Some((parse_num(a, "aspect")?, parse_num(b, "aspect")?)); } - "aspect-ratio" if syntax == Syntax::GridaXml => { + "aspect-ratio" if grida_xml => { box_constraint_seen = true; aspect_seen = true; let (a, b) = val @@ -1158,6 +1476,28 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result { + if corner_radius_seen { + return err(format!( + "duplicate corner-radius attribute on <{tag}>" + )); + } + corner_radius_seen = true; + corner_radius = parse_corner_radius(&val)?; + } + "corner-smoothing" if grida_xml => { + if corner_smoothing_seen { + return err(format!( + "duplicate corner-smoothing attribute on <{tag}>" + )); + } + corner_smoothing_seen = true; + let value = parse_num(&val, "corner-smoothing")?; + if !(0.0..=1.0).contains(&value) { + return err("corner-smoothing must be between 0 and 1 inclusive"); + } + corner_smoothing = CornerSmoothing(value); + } "rotation" => header.rotation = parse_num(&val, "rotation")?, "flip-x" => header.flip_x = parse_bool(&val, "flip-x", strict)?, "flip-y" => header.flip_y = parse_bool(&val, "flip-y", strict)?, @@ -1283,7 +1623,7 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result { + "kind" if !grida_xml => { shape_only_attr.get_or_insert_with(|| key.clone()); shape_kind = Some(match val.as_str() { "rect" => ShapeDesc::Rect, @@ -1292,9 +1632,24 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result return err(format!("bad shape kind `{val}`")), }) } - "size" => { + "size" if !grida_xml => { + text_only_attr.get_or_insert_with(|| key.clone()); + font_size = parse_positive(&val, "size", false)?; + } + "font-size" if grida_xml => { + text_only_attr.get_or_insert_with(|| key.clone()); + font_size = parse_positive(&val, "font-size", true)?; + } + "size" if grida_xml => return err( + "text attribute `size` belongs to historical textir; use `font-size`", + ), + "font-weight" if grida_xml => { text_only_attr.get_or_insert_with(|| key.clone()); - font_size = parse_positive(&val, "size", strict)?; + font_weight = parse_font_weight(&val, "text")?; + } + "font-style" if grida_xml => { + text_only_attr.get_or_insert_with(|| key.clone()); + font_style_italic = parse_font_style(&val, "text")?; } "ops" => { lens_only_attr.get_or_insert_with(|| key.clone()); @@ -1319,7 +1674,7 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result Result and "); } + let supports_corners = + node_tag == "frame" || matches!(shape_kind, Some(ShapeDesc::Rect)); + if (corner_radius_seen || corner_smoothing_seen) && !supports_corners { + return err(format!( + "corner-radius and corner-smoothing are only valid on and , not <{tag}>" + )); + } + if !corner_smoothing.is_zero() && !corner_radius.is_circular() { + return err( + "nonzero corner-smoothing requires circular corner radii (rx must equal ry) in Draft 0", + ); + } if matches!(header.x, AxisBinding::Span { .. }) && (width_seen || header.min_width.is_some() || header.max_width.is_some()) { @@ -1413,7 +1780,9 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result and require both axes supplied by a fixed size or Span, or exactly one supplied axis plus aspect-ratio"); + return err( + " and require both axes supplied by a fixed size or Span, or exactly one supplied axis plus aspect-ratio", + ); } } Some(ShapeDesc::Line) => { @@ -1474,7 +1843,7 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result Paints::solid(color), - None if syntax == Syntax::GridaXml => grida_xml_default_fills(&payload), + None if grida_xml => grida_xml_default_fills(&payload), None => Paints::default(), }; let node = Node { @@ -1482,6 +1851,8 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result Result"); } @@ -1503,13 +1874,17 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result Result { let txt = t.unescape().map_err(|e| ParseError(format!("text: {e}")))?; - if syntax == Syntax::GridaXml && !declaration_seen && !txt.is_empty() { + if grida_xml && !declaration_seen && !txt.is_empty() { pre_declaration_content = true; } if let Some(p) = stack.last_mut() { - if syntax == Syntax::GridaXml && !p.is_text && !txt.trim().is_empty() { + if grida_xml && !p.is_text && !txt.trim().is_empty() { return err(format!("character content is not allowed in <{}>", p.tag)); } if p.is_text && !txt.trim().is_empty() { p.content_started = true; } p.text_content.push_str(&txt); - } else if syntax == Syntax::GridaXml && !txt.trim().is_empty() { + } else if grida_xml && !txt.trim().is_empty() { return err("character content is not allowed outside the document envelope"); } } Ok(Event::End(e)) => { let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); - if syntax == Syntax::GridaXml && tag == "grida" { + if grida_xml && tag == "grida" { if !envelope_open || envelope_closed { return err("unbalanced "); } @@ -1555,7 +1930,7 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result { + Ok(Event::Decl(decl)) if grida_xml => { if declaration_seen { return err("duplicate XML declaration"); } @@ -1565,15 +1940,15 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result { + Ok(Event::Comment(_)) if grida_xml => { if !declaration_seen { pre_declaration_content = true; } } - Ok(Event::CData(_)) if syntax == Syntax::GridaXml => { + Ok(Event::CData(_)) if grida_xml => { return err("CDATA is not supported; use escaped text content"); } - Ok(Event::PI(_) | Event::DocType(_)) if syntax == Syntax::GridaXml => { + Ok(Event::PI(_) | Event::DocType(_)) if grida_xml => { return err("processing instructions and doctypes are not supported"); } Ok(_) => {} @@ -1583,7 +1958,7 @@ fn parse_with_syntax(input: &str, syntax: Syntax) -> Result envelope"); } @@ -1614,13 +1989,61 @@ fn finish( return err(format!("mismatched end tag for <{start_tag}>")); } } - let p = stack + let mut p = stack .pop() .ok_or_else(|| ParseError("unbalanced end".into()))?; if p.is_text { - if let Payload::Text { content, .. } = &mut nodes.get_mut(&p.id).unwrap().payload { - *content = p.text_content; + let default_style = p + .default_text_style + .expect("text pending state carries its default style"); + if !p.text_content.is_empty() { + p.text_segments.push(PendingTextSegment { + text: p.text_content, + style: default_style, + fills: None, + }); } + + let mut text = String::new(); + let mut runs = Vec::new(); + for segment in p.text_segments { + if segment.text.is_empty() { + continue; + } + let start = u32::try_from(text.len()) + .map_err(|_| ParseError("attributed text exceeds u32 byte offsets".into()))?; + text.push_str(&segment.text); + let end = u32::try_from(text.len()) + .map_err(|_| ParseError("attributed text exceeds u32 byte offsets".into()))?; + runs.push(StyledTextRun { + start, + end, + style: segment.style, + fills: segment.fills, + }); + } + let mut attributed = if text.is_empty() { + AttributedString::new(text, default_style) + } else { + AttributedString::from_runs(text, runs).map_err(ParseError)? + }; + attributed.merge_adjacent_runs(); + + let can_use_uniform_payload = default_style.font_weight + == TextStyleRec::DEFAULT_FONT_WEIGHT + && !default_style.font_style_italic + && attributed.is_uniform_default(default_style); + nodes.get_mut(&p.id).unwrap().payload = if can_use_uniform_payload { + Payload::Text { + content: attributed.text, + font_size: default_style.font_size, + } + } else { + Payload::AttributedText { + attributed_string: attributed, + default_style, + } + }; } Ok(()) } @@ -1637,6 +2060,25 @@ fn fmt_num(v: f32) -> String { } } +fn fmt_corner_axis(values: [f32; 4]) -> String { + if values[1..].iter().all(|value| *value == values[0]) { + fmt_num(values[0]) + } else { + values.map(fmt_num).join(" ") + } +} + +fn fmt_corner_radius(radius: RectangularCornerRadius) -> String { + let rx = [radius.tl.rx, radius.tr.rx, radius.br.rx, radius.bl.rx]; + let ry = [radius.tl.ry, radius.tr.ry, radius.br.ry, radius.bl.ry]; + let rx_text = fmt_corner_axis(rx); + if rx == ry { + rx_text + } else { + format!("{rx_text} / {}", fmt_corner_axis(ry)) + } +} + fn fmt_num_f64(v: f64) -> String { format!("{v}") } @@ -1680,10 +2122,45 @@ fn push_attr(out: &mut String, key: &str, val: &str) { let _ = write!(out, " {key}=\"{escaped}\""); } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Vocabulary { - TextIr, - GridaXml, +fn escape_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn validate_text_style_for_write(style: TextStyleRec, target: &str) -> Result<(), String> { + if !style.font_size.is_finite() || style.font_size <= 0.0 { + return Err(format!( + "font-size on <{target}> must be finite and greater than zero" + )); + } + if !(1..=1000).contains(&style.font_weight) { + return Err(format!( + "font-weight on <{target}> must be an integer from 1 through 1000" + )); + } + Ok(()) +} + +fn push_text_style_overrides(out: &mut String, style: TextStyleRec, inherited: TextStyleRec) { + if style.font_size != inherited.font_size { + push_attr(out, "font-size", &fmt_num(style.font_size)); + } + if style.font_weight != inherited.font_weight { + push_attr(out, "font-weight", &style.font_weight.to_string()); + } + if style.font_style_italic != inherited.font_style_italic { + push_attr( + out, + "font-style", + if style.font_style_italic { + "italic" + } else { + "normal" + }, + ); + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1702,7 +2179,7 @@ impl std::error::Error for PrintError {} /// it. New file-first documents should use [`crate::grida_xml::print`]. pub fn try_print(doc: &Document) -> Result { let mut out = String::new(); - print_node(doc, doc.root, 0, Vocabulary::TextIr, &mut out).map_err(PrintError)?; + print_node(doc, doc.root, 0, Dialect::TextIr, &mut out).map_err(PrintError)?; Ok(out) } @@ -1716,7 +2193,7 @@ pub(crate) fn print_grida_xml_render_root( depth: usize, out: &mut String, ) -> Result<(), String> { - print_node(doc, id, depth, Vocabulary::GridaXml, out) + print_node(doc, id, depth, Dialect::GridaXml, out) } #[derive(Debug, Clone, Copy)] @@ -1728,15 +2205,7 @@ enum FillEmission<'a> { } fn grida_xml_fill_emission(node: &Node) -> Result, String> { - if matches!( - node.payload, - Payload::Group - | Payload::Lens { .. } - | Payload::Shape { - desc: ShapeDesc::Line - } - ) && !node.fills.is_empty() - { + if !supports_fill(&node.payload) && !node.fills.is_empty() { return Err(format!("<{}> cannot carry fills", node.payload.kind_name())); } let default = grida_xml_default_fills(&node.payload); @@ -1755,6 +2224,20 @@ fn grida_xml_fill_emission(node: &Node) -> Result, String> { } } +fn grida_xml_run_fill_emission(fills: &Paints) -> FillEmission<'_> { + if let [Paint::Solid(solid)] = fills.as_slice() { + if solid.active && solid.color.alpha() == 255 && solid.blend_mode == BlendMode::Normal { + FillEmission::Attribute(solid.color) + } else { + FillEmission::Stack(fills) + } + } else if fills.is_empty() { + FillEmission::Empty + } else { + FillEmission::Stack(fills) + } +} + fn historical_fill(node: &Node) -> Result, String> { if node .strokes @@ -1782,6 +2265,65 @@ fn historical_fill(node: &Node) -> Result, String> { } } +fn validate_corner_style_for_write(node: &Node, grida_xml: bool) -> Result<(), String> { + let radii = [ + node.corner_radius.tl, + node.corner_radius.tr, + node.corner_radius.br, + node.corner_radius.bl, + ]; + if radii + .iter() + .any(|radius| !radius.rx.is_finite() || !radius.ry.is_finite()) + { + return Err(format!("node {} has non-finite corner radii", node.id)); + } + if radii + .iter() + .any(|radius| radius.rx < 0.0 || radius.ry < 0.0) + { + return Err(format!("node {} has negative corner radii", node.id)); + } + let smoothing = node.corner_smoothing.value(); + if !smoothing.is_finite() || !(0.0..=1.0).contains(&smoothing) { + return Err(format!( + "node {} corner-smoothing must be finite and between 0 and 1", + node.id + )); + } + + let has_corner_style = !node.corner_radius.is_zero() || !node.corner_smoothing.is_zero(); + if !has_corner_style { + return Ok(()); + } + if !grida_xml { + return Err(format!( + "node {} has corner geometry historical E3 TextIr cannot represent", + node.id + )); + } + let supports_corners = matches!(node.payload, Payload::Frame { .. }) + || matches!( + node.payload, + Payload::Shape { + desc: ShapeDesc::Rect + } + ); + if !supports_corners { + return Err(format!( + "<{}> cannot carry corner-radius or corner-smoothing", + node.payload.kind_name() + )); + } + if !node.corner_smoothing.is_zero() && !node.corner_radius.is_circular() { + return Err(format!( + "node {} uses nonzero corner-smoothing with elliptical radii; Draft 0 cannot render that state losslessly", + node.id + )); + } + Ok(()) +} + fn validate_common_paint(opacity: f32, tag: &str) -> Result<(), String> { if !opacity.is_finite() || !(0.0..=1.0).contains(&opacity) { return Err(format!( @@ -1893,6 +2435,94 @@ fn write_gradient_stops( Ok(()) } +fn write_gradient(paint: &Paint, depth: usize, out: &mut String) -> Result<(), String> { + let (kind, transform, stops, tile_mode, endpoints) = match paint { + Paint::LinearGradient(gradient) => ( + "linear", + gradient.transform, + gradient.stops.as_slice(), + Some(gradient.tile_mode), + Some((gradient.xy1, gradient.xy2)), + ), + Paint::RadialGradient(gradient) => ( + "radial", + gradient.transform, + gradient.stops.as_slice(), + Some(gradient.tile_mode), + None, + ), + Paint::SweepGradient(gradient) => ( + "sweep", + gradient.transform, + gradient.stops.as_slice(), + None, + None, + ), + Paint::DiamondGradient(gradient) => ( + "diamond", + gradient.transform, + gradient.stops.as_slice(), + None, + None, + ), + Paint::Solid(_) | Paint::Image(_) => unreachable!("gradient writer requires a gradient"), + }; + let endpoints = match endpoints { + Some((from, to)) => { + if !from.0.is_finite() || !from.1.is_finite() || !to.0.is_finite() || !to.1.is_finite() + { + return Err(" endpoints must be finite".into()); + } + if from == to { + return Err(" from and to must differ".into()); + } + Some(( + from.try_to_uv().ok_or_else(|| { + " from alignment is not representable: binary64 UV arithmetic cannot reproduce its stored f32 components".to_string() + })?, + to.try_to_uv().ok_or_else(|| { + " to alignment is not representable: binary64 UV arithmetic cannot reproduce its stored f32 components".to_string() + })?, + from != Alignment::CENTER_LEFT || to != Alignment::CENTER_RIGHT, + )) + } + None => None, + }; + + let indent = " ".repeat(depth); + let _ = write!(out, "{indent}"); + write_gradient_stops(stops, "gradient", depth + 1, out)?; + let _ = writeln!(out, "{indent}"); + Ok(()) +} + fn write_paint(paint: &Paint, depth: usize, out: &mut String) -> Result<(), String> { let indent = " ".repeat(depth); match paint { @@ -1908,99 +2538,11 @@ fn write_paint(paint: &Paint, depth: usize, out: &mut String) -> Result<(), Stri )?; let _ = writeln!(out, "/>"); } - Paint::LinearGradient(gradient) => { - if !gradient.xy1.0.is_finite() - || !gradient.xy1.1.is_finite() - || !gradient.xy2.0.is_finite() - || !gradient.xy2.1.is_finite() - { - return Err(" endpoints must be finite".into()); - } - if gradient.xy1 == gradient.xy2 { - return Err(" from and to must differ".into()); - } - let from = gradient.xy1.try_to_uv().ok_or_else(|| { - " from alignment is not representable: binary64 UV arithmetic cannot reproduce its stored f32 components".to_string() - })?; - let to = gradient.xy2.try_to_uv().ok_or_else(|| { - " to alignment is not representable: binary64 UV arithmetic cannot reproduce its stored f32 components".to_string() - })?; - let _ = write!(out, "{indent}"); - write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; - let _ = writeln!(out, "{indent}"); - } - Paint::RadialGradient(gradient) => { - let _ = write!(out, "{indent}"); - write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; - let _ = writeln!(out, "{indent}"); - } - Paint::SweepGradient(gradient) => { - let _ = write!(out, "{indent}"); - write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; - let _ = writeln!(out, "{indent}"); - } - Paint::DiamondGradient(gradient) => { - let _ = write!(out, "{indent}"); - write_gradient_stops(&gradient.stops, "gradient", depth + 1, out)?; - let _ = writeln!(out, "{indent}"); + paint @ (Paint::LinearGradient(_) + | Paint::RadialGradient(_) + | Paint::SweepGradient(_) + | Paint::DiamondGradient(_)) => { + write_gradient(paint, depth, out)?; } Paint::Image(image) => { let src = match &image.image { @@ -2076,6 +2618,44 @@ fn write_fill( Ok(()) } +fn write_attributed_text_content( + attributed: &AttributedString, + default_style: TextStyleRec, + depth: usize, + out: &mut String, +) -> Result<(), String> { + attributed.validate()?; + let mut attributed = attributed.clone(); + attributed.merge_adjacent_runs(); + if attributed.text.is_empty() && !attributed.is_uniform_default(default_style) { + return Err( + "empty attributed text cannot preserve a styled or painted zero-length run".into(), + ); + } + + for run in &attributed.runs { + validate_text_style_for_write(run.style, "tspan")?; + let run_text = escape_text(attributed.run_text(run)); + if run.style == default_style && run.fills.is_none() { + let _ = write!(out, "{run_text}"); + continue; + } + + let _ = write!(out, ""); + if let Some(emission @ (FillEmission::Empty | FillEmission::Stack(_))) = fill_emission { + write_fill(emission, depth + 1, true, out)?; + } + let _ = write!(out, "{run_text}"); + } + Ok(()) +} + fn normalized_dash_array(values: &[f32]) -> Vec { if values.len().is_multiple_of(2) { values.to_vec() @@ -2084,11 +2664,32 @@ fn normalized_dash_array(values: &[f32]) -> Vec { } } -fn validate_stroke_for_write(stroke: &Stroke, payload: &Payload) -> Result<(), String> { +pub(crate) fn validate_stroke_for_write( + stroke: &Stroke, + payload: &Payload, + corner_smoothing: CornerSmoothing, +) -> Result<(), String> { let default = Stroke::default_for(payload) .ok_or_else(|| format!("<{}> cannot carry strokes", payload.kind_name()))?; - if !stroke.width.is_finite() || stroke.width < 0.0 { - return Err(" width must be finite and non-negative".into()); + match stroke.width { + StrokeWidth::None => {} + StrokeWidth::Uniform(width) => { + if !width.is_finite() || width < 0.0 { + return Err(" width must be finite and non-negative".into()); + } + } + StrokeWidth::Rectangular(widths) => { + for (side, width) in ["top", "right", "bottom", "left"] + .into_iter() + .zip(widths.values()) + { + if !width.is_finite() || width < 0.0 { + return Err(format!( + " width {side} must be finite and non-negative" + )); + } + } + } } if !stroke.miter_limit.is_finite() || stroke.miter_limit <= 0.0 { return Err(" miter-limit must be finite and positive".into()); @@ -2138,7 +2739,7 @@ fn validate_stroke_for_write(stroke: &Stroke, payload: &Payload) -> Result<(), S ); } } - Payload::Text { .. } => { + Payload::Text { .. } | Payload::AttributedText { .. } => { if stroke.cap != default.cap || stroke.join != default.join || stroke.miter_limit != default.miter_limit @@ -2151,6 +2752,30 @@ fn validate_stroke_for_write(stroke: &Stroke, payload: &Payload) -> Result<(), S } Payload::Group | Payload::Lens { .. } => unreachable!("checked above"), } + if matches!(stroke.width.normalized(), StrokeWidth::Rectangular(_)) { + let supports_per_side = matches!(payload, Payload::Frame { .. }) + || matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Rect + } + ); + if !supports_per_side { + return Err(format!( + "<{}> cannot carry per-side stroke width", + payload.kind_name() + )); + } + if !corner_smoothing.is_zero() { + return Err("per-side stroke width requires corner-smoothing=\"0\" in Draft 0".into()); + } + if stroke.join != default.join || stroke.miter_limit != default.miter_limit { + return Err( + "per-side stroke width requires the default join=\"miter\" and miter-limit=\"4\" in Draft 0" + .into(), + ); + } + } Ok(()) } @@ -2161,11 +2786,12 @@ fn stroke_is_omitted(stroke: &Stroke, payload: &Payload) -> bool { fn write_stroke( stroke: &Stroke, payload: &Payload, + corner_smoothing: CornerSmoothing, depth: usize, inline: bool, out: &mut String, ) -> Result<(), String> { - validate_stroke_for_write(stroke, payload)?; + validate_stroke_for_write(stroke, payload, corner_smoothing)?; if stroke_is_omitted(stroke, payload) { return Ok(()); } @@ -2174,8 +2800,14 @@ fn write_stroke( let _ = write!(out, "{}", " ".repeat(depth)); } let _ = write!(out, " fmt_num(0.0), + StrokeWidth::Uniform(width) => fmt_num(width), + StrokeWidth::Rectangular(widths) => widths.values().map(fmt_num).join(" "), + }; + push_attr(out, "width", &value); } if stroke.align != default.align { push_attr(out, "align", stroke.align.as_str()); @@ -2235,45 +2867,39 @@ fn print_node( doc: &Document, id: NodeId, depth: usize, - vocabulary: Vocabulary, + dialect: Dialect, out: &mut String, ) -> Result<(), String> { let node = doc.get(id); let indent = " ".repeat(depth); - let tag = match (vocabulary, &node.payload) { - (Vocabulary::GridaXml, Payload::Frame { .. }) => "container", + let grida_xml = dialect == Dialect::GridaXml; + let tag = match (dialect, &node.payload) { + (Dialect::GridaXml, Payload::Frame { .. }) => "container", ( - Vocabulary::GridaXml, + Dialect::GridaXml, Payload::Shape { desc: ShapeDesc::Rect, }, ) => "rect", ( - Vocabulary::GridaXml, + Dialect::GridaXml, Payload::Shape { desc: ShapeDesc::Ellipse, }, ) => "ellipse", ( - Vocabulary::GridaXml, + Dialect::GridaXml, Payload::Shape { desc: ShapeDesc::Line, }, ) => "line", _ => node.payload.kind_name(), }; - let width_attr = if vocabulary == Vocabulary::GridaXml { - "width" - } else { - "w" - }; - let height_attr = if vocabulary == Vocabulary::GridaXml { - "height" - } else { - "h" - }; + validate_corner_style_for_write(node, grida_xml)?; + let width_attr = if grida_xml { "width" } else { "w" }; + let height_attr = if grida_xml { "height" } else { "h" }; let (min_width_attr, max_width_attr, min_height_attr, max_height_attr, aspect_attr) = - if vocabulary == Vocabulary::GridaXml { + if grida_xml { ( "min-width", "max-width", @@ -2339,6 +2965,16 @@ fn print_node( if let Some((a, b)) = node.header.aspect_ratio { push_attr(out, aspect_attr, &format!("{}:{}", fmt_num(a), fmt_num(b))); } + if grida_xml && !node.corner_radius.is_zero() { + push_attr(out, "corner-radius", &fmt_corner_radius(node.corner_radius)); + } + if grida_xml && !node.corner_smoothing.is_zero() { + push_attr( + out, + "corner-smoothing", + &fmt_num(node.corner_smoothing.value()), + ); + } if node.header.rotation != 0.0 { push_attr(out, "rotation", &fmt_num(node.header.rotation)); } @@ -2430,7 +3066,7 @@ fn print_node( } } Payload::Shape { desc } => { - if vocabulary == Vocabulary::TextIr { + if !grida_xml { let kind = match desc { ShapeDesc::Rect => "rect", ShapeDesc::Ellipse => "ellipse", @@ -2440,9 +3076,30 @@ fn print_node( } } Payload::Text { font_size, .. } => { + if grida_xml { + validate_text_style_for_write(TextStyleRec::from_font_size(*font_size), "text")?; + } if *font_size != 16.0 { - push_attr(out, "size", &fmt_num(*font_size)); + push_attr( + out, + if grida_xml { "font-size" } else { "size" }, + &fmt_num(*font_size), + ); + } + } + Payload::AttributedText { + attributed_string, + default_style, + } => { + if !grida_xml { + return Err(format!( + "node {} has attributed text historical E3 TextIr cannot represent", + node.id + )); } + validate_text_style_for_write(*default_style, "text")?; + attributed_string.validate()?; + push_text_style_overrides(out, *default_style, TextStyleRec::default()); } Payload::Group => {} Payload::Lens { ops } => { @@ -2483,23 +3140,23 @@ fn print_node( push_attr(out, "ops", &parts.join(" ")); } } - let fill_emission = match vocabulary { - Vocabulary::TextIr => { + let fill_emission = match dialect { + Dialect::TextIr => { if let Some(fill) = historical_fill(node)? { push_attr(out, "fill", &fill.to_hex()); } FillEmission::Omit } - Vocabulary::GridaXml => grida_xml_fill_emission(node)?, + Dialect::GridaXml => grida_xml_fill_emission(node)?, }; if let FillEmission::Attribute(color) = fill_emission { push_attr(out, "fill", &color.to_hex()); } - let strokes: Vec<&Stroke> = match vocabulary { - Vocabulary::TextIr => vec![], - Vocabulary::GridaXml => { + let strokes: Vec<&Stroke> = match dialect { + Dialect::TextIr => vec![], + Dialect::GridaXml => { for stroke in &node.strokes { - validate_stroke_for_write(stroke, &node.payload)?; + validate_stroke_for_write(stroke, &node.payload, node.corner_smoothing)?; } node.strokes .iter() @@ -2510,24 +3167,33 @@ fn print_node( let has_fill_child = matches!(fill_emission, FillEmission::Empty | FillEmission::Stack(_)); let has_properties = has_fill_child || !strokes.is_empty(); - let is_text = matches!(node.payload, Payload::Text { .. }); + let is_text = node.payload.as_text().is_some(); if is_text { - let content = match &node.payload { - Payload::Text { content, .. } => content.clone(), - _ => unreachable!(), - }; - let escaped = content - .replace('&', "&") - .replace('<', "<") - .replace('>', ">"); let _ = write!(out, ">"); if has_fill_child { write_fill(fill_emission, depth + 1, true, out)?; } for stroke in strokes { - write_stroke(stroke, &node.payload, depth + 1, true, out)?; + write_stroke( + stroke, + &node.payload, + node.corner_smoothing, + depth + 1, + true, + out, + )?; } - let _ = writeln!(out, "{escaped}"); + match &node.payload { + Payload::Text { content, .. } => { + let _ = write!(out, "{}", escape_text(content)); + } + Payload::AttributedText { + attributed_string, + default_style, + } => write_attributed_text_content(attributed_string, *default_style, depth, out)?, + _ => unreachable!(), + } + let _ = writeln!(out, ""); } else if node.children.is_empty() && !has_properties { let _ = writeln!(out, "/>"); } else { @@ -2536,10 +3202,17 @@ fn print_node( write_fill(fill_emission, depth + 1, false, out)?; } for stroke in strokes { - write_stroke(stroke, &node.payload, depth + 1, false, out)?; + write_stroke( + stroke, + &node.payload, + node.corner_smoothing, + depth + 1, + false, + out, + )?; } for c in &node.children { - print_node(doc, *c, depth + 1, vocabulary, out)?; + print_node(doc, *c, depth + 1, dialect, out)?; } let _ = writeln!(out, "{indent}"); } diff --git a/model-v2/a/lab/tests/draft0_resolution.rs b/model-v2/a/lab/tests/draft0_resolution.rs index 81afcf6f92..2c897f81e8 100644 --- a/model-v2/a/lab/tests/draft0_resolution.rs +++ b/model-v2/a/lab/tests/draft0_resolution.rs @@ -164,7 +164,7 @@ fn row_flex_self_stretch_preserves_the_lines_locked_zero_height() { header.self_align = SelfAlign::Stretch; let line = builder.add(container, header, payload.clone()); let mut stroke = Stroke::default_for(&payload).unwrap(); - stroke.width = 10.0; + stroke.width = StrokeWidth::Uniform(10.0); stroke.paints = Paints::solid(Color::BLACK); builder.node_mut(line).strokes.push(stroke); @@ -192,7 +192,7 @@ fn stroked_shape(desc: ShapeDesc, align: StrokeAlign, width: f32) -> (Document, let payload = Payload::Shape { desc }; let mut stroke = Stroke::default_for(&payload).expect("shape supports strokes"); stroke.paints = Paints::solid(Color::BLACK); - stroke.width = width; + stroke.width = StrokeWidth::Uniform(width); stroke.align = align; let shape = builder.add(0, header, payload); builder.node_mut(shape).strokes.push(stroke); @@ -229,6 +229,62 @@ fn effective_strokes_expand_visual_bounds_without_changing_layout_bounds() { } } +#[test] +fn per_side_strokes_expand_each_visual_bound_independently() { + let (mut doc, shape) = stroked_shape(ShapeDesc::Rect, StrokeAlign::Outside, 1.0); + doc.get_mut(shape).strokes[0].width = StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 2.0, + stroke_right_width: 4.0, + stroke_bottom_width: 6.0, + stroke_left_width: 8.0, + }); + + let resolved = run(&doc); + assert_rect( + resolved.box_of(shape), + 10.0, + 20.0, + 100.0, + 50.0, + "per-side stroke does not affect layout box", + ); + assert_rect( + resolved.aabb_of(shape), + 2.0, + 18.0, + 112.0, + 58.0, + "per-side stroke contributes asymmetric visual outsets", + ); +} + +#[test] +fn unsupported_rectangular_stroke_states_do_not_expand_visual_bounds() { + let cases = [ShapeDesc::Ellipse, ShapeDesc::Rect]; + for desc in cases { + let (mut doc, shape) = stroked_shape(desc, StrokeAlign::Outside, 1.0); + doc.get_mut(shape).strokes[0].width = StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 2.0, + stroke_right_width: 4.0, + stroke_bottom_width: 6.0, + stroke_left_width: 8.0, + }); + if desc == ShapeDesc::Rect { + doc.get_mut(shape).corner_smoothing = CornerSmoothing(0.5); + } + + let resolved = run(&doc); + assert_rect( + resolved.aabb_of(shape), + 10.0, + 20.0, + 100.0, + 50.0, + "unsupported rectangular stroke state has no visual coverage", + ); + } +} + #[test] fn stroke_expansion_is_transformed_with_the_node() { let (mut doc, shape) = stroked_shape(ShapeDesc::Rect, StrokeAlign::Outside, 10.0); @@ -279,7 +335,7 @@ fn text_miter_limit_is_included_in_conservative_visual_bounds() { let text = builder.add(0, header, payload.clone()); let mut stroke = Stroke::default_for(&payload).unwrap(); stroke.paints = Paints::solid(Color::BLACK); - stroke.width = 2.0; + stroke.width = StrokeWidth::Uniform(2.0); stroke.align = StrokeAlign::Center; builder.node_mut(text).strokes.push(stroke); @@ -310,7 +366,7 @@ fn ineffective_strokes_do_not_expand_visual_bounds() { ); doc.get_mut(shape).strokes[0].paints = Paints::solid(Color::BLACK); - doc.get_mut(shape).strokes[0].width = 0.0; + doc.get_mut(shape).strokes[0].width = StrokeWidth::None; let resolved = run(&doc); assert_rect( resolved.aabb_of(shape), diff --git a/model-v2/a/lab/tests/grida_xml_attributed_text.rs b/model-v2/a/lab/tests/grida_xml_attributed_text.rs new file mode 100644 index 0000000000..cc4857edd1 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_attributed_text.rs @@ -0,0 +1,326 @@ +//! Focused contract for the flat `` + direct-child `` surface. + +use anchor_lab::grida_xml; +use anchor_lab::model::*; + +fn parse_text(source: &str) -> (Document, NodeId) { + let source = format!("{source}"); + let doc = grida_xml::parse(&source).expect("attributed text source parses"); + let container = doc.get(doc.root).children[0]; + let text = doc.get(container).children[0]; + (doc, text) +} + +fn attributed(node: &Node) -> (&AttributedString, TextStyleRec) { + match &node.payload { + Payload::AttributedText { + attributed_string, + default_style, + } => (attributed_string, *default_style), + payload => panic!("expected attributed text, got {payload:?}"), + } +} + +#[test] +fn mixed_content_lowers_to_complete_utf8_byte_runs() { + let (doc, text_id) = parse_text( + r##" A🙂 +Z"##, + ); + let (value, default_style) = attributed(doc.get(text_id)); + + assert_eq!(value.text, " A🙂中\nZ"); + assert_eq!( + default_style, + TextStyleRec { + font_size: 18.0, + font_weight: 500, + font_style_italic: true, + } + ); + assert_eq!(value.runs.len(), 3); + assert_eq!((value.runs[0].start, value.runs[0].end), (0, 6)); + assert_eq!((value.runs[1].start, value.runs[1].end), (6, 9)); + assert_eq!((value.runs[2].start, value.runs[2].end), (9, 11)); + assert_eq!(value.run_text(&value.runs[0]), " A🙂"); + assert_eq!(value.run_text(&value.runs[1]), "中"); + assert_eq!(value.run_text(&value.runs[2]), "\nZ"); + assert_eq!(value.runs[0].style, default_style); + assert_eq!(value.runs[2].style, default_style); + assert_eq!( + value.runs[1].style, + TextStyleRec { + font_size: 24.0, + font_weight: 700, + font_style_italic: false, + } + ); + let fills = value.runs[1].fills.as_ref().expect("explicit run fill"); + let [Paint::Solid(solid)] = fills.as_slice() else { + panic!("solid shorthand must lower to one solid paint") + }; + assert_eq!(solid.color.to_hex(), "#FF0000"); + + let printed = grida_xml::print(&doc).expect("attributed text prints"); + assert!(printed.contains( + r##" A🙂 +Z"## + )); + let reparsed = grida_xml::parse(&printed).unwrap(); + assert_eq!(printed, grida_xml::print(&reparsed).unwrap()); +} + +#[test] +fn adjacent_equal_runs_coalesce_and_semantically_uniform_markup_disappears() { + let (doc, text_id) = parse_text( + r#"xaby"#, + ); + let (value, _) = attributed(doc.get(text_id)); + assert_eq!(value.text, "xaby"); + assert_eq!(value.runs.len(), 3); + assert_eq!(value.run_text(&value.runs[1]), "ab"); + + let printed = grida_xml::print(&doc).unwrap(); + assert_eq!(printed.matches("ab"#), + "{printed}" + ); + + let (uniform, uniform_id) = parse_text(r#"abc"#); + assert!(matches!( + &uniform.get(uniform_id).payload, + Payload::Text { content, font_size } + if content == "abc" && *font_size == TextStyleRec::DEFAULT_FONT_SIZE + )); + let uniform_printed = grida_xml::print(&uniform).unwrap(); + assert!(!uniform_printed.contains("ABCD"##, + ); + let (value, _) = attributed(doc.get(text_id)); + assert_eq!(value.text, "ABCD"); + assert_eq!(value.runs.len(), 4); + + let painted = value.runs[1].fills.as_ref().expect("paint override"); + assert_eq!(painted.len(), 3); + assert!(matches!(painted[0], Paint::Solid(_))); + assert!(matches!(painted[1], Paint::LinearGradient(_))); + let Paint::Image(image) = &painted[2] else { + panic!("third run paint must be the image resource") + }; + assert_eq!(image.image, ResourceRef::Rid("./texture.png".into())); + assert_eq!(image.fit, ImagePaintFit::Fit(BoxFit::Cover)); + assert_eq!(image.opacity, 0.25); + assert!(value.runs[2] + .fills + .as_ref() + .expect("explicit empty override") + .is_empty()); + assert!(value.runs[0].fills.is_none()); + assert!(value.runs[3].fills.is_none()); + + let printed = grida_xml::print(&doc).unwrap(); + assert!(printed.contains(""), "{printed}"); + assert!( + printed.contains(r#""#), + "{printed}" + ); + assert!(printed.contains("B"), "{printed}"); + assert!(printed.contains("C"), "{printed}"); + let reparsed = grida_xml::parse(&printed).unwrap(); + assert_eq!(printed, grida_xml::print(&reparsed).unwrap()); +} + +#[test] +fn canonical_writer_normalizes_programmatic_run_boundaries() { + let (mut doc, text_id) = parse_text("ab"); + let default_style = TextStyleRec::default(); + let bold = TextStyleRec { + font_weight: 700, + ..default_style + }; + let attributed_string = AttributedString::from_runs( + "ab", + vec![ + StyledTextRun { + start: 0, + end: 1, + style: bold, + fills: None, + }, + StyledTextRun { + start: 1, + end: 2, + style: bold, + fills: None, + }, + ], + ) + .unwrap(); + doc.get_mut(text_id).payload = Payload::AttributedText { + attributed_string, + default_style, + }; + + let printed = grida_xml::print(&doc).expect("writer compares normalized text semantics"); + assert_eq!(printed.matches("ab"#), + "{printed}" + ); + assert_eq!( + printed, + grida_xml::print(&grida_xml::parse(&printed).unwrap()).unwrap() + ); +} + +#[test] +fn attributed_text_rejects_lossy_or_ambiguous_inline_syntax() { + let cases = [ + ( + r#"x"#, + "nested ", + ), + (r#""#, "at least one character"), + (r#""#, "at least one character"), + ( + r#"x"#, + "no lossless attributed-text destination", + ), + ( + r#"
"#, + "use explicit styling and a newline character", + ), + (r#"x"#, "use "), + ( + r#"x"#, + "SVG positioning attribute `x`", + ), + ( + r#"x"#, + "SVG positioning attribute `dx`", + ), + ( + r#"x"#, + "integer from 1 through 1000", + ), + ( + r#"x"#, + "integer from 1 through 1000", + ), + ( + r#"x"#, + "integer from 1 through 1000", + ), + ( + r#"x"#, + "must be `normal` or `italic`", + ), + ( + r#"x"#, + "font-size on must be greater than zero", + ), + ( + r#"x"#, + "unknown attribute `style` on ", + ), + ( + r#"x"#, + "unknown attribute `width` on ", + ), + ( + r#" x"#, + "must be the first child/event", + ), + ( + r#"x"#, + "must be the first child/event", + ), + ( + r##"x"##, + "both the `fill` attribute and ", + ), + ( + r#"x"#, + "run stroke geometry", + ), + ( + r#"x"#, + "not allowed inside ", + ), + (r#"x"#, "use `font-size`"), + ]; + + for (text, expected) in cases { + let source = format!("{text}"); + let error = grida_xml::parse(&source).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected `{expected}` for {text}, got {error}" + ); + } + + let outside = + grida_xml::parse(r#"x"#) + .unwrap_err(); + assert!(outside.to_string().contains("direct child of ")); +} + +#[test] +fn attributed_string_validation_is_utf8_and_override_aware() { + let style = TextStyleRec::default(); + let bad_boundary = AttributedString::from_runs( + "🙂x", + vec![ + StyledTextRun { + start: 0, + end: 1, + style, + fills: None, + }, + StyledTextRun { + start: 1, + end: 5, + style, + fills: None, + }, + ], + ) + .unwrap_err(); + assert!(bad_boundary.contains("UTF-8 character boundaries")); + + let mut value = AttributedString::from_runs( + "abc", + vec![ + StyledTextRun { + start: 0, + end: 1, + style, + fills: None, + }, + StyledTextRun { + start: 1, + end: 2, + style, + fills: None, + }, + StyledTextRun { + start: 2, + end: 3, + style, + fills: Some(Paints::default()), + }, + ], + ) + .unwrap(); + value.merge_adjacent_runs(); + assert_eq!(value.runs.len(), 2); + assert_eq!((value.runs[0].start, value.runs[0].end), (0, 2)); + assert!(value.runs[0].fills.is_none()); + assert!(value.runs[1].fills.as_ref().unwrap().is_empty()); +} diff --git a/model-v2/a/lab/tests/grida_xml_corners.rs b/model-v2/a/lab/tests/grida_xml_corners.rs new file mode 100644 index 0000000000..aa1113a8e2 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_corners.rs @@ -0,0 +1,219 @@ +//! Draft 0 rounded-box syntax is a lossless projection of Grida's existing +//! per-corner elliptical radii plus normalized corner smoothing. + +use anchor_lab::grida_xml::{self, PrintError}; +use anchor_lab::model::*; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use anchor_lab::{svgout, textir}; + +fn canonical(source: &str) -> (Document, String) { + let doc = grida_xml::parse(source).expect("Draft 0 source parses"); + let printed = grida_xml::print(&doc).expect("parsed Draft 0 source prints"); + let reparsed = grida_xml::parse(&printed).expect("canonical source reparses"); + assert_eq!(doc, reparsed, "semantic round-trip\n---\n{printed}"); + assert_eq!( + printed, + grida_xml::print(&reparsed).expect("canonical source reprints"), + "writer fixpoint" + ); + (doc, printed) +} + +fn authored_root(doc: &Document) -> NodeId { + doc.get(doc.root).children[0] +} + +fn one_rect(attributes: &str) -> String { + format!( + "" + ) +} + +#[test] +fn per_corner_elliptical_radii_materialize_in_tl_tr_br_bl_order() { + let (doc, printed) = canonical(&one_rect(r#"corner-radius="1 2 3 4 / 5 6 7 8""#)); + let rect = doc.get(authored_root(&doc)).children[0]; + assert_eq!( + doc.get(rect).corner_radius, + RectangularCornerRadius { + tl: Radius { rx: 1.0, ry: 5.0 }, + tr: Radius { rx: 2.0, ry: 6.0 }, + br: Radius { rx: 3.0, ry: 7.0 }, + bl: Radius { rx: 4.0, ry: 8.0 }, + } + ); + assert!( + printed.contains(r#"corner-radius="1 2 3 4 / 5 6 7 8""#), + "{printed}" + ); +} + +#[test] +fn canonical_writer_compresses_only_the_defined_one_or_four_value_grammar() { + let (_, uniform) = canonical(&one_rect(r#"corner-radius="9 9 9 9""#)); + assert!(uniform.contains(r#"corner-radius="9""#), "{uniform}"); + + let (_, elliptical) = canonical(&one_rect(r#"corner-radius="5 5 5 5 / 6 6 6 6""#)); + assert!( + elliptical.contains(r#"corner-radius="5 / 6""#), + "{elliptical}" + ); + + let (_, circular) = canonical(&one_rect(r#"corner-radius="1 2 3 4 / 1 2 3 4""#)); + assert!( + circular.contains(r#"corner-radius="1 2 3 4""#), + "{circular}" + ); + assert!(!circular.contains(" / "), "{circular}"); +} + +#[test] +fn smoothing_is_normalized_and_may_be_dormant_with_zero_radii() { + let (doc, printed) = canonical(&one_rect(r#"corner-smoothing="0.6""#)); + let rect = doc.get(authored_root(&doc)).children[0]; + assert_eq!( + doc.get(rect).corner_radius, + RectangularCornerRadius::default() + ); + assert_eq!(doc.get(rect).corner_smoothing, CornerSmoothing(0.6)); + assert!(printed.contains(r#"corner-smoothing="0.6""#), "{printed}"); + + let (_, rounded) = canonical(&one_rect( + r#"corner-radius="12 18 24 30" corner-smoothing="1""#, + )); + assert!(rounded.contains(r#"corner-smoothing="1""#), "{rounded}"); +} + +#[test] +fn explicit_zero_corner_defaults_are_omitted() { + let (_, printed) = canonical(&one_rect( + r#"corner-radius="0 0 0 0 / 0" corner-smoothing="0""#, + )); + assert!(!printed.contains("corner-radius="), "{printed}"); + assert!(!printed.contains("corner-smoothing="), "{printed}"); +} + +#[test] +fn malformed_corner_values_have_focused_parse_errors() { + for (attributes, expected) in [ + (r#"corner-radius="""#, "takes 1 or 4 numbers"), + (r#"corner-radius="1 2""#, "takes 1 or 4 numbers"), + (r#"corner-radius="1 2 3""#, "takes 1 or 4 numbers"), + (r#"corner-radius="1 2 3 4 5""#, "takes 1 or 4 numbers"), + (r#"corner-radius="/ 2""#, "both sides"), + (r#"corner-radius="2 /""#, "both sides"), + (r#"corner-radius="1 / 2 / 3""#, "at most one"), + (r#"corner-radius="-1""#, "non-negative"), + (r#"corner-radius="NaN""#, "non-finite"), + (r#"corner-smoothing="-0.1""#, "between 0 and 1"), + (r#"corner-smoothing="1.1""#, "between 0 and 1"), + (r#"corner-smoothing="NaN""#, "non-finite"), + ] { + let error = grida_xml::parse(&one_rect(attributes)).unwrap_err(); + assert!( + error.to_string().contains(expected), + "{attributes}: expected `{expected}`, got `{error}`" + ); + } +} + +#[test] +fn corner_attributes_are_box_primitive_only() { + for node in [ + r#""#, + r#""#, + r#"x"#, + r#""#, + ] { + let source = format!("{node}"); + let error = grida_xml::parse(&source).unwrap_err(); + assert!( + error + .to_string() + .contains("only valid on and "), + "{node}: {error}" + ); + } + + canonical( + r#""#, + ); +} + +#[test] +fn smooth_elliptical_corners_are_rejected_instead_of_narrowed() { + let error = grida_xml::parse(&one_rect( + r#"corner-radius="12 / 8" corner-smoothing="0.5""#, + )) + .unwrap_err(); + assert!(error.to_string().contains("requires circular"), "{error}"); +} + +#[test] +fn writer_validates_programmatic_corner_states() { + let mut doc = grida_xml::parse(&one_rect("")).unwrap(); + let rect = doc.get(authored_root(&doc)).children[0]; + + doc.get_mut(rect).corner_radius = RectangularCornerRadius::circular(-1.0); + let error = grida_xml::print(&doc).unwrap_err(); + assert!( + error.to_string().contains("negative corner radii"), + "{error}" + ); + + doc.get_mut(rect).corner_radius = RectangularCornerRadius::default(); + doc.get_mut(rect).corner_smoothing = CornerSmoothing(f32::NAN); + let error = grida_xml::print(&doc).unwrap_err(); + assert!(error.to_string().contains("must be finite"), "{error}"); + + doc.get_mut(rect).corner_radius = RectangularCornerRadius::all(Radius { rx: 8.0, ry: 4.0 }); + doc.get_mut(rect).corner_smoothing = CornerSmoothing(0.4); + let error = grida_xml::print(&doc).unwrap_err(); + assert!(error.to_string().contains("cannot render"), "{error}"); + + let mut ellipse = grida_xml::parse( + r#""#, + ) + .unwrap(); + let ellipse_id = ellipse.get(authored_root(&ellipse)).children[0]; + ellipse.get_mut(ellipse_id).corner_radius = RectangularCornerRadius::circular(2.0); + let error = grida_xml::print(&ellipse).unwrap_err(); + assert!(error.to_string().contains("cannot carry"), "{error}"); +} + +#[test] +fn implicit_document_root_corner_state_is_part_of_canonicality() { + let mut doc = grida_xml::parse(r#""#).unwrap(); + doc.get_mut(doc.root).corner_radius = RectangularCornerRadius::circular(1.0); + assert_eq!( + grida_xml::print(&doc), + Err(PrintError::NonCanonicalDocumentRoot) + ); +} + +#[test] +fn historical_textir_and_svg_snapshot_export_refuse_corner_loss() { + let mut historical = + textir::parse(r#""#) + .unwrap(); + historical.get_mut(1).corner_radius = RectangularCornerRadius::circular(3.0); + let text_error = textir::try_print(&historical).unwrap_err(); + assert!( + text_error.to_string().contains("cannot represent"), + "{text_error}" + ); + + let doc = grida_xml::parse(&one_rect(r#"corner-radius="3""#)).unwrap(); + let resolved = resolve(&doc, &ResolveOptions::default()); + let svg_error = svgout::render( + &doc, + &resolved, + &svgout::SvgOptions { + show_aabb: false, + width: 100.0, + height: 100.0, + }, + ) + .unwrap_err(); + assert!(svg_error.to_string().contains("losslessly"), "{svg_error}"); +} diff --git a/model-v2/a/lab/tests/grida_xml_properties.rs b/model-v2/a/lab/tests/grida_xml_properties.rs index 23abcdc70d..54a3adf326 100644 --- a/model-v2/a/lab/tests/grida_xml_properties.rs +++ b/model-v2/a/lab/tests/grida_xml_properties.rs @@ -42,7 +42,7 @@ fn binding_forms_auto_sizes_and_common_visual_attributes_materialize() { - + @@ -139,7 +139,10 @@ fn binding_forms_auto_sizes_and_common_visual_attributes_materialize() { assert!(printed.contains("hidden=\"true\""), "{printed}"); assert!(!printed.contains("hidden=\"false\""), "{printed}"); assert!(!printed.contains("clips=\"false\""), "{printed}"); - assert!(printed.contains("x"), "{printed}"); + assert!( + printed.contains("x"), + "{printed}" + ); } #[test] diff --git a/model-v2/a/lab/tests/grida_xml_stroke_widths.rs b/model-v2/a/lab/tests/grida_xml_stroke_widths.rs new file mode 100644 index 0000000000..2bc94d418f --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_stroke_widths.rs @@ -0,0 +1,215 @@ +//! Draft 0 rectangular stroke-width grammar and model projection. + +use anchor_lab::grida_xml; +use anchor_lab::model::*; + +fn named(doc: &Document, name: &str) -> NodeId { + (0..doc.capacity() as NodeId) + .find(|id| { + doc.get_opt(*id) + .is_some_and(|node| node.header.name.as_deref() == Some(name)) + }) + .expect("named node") +} + +fn rect_document(node_attributes: &str, stroke_attributes: &str) -> String { + format!( + r##""## + ) +} + +#[test] +fn four_widths_project_to_the_production_shaped_model_and_round_trip() { + let source = rect_document("", r#"width="2 4 6 8" align="outside" dash-array="5 3""#); + let doc = grida_xml::parse(&source).unwrap(); + let stroke = &doc.get(named(&doc, "target")).strokes[0]; + assert_eq!( + stroke.width, + StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 2.0, + stroke_right_width: 4.0, + stroke_bottom_width: 6.0, + stroke_left_width: 8.0, + }) + ); + assert_eq!(stroke.dash_array.as_deref(), Some(&[5.0, 3.0][..])); + + let printed = grida_xml::print(&doc).unwrap(); + assert!(printed.contains(r#"width="2 4 6 8""#), "{printed}"); + assert_eq!( + grida_xml::print(&grida_xml::parse(&printed).unwrap()).unwrap(), + printed + ); +} + +#[test] +fn container_strokes_accept_the_same_four_side_geometry() { + let source = r##""##; + let doc = grida_xml::parse(source).unwrap(); + assert_eq!( + doc.get(named(&doc, "target")).strokes[0].width, + StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 3.0, + stroke_right_width: 5.0, + stroke_bottom_width: 7.0, + stroke_left_width: 9.0, + }) + ); + assert!(grida_xml::print(&doc) + .unwrap() + .contains(r#"width="3 5 7 9""#)); +} + +#[test] +fn equal_and_zero_side_lists_normalize_to_the_shortest_scalar_form() { + let equal = grida_xml::parse(&rect_document("", r#"width="7 7 7 7""#)).unwrap(); + let stroke = &equal.get(named(&equal, "target")).strokes[0]; + assert_eq!(stroke.width, StrokeWidth::Uniform(7.0)); + let printed = grida_xml::print(&equal).unwrap(); + assert!(printed.contains(r#"width="7""#), "{printed}"); + assert!(!printed.contains(r#"width="7 7 7 7""#), "{printed}"); + + let zero = grida_xml::parse(&rect_document("", r#"width="0 0 0 0""#)).unwrap(); + let stroke = &zero.get(named(&zero, "target")).strokes[0]; + assert_eq!(stroke.width, StrokeWidth::None); + let printed = grida_xml::print(&zero).unwrap(); + assert!(printed.contains(r#"width="0""#), "{printed}"); + + let default = grida_xml::parse(&rect_document("", r#"width="1 1 1 1""#)).unwrap(); + let printed = grida_xml::print(&default).unwrap(); + assert!(!printed.contains(""#; + let doc = grida_xml::parse(source).unwrap(); + let printed = grida_xml::print(&doc).unwrap(); + assert!( + printed.contains(r#""#), + "{printed}" + ); + assert_eq!(grida_xml::parse(&printed).unwrap(), doc); + + let default_alias = r#""#; + let error = grida_xml::parse(default_alias).unwrap_err(); + assert!( + error.0.contains("indistinguishable from omission"), + "{error}" + ); +} + +#[test] +fn writer_normalizes_equivalent_programmatic_width_variants() { + let mut doc = grida_xml::parse(&rect_document("", r#"width="3""#)).unwrap(); + let target = named(&doc, "target"); + doc.get_mut(target).strokes[0].width = + StrokeWidth::Rectangular(RectangularStrokeWidth::all(3.0)); + let printed = grida_xml::print(&doc).unwrap(); + assert!(printed.contains(r#"width="3""#), "{printed}"); + + doc.get_mut(target).strokes[0].width = StrokeWidth::Uniform(0.0); + let printed = grida_xml::print(&doc).unwrap(); + assert!(printed.contains(r#"width="0""#), "{printed}"); +} + +#[test] +fn malformed_or_negative_side_lists_are_rejected() { + let cases = [ + (r#"width="""#, "takes 1 or exactly 4 numbers"), + (r#"width="1 2""#, "got 2"), + (r#"width="1 2 3""#, "got 3"), + (r#"width="1 2 3 4 5""#, "got 5"), + (r#"width="1 -2 3 4""#, "stroke width right"), + (r#"width="1 NaN 3 4""#, "non-finite"), + ]; + for (attributes, expected) in cases { + let error = grida_xml::parse(&rect_document("", attributes)).unwrap_err(); + assert!(error.0.contains(expected), "{attributes}: {error}"); + } +} + +#[test] +fn four_value_syntax_is_rectangular_only_even_when_the_values_are_equal() { + let targets = [ + r##""##, + r##""##, + r##"x"##, + ]; + for target in targets { + let source = format!(r#"{target}"#); + let error = grida_xml::parse(&source).unwrap_err(); + assert!( + error.0.contains("four-value stroke width is valid only"), + "{error}" + ); + } +} + +#[test] +fn per_side_width_rejects_renderer_states_that_production_cannot_preserve() { + let cases = [ + ( + "corner-radius=\"8\" corner-smoothing=\"0.25\"", + r#"width="1 2 3 4""#, + "corner-smoothing", + ), + ("", r#"width="1 2 3 4" join="round""#, "default join"), + ( + "", + r#"width="1 2 3 4" miter-limit="6""#, + "miter-limit=\"4\"", + ), + ]; + for (node_attributes, stroke_attributes, expected) in cases { + let error = + grida_xml::parse(&rect_document(node_attributes, stroke_attributes)).unwrap_err(); + assert!(error.0.contains(expected), "{error}"); + } +} + +#[test] +fn writer_applies_the_same_cross_geometry_gates() { + let mut doc = grida_xml::parse(&rect_document("", r#"width="1 2 3 4""#)).unwrap(); + let target = named(&doc, "target"); + doc.get_mut(target).corner_smoothing = CornerSmoothing(0.5); + let error = grida_xml::print(&doc).unwrap_err(); + assert!(error.to_string().contains("corner-smoothing"), "{error}"); + + doc.get_mut(target).corner_smoothing = CornerSmoothing::default(); + doc.get_mut(target).strokes[0].join = StrokeJoin::Bevel; + let error = grida_xml::print(&doc).unwrap_err(); + assert!(error.to_string().contains("default join"), "{error}"); + + let source = r##""##; + let mut ellipse = grida_xml::parse(source).unwrap(); + let target = named(&ellipse, "target"); + ellipse.get_mut(target).strokes[0].width = StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 1.0, + stroke_right_width: 2.0, + stroke_bottom_width: 3.0, + stroke_left_width: 4.0, + }); + let error = grida_xml::print(&ellipse).unwrap_err(); + assert!( + error.to_string().contains("cannot carry per-side"), + "{error}" + ); +} + +#[test] +fn writer_reports_the_nonfinite_side_before_tree_integrity() { + let mut doc = grida_xml::parse(&rect_document("", r#"width="1 2 3 4""#)).unwrap(); + let target = named(&doc, "target"); + let StrokeWidth::Rectangular(widths) = &mut doc.get_mut(target).strokes[0].width else { + panic!("rectangular width") + }; + widths.stroke_right_width = f32::NAN; + + let error = grida_xml::print(&doc).unwrap_err(); + assert!( + error.to_string().contains("width right must be finite"), + "{error}" + ); + assert!(!error.to_string().contains("scene tree"), "{error}"); +} diff --git a/model-v2/a/lab/tests/grida_xml_strokes.rs b/model-v2/a/lab/tests/grida_xml_strokes.rs index 5763425155..4811ebb88d 100644 --- a/model-v2/a/lab/tests/grida_xml_strokes.rs +++ b/model-v2/a/lab/tests/grida_xml_strokes.rs @@ -38,7 +38,7 @@ fn repeated_strokes_preserve_geometry_paint_and_source_order() { assert_eq!(road.strokes.len(), 2); let bottom = &road.strokes[0]; - assert_eq!(bottom.width, 12.0); + assert_eq!(bottom.width, StrokeWidth::Uniform(12.0)); assert_eq!(bottom.align, StrokeAlign::Outside); assert_eq!(bottom.join, StrokeJoin::Round); assert_eq!(bottom.miter_limit, 6.0); @@ -50,7 +50,7 @@ fn repeated_strokes_preserve_geometry_paint_and_source_order() { assert!(matches!(bottom.paints[0], Paint::Solid(_))); let top = &road.strokes[1]; - assert_eq!(top.width, 3.0); + assert_eq!(top.width, StrokeWidth::Uniform(3.0)); assert_eq!(top.align, StrokeAlign::Inside); assert_eq!(top.paints.len(), 2); assert!(matches!(top.paints[0], Paint::Solid(_))); @@ -83,7 +83,7 @@ fn stroke_defaults_are_target_specific_and_round_trip() { let doc = grida_xml::parse(source).unwrap(); for name in ["rect", "ellipse", "text"] { let stroke = &doc.get(named(&doc, name)).strokes[0]; - assert_eq!(stroke.width, 1.0, "{name}"); + assert_eq!(stroke.width, StrokeWidth::Uniform(1.0), "{name}"); assert_eq!(stroke.align, StrokeAlign::Inside, "{name}"); assert_eq!(stroke.cap, StrokeCap::Butt, "{name}"); assert_eq!(stroke.join, StrokeJoin::Miter, "{name}"); @@ -166,7 +166,7 @@ fn default_empty_stroke_is_invalid_but_non_default_empty_geometry_survives() { .expect("non-default empty geometry remains authored state"); let stroke = &doc.get(named(&doc, "dormant")).strokes[0]; assert!(stroke.paints.is_empty()); - assert_eq!(stroke.width, 2.0); + assert_eq!(stroke.width, StrokeWidth::Uniform(2.0)); let printed = grida_xml::print(&doc).unwrap(); assert!(printed.contains(""), "{printed}"); assert_eq!(doc, grida_xml::parse(&printed).unwrap()); @@ -297,7 +297,7 @@ fn writer_omits_default_empty_stroke_and_refuses_invalid_stroke_state() { let payload = doc.get(r).payload.clone(); let mut invalid = Stroke::default_for(&payload).unwrap(); - invalid.width = -1.0; + invalid.width = StrokeWidth::Uniform(-1.0); invalid .paints .push(Paint::Solid(SolidPaint::new(Color::BLACK))); diff --git a/model-v2/a/lab/tests/grida_xml_suite.rs b/model-v2/a/lab/tests/grida_xml_suite.rs index e695a0c2e3..127560dfb8 100644 --- a/model-v2/a/lab/tests/grida_xml_suite.rs +++ b/model-v2/a/lab/tests/grida_xml_suite.rs @@ -10,7 +10,7 @@ const COMPOSED: &str = r#" - content + content @@ -257,8 +257,8 @@ fn numeric_domains_are_checked_at_parse_time() { "width must be non-negative", ), ( - r#"x"#, - "size must be greater than zero", + r#"x"#, + "font-size must be greater than zero", ), ( r#"x"#, @@ -676,6 +676,8 @@ fn print_roundtrip_ignores_arena_ids_but_not_tree_integrity() { desc: ShapeDesc::Rect, }, children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), fills: Paints::default(), strokes: vec![], }, @@ -694,6 +696,8 @@ fn print_roundtrip_ignores_arena_ids_but_not_tree_integrity() { clips_content: false, }, children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), fills: Paints::default(), strokes: vec![], }; diff --git a/model-v2/a/lab/tests/mm_laws.rs b/model-v2/a/lab/tests/mm_laws.rs index d07d8b1ea6..b70ec36ea4 100644 --- a/model-v2/a/lab/tests/mm_laws.rs +++ b/model-v2/a/lab/tests/mm_laws.rs @@ -173,6 +173,8 @@ fn mm7_add_delete_restores() { header: shape(10.0, 10.0).0, payload: shape(10.0, 10.0).1, children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), fills: Paints::default(), strokes: vec![], }, diff --git a/model-v2/engine/rig/examples/dynamic-slide.grida.xml b/model-v2/engine/rig/examples/dynamic-slide.grida.xml index a566481a69..27f5bc877b 100644 --- a/model-v2/engine/rig/examples/dynamic-slide.grida.xml +++ b/model-v2/engine/rig/examples/dynamic-slide.grida.xml @@ -1,28 +1,28 @@ - GRIDA XML · DRAFT 0 - Dynamic design, plain text. - A small authored tree can mix graphics, layout, and local composition. + GRIDA XML · DRAFT 0 + Dynamic design, plain text. + A small authored tree can mix graphics, layout, and local composition. - Presentations - Compose slides as trees. + Presentations + Compose slides as trees. - 01 + 01 - Interfaces - Flex keeps intent editable. + Interfaces + Flex keeps intent editable. - 02 + 02 - Agent-ready - Readable source, fewer traps. + Agent-ready + Readable source, fewer traps. - 03 + 03 diff --git a/model-v2/engine/rig/examples/rich-fills.grida.xml b/model-v2/engine/rig/examples/rich-fills.grida.xml index 42787b8939..e9062a6645 100644 --- a/model-v2/engine/rig/examples/rich-fills.grida.xml +++ b/model-v2/engine/rig/examples/rich-fills.grida.xml @@ -9,7 +9,7 @@
- Ordered fills + Ordered fills solid + gradient + image
@@ -20,7 +20,7 @@ - Unit-space radial + Unit-space radial diff --git a/model-v2/engine/rig/examples/rich-strokes.grida.xml b/model-v2/engine/rig/examples/rich-strokes.grida.xml index 3d76fb8b69..5243bd1ccf 100644 --- a/model-v2/engine/rig/examples/rich-strokes.grida.xml +++ b/model-v2/engine/rig/examples/rich-strokes.grida.xml @@ -13,7 +13,7 @@ - One shape · three strokes + One shape · three strokes diff --git a/model-v2/engine/rig/examples/source-becomes-surface.grida.xml b/model-v2/engine/rig/examples/source-becomes-surface.grida.xml index 8760d3d2c5..39e65f3534 100644 --- a/model-v2/engine/rig/examples/source-becomes-surface.grida.xml +++ b/model-v2/engine/rig/examples/source-becomes-surface.grida.xml @@ -26,8 +26,8 @@ - GRIDA / XML - AUTHORING SURFACE · DRAFT 0 + GRIDA / XML + AUTHORING SURFACE · DRAFT 0 1600 × 1000 / LIVE @@ -66,9 +66,9 @@ - FILE-FIRST DESIGN IR + FILE-FIRST DESIGN IR
- + @@ -76,7 +76,7 @@ MATERIAL, NOT MYSTERY. - One inspectable tree for live interfaces, + One inspectable tree for live interfaces, presentations, and programmable graphics. @@ -148,16 +148,16 @@ presentations, and programmable graphics. - ORDERED PAINT + ORDERED PAINT - FLEX + FREE + FLEX + FREE - MULTI-STROKE + MULTI-STROKE - AGENT-READY + AGENT-READY @@ -169,7 +169,7 @@ presentations, and programmable graphics. - ONE TREE · RESPONSIVE BOXES · PRODUCTION PAINTS + ONE TREE · RESPONSIVE BOXES · PRODUCTION PAINTS @@ -326,14 +326,14 @@ presentations, and programmable graphics. - + IR - PAINT · LAYOUT · TYPE + PAINT · LAYOUT · TYPE @@ -353,7 +353,7 @@ presentations, and programmable graphics. - MATERIALIZED THROUGH THE SAME ENGINE PAINT MODEL + MATERIALIZED THROUGH THE SAME ENGINE PAINT MODEL @@ -368,8 +368,8 @@ presentations, and programmable graphics. - LIVE PIPELINE - SOURCE > RESOLVE > DRAWLIST > PAINT + LIVE PIPELINE + SOURCE > RESOLVE > DRAWLIST > PAINT @@ -378,7 +378,7 @@ presentations, and programmable graphics. - PAINTS × ORDERED + PAINTS × ORDERED @@ -390,7 +390,7 @@ presentations, and programmable graphics. - STROKES × NATIVE + STROKES × NATIVE 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 literal 0 HcmV?d00001 diff --git a/model-v2/engine/rig/examples/social-feed/assets/post-orange-studio.webp b/model-v2/engine/rig/examples/social-feed/assets/post-orange-studio.webp new file mode 100644 index 0000000000000000000000000000000000000000..2927f28c9bb07e4122e5a7648a976d8a45711a42 GIT binary patch literal 192620 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

I5%^%_9-UksoYav%l=U{76y%yb38jEK6~lq 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

kfxD}|45AB8RW*F!=m%8XG| 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ξ?jt3`?aeP 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 + + + + + + + + + + + + + + + + + {initial} + + + + {label} + + + + + + + + + + {initial} + + {account} + {detail} + Follow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + O + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 / 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + O + + orbit.index + Orbit Index + Switch + + + Suggested for you + See all + + + + + + + + + + About · Help · Press · API · Jobs · Privacy · Terms +Locations · Language · Verified + +© 2026 FOLIO + + + + + + + + + + + + + + 3 + + Messages + + + + + + diff --git a/model-v2/engine/rig/examples/social-feed/post-card.grida.xml b/model-v2/engine/rig/examples/social-feed/post-card.grida.xml new file mode 100644 index 0000000000..9e3c7e2d75 --- /dev/null +++ b/model-v2/engine/rig/examples/social-feed/post-card.grida.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + {avatar} + + {author} + · {time} + {location} + ••• + + + + + + + + + + + + + + + + + + + {likes} + + + + + + + + {comments} + + + + + + + + + + + + + + + + + {author} {caption} + See translation + {timestamp} + + + diff --git a/model-v2/engine/rig/fixtures/component-program/entry.grida.xml b/model-v2/engine/rig/fixtures/component-program/entry.grida.xml new file mode 100644 index 0000000000..fcda05d0dc --- /dev/null +++ b/model-v2/engine/rig/fixtures/component-program/entry.grida.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/model-v2/engine/rig/fixtures/component-program/swatch.grida.xml b/model-v2/engine/rig/fixtures/component-program/swatch.grida.xml new file mode 100644 index 0000000000..0ef534b9bb --- /dev/null +++ b/model-v2/engine/rig/fixtures/component-program/swatch.grida.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/model-v2/engine/rig/fixtures/slot-program/entry.grida.xml b/model-v2/engine/rig/fixtures/slot-program/entry.grida.xml new file mode 100644 index 0000000000..54922d92ba --- /dev/null +++ b/model-v2/engine/rig/fixtures/slot-program/entry.grida.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/model-v2/engine/rig/fixtures/slot-program/post-shell.grida.xml b/model-v2/engine/rig/fixtures/slot-program/post-shell.grida.xml new file mode 100644 index 0000000000..ed96d44978 --- /dev/null +++ b/model-v2/engine/rig/fixtures/slot-program/post-shell.grida.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/model-v2/engine/src/bin/grida_xml_render.rs b/model-v2/engine/src/bin/grida_xml_render.rs index 90c0d942c3..2f898ace26 100644 --- a/model-v2/engine/src/bin/grida_xml_render.rs +++ b/model-v2/engine/src/bin/grida_xml_render.rs @@ -1,15 +1,16 @@ -//! Thin file host for the Draft 0 `.grida.xml` proof. +//! Thin local-file host for the versioned `.grida.xml` proof. //! -//! The format parser remains a pure `&str -> Document` boundary. This binary -//! owns path I/O, a platform typeface, the raster surface, and PNG encoding; -//! the scene itself still runs through the engine's one frame entry. +//! The source linker remains pure behind a host-supplied [`SourceProvider`]. +//! This binary owns source and image path I/O, a platform typeface, the raster +//! surface, and PNG encoding; the materialized ordinary scene still runs +//! through the engine's one frame entry. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anchor_engine::frame; use anchor_engine::paint::PaintCtx; -use anchor_lab::grida_xml; +use anchor_lab::grida_xml_source::{self, MaterializedProgram, SourceProvider, SourceSnapshot}; use anchor_lab::math::Affine; use anchor_lab::model::{Document, NodeId, Paint, Payload, ResourceRef}; use anchor_lab::resolve::{Report, ResolveOptions, Resolved}; @@ -19,6 +20,59 @@ const DEFAULT_WIDTH: i32 = 1280; const DEFAULT_HEIGHT: i32 = 720; const USAGE: &str = "usage: grida_xml_render [width height]"; +struct LocalFileSourceProvider; + +impl LocalFileSourceProvider { + /// Freeze one filesystem source as the canonical immutable snapshot the + /// linker requires. Canonical paths make aliases and symlinks share one + /// source identity and base within the link operation. + fn snapshot(path: &Path) -> Result { + let canonical = std::fs::canonicalize(path) + .map_err(|error| format!("resolve source {}: {error}", path.display()))?; + let source = std::fs::read_to_string(&canonical) + .map_err(|error| format!("read source {}: {error}", canonical.display()))?; + let identity = canonical + .to_str() + .ok_or_else(|| format!("source path is not valid UTF-8: {}", canonical.display()))? + .to_owned(); + let parent = canonical + .parent() + .ok_or_else(|| format!("source has no parent directory: {}", canonical.display()))?; + let base = parent + .to_str() + .ok_or_else(|| format!("source base is not valid UTF-8: {}", parent.display()))? + .to_owned(); + Ok(SourceSnapshot::new(identity, base, source)) + } +} + +impl SourceProvider for LocalFileSourceProvider { + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result { + if location.contains("://") || location.starts_with("data:") { + return Err(format!( + "non-file source location `{location}` is not supported by this file host" + )); + } + let location = Path::new(location); + let path = if location.is_absolute() { + location.to_path_buf() + } else { + Path::new(containing.base()).join(location) + }; + Self::snapshot(&path) + } +} + +fn materialize_file(path: &Path) -> Result { + let entry = LocalFileSourceProvider::snapshot(path)?; + grida_xml_source::materialize(entry, &mut LocalFileSourceProvider) + .map_err(|error| error.to_string()) +} + fn parse_extent(value: &str, label: &str) -> Result { let extent = value .parse::() @@ -49,6 +103,23 @@ fn node_label(doc: &Document, id: NodeId) -> String { .unwrap_or_else(|| format!("node {id}")) } +fn provenance_suffix(program: &MaterializedProgram, id: NodeId) -> String { + let Some(provenance) = program.provenance.get(&id) else { + return String::new(); + }; + let mut suffix = format!("; authored in {}", provenance.source); + if let Some(component) = &provenance.component { + suffix.push_str(&format!(" as {component}")); + } + for site in &provenance.use_chain { + suffix.push_str(&format!( + " via {}:{} -> {}", + site.source, site.span.start, site.href + )); + } + suffix +} + fn collect_visible_image_resources( doc: &Document, id: NodeId, @@ -110,19 +181,31 @@ fn local_resource_path(base: &Path, rid: &str) -> Result { } /// Resolve and decode every image that can contribute to the frame before any -/// paint command runs. Relative RIDs are based at the source document, never -/// the process working directory; duplicate RIDs share one decoded image. +/// paint command runs. Materialization has already replaced authored RIDs with +/// collision-free runtime keys; the manifest restores each key's lexical +/// source and canonical base for host I/O and diagnostics. fn load_image_resources( - input: &Path, - doc: &Document, + program: &MaterializedProgram, paint_ctx: &mut PaintCtx, ) -> Result<(), String> { - let base = input.parent().unwrap_or_else(|| Path::new(".")); + let doc = &program.document; + let mut manifest = BTreeMap::new(); + for resource in &program.resources { + if manifest + .insert(resource.runtime_rid.as_str(), resource) + .is_some() + { + return Err(format!( + "duplicate runtime image resource `{}` in materialized manifest", + resource.runtime_rid + )); + } + } let mut refs = vec![]; collect_visible_image_resources(doc, doc.root, &mut refs); let mut loaded = BTreeSet::new(); for (node, resource) in refs { - let rid = match resource { + let runtime_rid = match resource { ResourceRef::Rid(rid) => rid, ResourceRef::Hash(hash) => { return Err(format!( @@ -131,23 +214,39 @@ fn load_image_resources( )); } }; - if !loaded.insert(rid.clone()) || paint_ctx.contains_image(&rid) { + let resource = manifest.get(runtime_rid.as_str()).ok_or_else(|| { + format!( + "{} image runtime resource `{runtime_rid}` is absent from the materialized manifest", + node_label(doc, node) + ) + })?; + // Runtime keys are unique only within one materialized program. Load + // each key once per call, but deliberately replace an older context + // entry when a host reuses its PaintCtx across program reloads. + if !loaded.insert(runtime_rid.clone()) { continue; } - let resolved = local_resource_path(base, &rid) - .map_err(|error| format!("{} image `{rid}`: {error}", node_label(doc, node)))?; + let authored = &resource.authored; + let source = &resource.source; + let resolved = + local_resource_path(Path::new(&resource.base), authored).map_err(|error| { + format!( + "{} image `{authored}` authored in {source}: {error}", + node_label(doc, node) + ) + })?; let bytes = std::fs::read(&resolved).map_err(|error| { format!( - "{} image `{rid}` resolved to {}: {error}", + "{} image `{authored}` authored in {source} resolved to {}: {error}", node_label(doc, node), resolved.display() ) })?; paint_ctx - .insert_encoded(rid.clone(), &bytes) + .insert_encoded(runtime_rid.clone(), &bytes) .map_err(|error| { format!( - "{} image `{rid}` resolved to {}: {error}", + "{} image `{authored}` authored in {source} resolved to {}: {error}", node_label(doc, node), resolved.display() ) @@ -159,7 +258,11 @@ fn load_image_resources( /// A resolver report that says intent was ignored or underdetermined is a /// failed file render, not a warning the CLI may discard. Ordinary min/max /// and span clamps remain valid resolved output. -fn ensure_resolved_without_errors(doc: &Document, resolved: &Resolved) -> Result<(), String> { +fn ensure_resolved_without_errors( + program: &MaterializedProgram, + resolved: &Resolved, +) -> Result<(), String> { + let doc = &program.document; for report in &resolved.reports { let (node, field, rule) = match report { Report::IgnoredByRule { node, field, rule } @@ -167,8 +270,9 @@ fn ensure_resolved_without_errors(doc: &Document, resolved: &Resolved) -> Result Report::Clamped { .. } => continue, }; return Err(format!( - "{} could not resolve `{field}`: {rule}", - node_label(doc, node) + "{} could not resolve `{field}`: {rule}{}", + node_label(doc, node), + provenance_suffix(program, node) )); } Ok(()) @@ -181,10 +285,8 @@ fn run() -> Result<(), String> { }; let (width, height) = dimensions(rest)?; - let source = std::fs::read_to_string(input) - .map_err(|error| format!("read {}: {error}", Path::new(input).display()))?; - let doc = grida_xml::parse(&source) - .map_err(|error| format!("parse {}: {error}", Path::new(input).display()))?; + let program = materialize_file(Path::new(input))?; + let doc = &program.document; let mut surface = surfaces::raster_n32_premul((width, height)) .ok_or_else(|| format!("could not allocate {width}x{height} raster surface"))?; @@ -198,15 +300,15 @@ fn run() -> Result<(), String> { .legacy_make_typeface(None, FontStyle::default()) .ok_or_else(|| "no platform-default typeface is available".to_string())?; let mut paint_ctx = PaintCtx::new(Some(font)); - load_image_resources(Path::new(input), &doc, &mut paint_ctx)?; + load_image_resources(&program, &mut paint_ctx)?; let (resolved, _, _) = frame::render( surface.canvas(), - &doc, + doc, &options, &Affine::IDENTITY, &paint_ctx, ); - ensure_resolved_without_errors(&doc, &resolved)?; + ensure_resolved_without_errors(&program, &resolved)?; let image = surface.image_snapshot(); let png = image @@ -235,64 +337,109 @@ fn main() { #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; - use super::{ensure_resolved_without_errors, load_image_resources, local_resource_path}; - use anchor_engine::paint::PaintCtx; - use anchor_lab::grida_xml; - use anchor_lab::model::{ - AttributedString, DocBuilder, Header, ImagePaint, Paint, Paints, Payload, SizeIntent, - StyledTextRun, TextStyleRec, + use super::{ + ensure_resolved_without_errors, load_image_resources, local_resource_path, + materialize_file, LocalFileSourceProvider, }; + use anchor_engine::frame; + use anchor_engine::paint::{read_pixels, PaintCtx}; + use anchor_lab::grida_xml_source::{self, MaterializedProgram, SourceSnapshot}; + use anchor_lab::math::Affine; use anchor_lab::resolve::{resolve, ResolveOptions}; + use skia_safe::{surfaces, Color}; fn fixture_input() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("rig/fixtures/nested-rects.grida.xml") } - fn attributed_image_document(rid: &str) -> anchor_lab::model::Document { - let style = TextStyleRec::default(); - let attributed_string = AttributedString::from_runs( - "x", - vec![StyledTextRun { - start: 0, - end: 1, - style, - fills: Some(Paints::new([Paint::Image(ImagePaint::from_rid(rid))])), - }], + fn image_fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/images") + .join(name) + } + + fn materialize_at_fixture(source: &str) -> MaterializedProgram { + let input = std::fs::canonicalize(fixture_input()).unwrap(); + let identity = input.to_str().unwrap().to_owned(); + let base = input.parent().unwrap().to_str().unwrap().to_owned(); + grida_xml_source::materialize( + SourceSnapshot::new(identity, base, source), + &mut LocalFileSourceProvider, ) - .unwrap(); - let mut builder = DocBuilder::new(); - builder.add( - 0, - Header::new(SizeIntent::Fixed(32.0), SizeIntent::Fixed(32.0)), - Payload::AttributedText { - attributed_string, - default_style: style, - }, - ); - builder.build() + .unwrap() + } + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "grida-xml-render-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + if path.exists() { + std::fs::remove_dir_all(&path).unwrap(); + } + std::fs::create_dir(&path).unwrap(); + Self { path } + } + + fn write(&self, relative: &str, bytes: &[u8]) -> PathBuf { + let path = self.path.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } } #[test] fn probe_fixture_resolves_without_errors() { - let doc = - grida_xml::parse(include_str!("../../rig/fixtures/nested-rects.grida.xml")).unwrap(); - let resolved = resolve(&doc, &ResolveOptions::default()); - assert_eq!(ensure_resolved_without_errors(&doc, &resolved), Ok(())); + let program = + materialize_at_fixture(include_str!("../../rig/fixtures/nested-rects.grida.xml")); + let resolved = resolve(&program.document, &ResolveOptions::default()); + assert_eq!(ensure_resolved_without_errors(&program, &resolved), Ok(())); } #[test] fn resolution_guard_rejects_underdetermined_intent() { - let doc = grida_xml::parse( + let program = materialize_at_fixture( r#""#, - ) - .unwrap(); - let resolved = resolve(&doc, &ResolveOptions::default()); - let error = ensure_resolved_without_errors(&doc, &resolved).unwrap_err(); + ); + let resolved = resolve(&program.document, &ResolveOptions::default()); + let error = ensure_resolved_without_errors(&program, &resolved).unwrap_err(); assert!( error.contains("`centered` could not resolve `x`: End/Center pin underdetermined"), "{error}" ); + assert!(error.contains("authored in"), "{error}"); + } + + #[test] + fn resolution_errors_inside_components_keep_definition_and_use_provenance() { + let program = materialize_at_fixture( + r##""##, + ); + let resolved = resolve(&program.document, &ResolveOptions::default()); + let error = ensure_resolved_without_errors(&program, &resolved).unwrap_err(); + assert!( + error.contains("`centered` could not resolve `x`: End/Center pin underdetermined"), + "{error}" + ); + assert!(error.contains("#broken"), "{error}"); + assert!(error.contains(" via "), "{error}"); + assert!(error.contains("-> #broken"), "{error}"); } #[test] @@ -301,10 +448,12 @@ mod tests { let source = format!( r#""# ); - let doc = grida_xml::parse(&source).unwrap(); + let program = materialize_at_fixture(&source); + assert_eq!(program.resources[0].authored, rid); + let runtime_rid = program.resources[0].runtime_rid.clone(); let mut ctx = PaintCtx::new(None); - load_image_resources(&fixture_input(), &doc, &mut ctx).unwrap(); - assert!(ctx.contains_image(rid)); + load_image_resources(&program, &mut ctx).unwrap(); + assert!(ctx.contains_image(&runtime_rid)); } #[test] @@ -313,27 +462,33 @@ mod tests { let source = format!( r#""# ); - let doc = grida_xml::parse(&source).unwrap(); + let program = materialize_at_fixture(&source); + let runtime_rid = program.resources[0].runtime_rid.clone(); let mut ctx = PaintCtx::new(None); - load_image_resources(&fixture_input(), &doc, &mut ctx).unwrap(); - assert!(ctx.contains_image(rid)); + load_image_resources(&program, &mut ctx).unwrap(); + assert!(ctx.contains_image(&runtime_rid)); } #[test] fn image_resources_are_discovered_in_attributed_run_fills() { let rid = "../../../../fixtures/images/border-diamonds.png"; - let document = attributed_image_document(rid); + let source = format!( + r#"x"# + ); + let program = materialize_at_fixture(&source); + let runtime_rid = program.resources[0].runtime_rid.clone(); let mut ctx = PaintCtx::new(None); - load_image_resources(&fixture_input(), &document, &mut ctx).unwrap(); - assert!(ctx.contains_image(rid)); + load_image_resources(&program, &mut ctx).unwrap(); + assert!(ctx.contains_image(&runtime_rid)); } #[test] fn missing_attributed_run_image_reports_its_authored_resource() { - let document = attributed_image_document("./missing-run-image.png"); - let error = load_image_resources(&fixture_input(), &document, &mut PaintCtx::new(None)) - .unwrap_err(); + let source = r#"x"#; + let program = materialize_at_fixture(source); + let error = load_image_resources(&program, &mut PaintCtx::new(None)).unwrap_err(); assert!(error.contains("image `./missing-run-image.png`"), "{error}"); + assert!(error.contains("authored in"), "{error}"); assert!( error.contains("rig/fixtures/./missing-run-image.png"), "{error}" @@ -342,20 +497,19 @@ mod tests { #[test] fn image_resource_errors_name_authored_and_resolved_locations() { - let missing = grida_xml::parse( + let missing = materialize_at_fixture( r#""#, - ) - .unwrap(); + ); let mut ctx = PaintCtx::new(None); - let error = load_image_resources(&fixture_input(), &missing, &mut ctx).unwrap_err(); + let error = load_image_resources(&missing, &mut ctx).unwrap_err(); assert!(error.contains("`missing` image `./missing.png`"), "{error}"); + assert!(error.contains("authored in"), "{error}"); assert!(error.contains("rig/fixtures/./missing.png"), "{error}"); - let corrupt = grida_xml::parse( + let corrupt = materialize_at_fixture( r#""#, - ) - .unwrap(); - let error = load_image_resources(&fixture_input(), &corrupt, &mut ctx).unwrap_err(); + ); + let error = load_image_resources(&corrupt, &mut ctx).unwrap_err(); assert!( error.contains("`corrupt` image `./nested-rects.grida.xml`"), "{error}" @@ -371,4 +525,132 @@ mod tests { base.join("hash:asset.png") ); } + + #[test] + fn external_component_sources_and_resources_resolve_from_their_own_canonical_base() { + let temp = TestDir::new(); + let entry = temp.write( + "entry.grida.xml", + br#""#, + ); + let component = temp.write( + "components/card.grida.xml", + br#""#, + ); + temp.write( + "components/texture.png", + &std::fs::read(image_fixture("border-diamonds.png")).unwrap(), + ); + + let program = materialize_file(&entry).unwrap(); + assert_eq!(program.program.units().len(), 2); + assert_eq!(program.resources.len(), 1); + let canonical_component = std::fs::canonicalize(component).unwrap(); + let resource = &program.resources[0]; + assert_eq!(resource.source, canonical_component.to_str().unwrap()); + assert_eq!( + resource.base, + canonical_component.parent().unwrap().to_str().unwrap() + ); + assert_eq!(resource.authored, "./texture.png"); + + let mut ctx = PaintCtx::new(None); + load_image_resources(&program, &mut ctx).unwrap(); + assert!(ctx.contains_image(&resource.runtime_rid)); + } + + #[test] + fn equal_relative_resources_from_two_sources_load_under_distinct_runtime_keys() { + let temp = TestDir::new(); + let entry = temp.write( + "entry.grida.xml", + br#""#, + ); + let component = br#""#; + temp.write("a/card.grida.xml", component); + temp.write("b/card.grida.xml", component); + let a_image = temp.write( + "a/texture.png", + &std::fs::read(image_fixture("border-diamonds.png")).unwrap(), + ); + let b_image = temp.write( + "b/texture.png", + &std::fs::read(image_fixture("stripes.png")).unwrap(), + ); + assert_ne!( + std::fs::read(a_image).unwrap(), + std::fs::read(b_image).unwrap() + ); + + let program = materialize_file(&entry).unwrap(); + assert_eq!(program.resources.len(), 2); + assert_eq!(program.resources[0].authored, "./texture.png"); + assert_eq!(program.resources[1].authored, "./texture.png"); + assert_ne!( + program.resources[0].runtime_rid, + program.resources[1].runtime_rid + ); + assert_ne!(program.resources[0].source, program.resources[1].source); + + let mut ctx = PaintCtx::new(None); + load_image_resources(&program, &mut ctx).unwrap(); + for resource in &program.resources { + assert!(ctx.contains_image(&resource.runtime_rid)); + } + + let mut surface = surfaces::raster_n32_premul((50, 20)).unwrap(); + surface.canvas().clear(Color::BLACK); + frame::render( + surface.canvas(), + &program.document, + &ResolveOptions { + viewport: (50.0, 20.0), + ..Default::default() + }, + &Affine::IDENTITY, + &ctx, + ); + let pixels = read_pixels(&mut surface, 50, 20); + let region = |x: usize| { + (0..20) + .flat_map(|y| { + let start = (y * 50 + x) * 4; + pixels[start..start + 20 * 4].iter().copied() + }) + .collect::>() + }; + assert_ne!( + region(0), + region(25), + "each origin-aware runtime key must decode bytes from its own source base" + ); + } + + #[test] + fn version2_resource_argument_keeps_the_callers_file_base() { + let temp = TestDir::new(); + let entry = temp.write( + "entry.grida.xml", + br##""##, + ); + temp.write( + "components/card.grida.xml", + br##""##, + ); + temp.write( + "texture.png", + &std::fs::read(image_fixture("checker.png")).unwrap(), + ); + + let program = materialize_file(&entry).unwrap(); + assert_eq!(program.resources.len(), 1); + let entry = std::fs::canonicalize(entry).unwrap(); + let resource = &program.resources[0]; + assert_eq!(resource.source, entry.to_str().unwrap()); + assert_eq!(resource.base, entry.parent().unwrap().to_str().unwrap()); + + let mut ctx = PaintCtx::new(None); + load_image_resources(&program, &mut ctx).unwrap(); + assert!(ctx.contains_image(&resource.runtime_rid)); + } } diff --git a/model-v2/engine/src/paint.rs b/model-v2/engine/src/paint.rs index 27b46315d4..3139c22e6f 100644 --- a/model-v2/engine/src/paint.rs +++ b/model-v2/engine/src/paint.rs @@ -30,7 +30,9 @@ use crate::drawlist::{DrawList, ItemKind}; /// Host-supplied resources: the typeface offered to text resolution and decoded /// images used at paint time. Exact shaped fonts live with the drawlist; images -/// stay keyed by authored RID so path resolution remains a host concern. +/// stay keyed by the model's logical RID so authored-source resolution remains +/// a host concern. A retained source program may lower an authored RID to an +/// origin-aware runtime RID before this boundary. pub struct PaintCtx { id: u64, resource_revision: u64, @@ -73,7 +75,7 @@ impl PaintCtx { self.bump_resource_revision(); } - /// Register an already-decoded image under the exact authored resource id. + /// Register an already-decoded image under the exact model resource id. pub fn insert_image(&mut self, rid: impl Into, image: Image) { let image = image.with_default_mipmaps().unwrap_or(image); self.images.insert(rid.into(), image); diff --git a/model-v2/engine/tests/grida_xml_slots.rs b/model-v2/engine/tests/grida_xml_slots.rs new file mode 100644 index 0000000000..0d13d59252 --- /dev/null +++ b/model-v2/engine/tests/grida_xml_slots.rs @@ -0,0 +1,227 @@ +//! Engine integration proof for Version 3 render-slot projection. +//! +//! Slot declarations and assignments are consumed by the source materializer. +//! Resolution, display-list construction, and painting observe only the +//! resulting ordinary scene nodes. + +#[allow(dead_code)] +mod support; + +use anchor_engine::drawlist::build_glyphless; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml_source::{self, SourceProvider, SourceSnapshot}; +use anchor_lab::math::Affine; +use anchor_lab::model::{Payload, ShapeDesc}; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{surfaces, Color}; + +const ENTRY_SOURCE: &str = include_str!("../rig/fixtures/slot-program/entry.grida.xml"); +const COMPONENT_SOURCE: &str = include_str!("../rig/fixtures/slot-program/post-shell.grida.xml"); +const ENTRY_ID: &str = "fixture:slot-program/entry"; +const COMPONENT_ID: &str = "fixture:slot-program/post-shell"; +const SOURCE_BASE: &str = "fixture:/slot-program/"; +const WIDTH: i32 = 112; +const HEIGHT: i32 = 48; + +#[derive(Default)] +struct FixtureSources { + requests: usize, +} + +impl SourceProvider for FixtureSources { + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result { + self.requests += 1; + if containing.identity() != ENTRY_ID || location != "./post-shell.grida.xml" { + return Err(format!( + "unexpected fixture reference from {} to {location}", + containing.identity() + )); + } + Ok(SourceSnapshot::new( + COMPONENT_ID, + SOURCE_BASE, + COMPONENT_SOURCE, + )) + } +} + +fn options() -> ResolveOptions { + ResolveOptions { + viewport: (WIDTH as f32, HEIGHT as f32), + ..Default::default() + } +} + +#[test] +fn version3_slots_materialize_before_the_component_blind_frame() { + let mut sources = FixtureSources::default(); + let output = grida_xml_source::materialize( + SourceSnapshot::new(ENTRY_ID, SOURCE_BASE, ENTRY_SOURCE), + &mut sources, + ) + .expect("external Version 3 slot program materializes"); + + assert_eq!( + sources.requests, 1, + "one external source snapshot is reused" + ); + assert_eq!(output.program.units().len(), 2); + assert!(output.resources.is_empty()); + + // No component, use, or slot kind crosses the source boundary. The + // concrete document contains only ordinary containers and rectangles. + assert_eq!(output.document.len(), 12); + for id in 0..output.document.capacity() as u32 { + let Some(node) = output.document.get_opt(id) else { + continue; + }; + assert!( + matches!( + node.payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect + } + ), + "unexpected ordinary payload at node {id}: {:?}", + node.payload + ); + } + + let scene = output.document.get(output.document.root).children[0]; + let instances = &output.document.get(scene).children; + assert_eq!(instances.len(), 2); + let filled = instances[0]; + let empty = instances[1]; + + let filled_children = &output.document.get(filled).children; + let empty_children = &output.document.get(empty).children; + assert_eq!(filled_children.len(), 3); + assert_eq!(empty_children.len(), 3); + let (filled_header, filled_body, filled_footer) = + (filled_children[0], filled_children[1], filled_children[2]); + let (empty_header, empty_body, empty_footer) = + (empty_children[0], empty_children[1], empty_children[2]); + let assignments = &output.document.get(filled_body).children; + assert_eq!(assignments.len(), 2); + assert!(output.document.get(empty_body).children.is_empty()); + let green = assignments[0]; + let blue = assignments[1]; + + assert_eq!(output.slot_projections.len(), 2); + let filled_projection = output + .slot_projections + .iter() + .find(|projection| { + projection + .use_chain + .last() + .and_then(|site| site.name.as_deref()) + == Some("filled") + }) + .expect("filled slot projection"); + let empty_projection = output + .slot_projections + .iter() + .find(|projection| { + projection + .use_chain + .last() + .and_then(|site| site.name.as_deref()) + == Some("empty") + }) + .expect("empty slot projection"); + for projection in [filled_projection, empty_projection] { + assert_eq!(projection.definition.source, COMPONENT_ID); + assert_eq!(projection.definition.component.source, COMPONENT_ID); + assert_eq!(projection.definition.component.id, "post-shell"); + assert_eq!(projection.definition.name, "content"); + assert!(projection.definition.span.end > projection.definition.span.start); + } + assert_eq!( + filled_projection + .assignments + .iter() + .map(|assignment| assignment.node) + .collect::>(), + [green, blue] + ); + assert!(filled_projection.assignments.iter().all(|assignment| { + assignment.site.source == ENTRY_ID + && assignment.site.component.is_none() + && assignment.site.name == "content" + && assignment.site.span.end > assignment.site.span.start + })); + assert!(empty_projection.assignments.is_empty()); + + for assigned in [green, blue] { + let provenance = &output.provenance[&assigned]; + assert_eq!(provenance.source, ENTRY_ID); + assert!(provenance.component.is_none()); + assert_eq!(provenance.use_chain.len(), 1); + assert_eq!(provenance.use_chain[0].target.source, COMPONENT_ID); + assert_eq!(provenance.use_chain[0].target.id, "post-shell"); + } + for definition_node in [filled_header, filled_footer, empty_header, empty_footer] { + let provenance = &output.provenance[&definition_node]; + assert_eq!(provenance.source, COMPONENT_ID); + assert_eq!(provenance.component.as_ref().unwrap().id, "post-shell"); + } + + let resolved = resolve(&output.document, &options()); + assert!(resolved.reports.is_empty(), "{:?}", resolved.reports); + assert_eq!(resolved.xywh(filled), (4.0, 4.0, 48.0, 40.0)); + assert_eq!(resolved.xywh(empty), (60.0, 4.0, 48.0, 40.0)); + assert_eq!(resolved.xywh(filled_header), (0.0, 0.0, 48.0, 8.0)); + assert_eq!(resolved.xywh(filled_body), (0.0, 8.0, 48.0, 24.0)); + assert_eq!(resolved.xywh(green), (0.0, 0.0, 32.0, 24.0)); + assert_eq!(resolved.xywh(blue), (16.0, 0.0, 32.0, 24.0)); + assert_eq!(resolved.xywh(filled_footer), (0.0, 32.0, 48.0, 8.0)); + assert_eq!(resolved.world_of(green), Affine::translate(4.0, 12.0)); + assert_eq!(resolved.world_of(blue), Affine::translate(20.0, 12.0)); + assert_eq!(resolved.world_of(empty_body), Affine::translate(60.0, 12.0)); + + // The display list is already slot-blind. Its ordinary painter order is + // definition header, caller roots in assignment order, then definition + // footer, followed by the empty instance's header and footer. + let list = build_glyphless(&output.document, &resolved); + assert_eq!( + list.items.iter().map(|item| item.node).collect::>(), + [ + filled_header, + green, + blue, + filled_footer, + empty_header, + empty_footer, + ] + ); + + let paint_ctx = PaintCtx::new(None); + let mut surface = surfaces::raster_n32_premul((WIDTH, HEIGHT)).expect("raster surface"); + surface.canvas().clear(Color::BLACK); + let (_, frame_list, _) = frame::render( + surface.canvas(), + &output.document, + &options(), + &Affine::IDENTITY, + &paint_ctx, + ); + assert_eq!(frame_list, list); + assert_eq!(surface.canvas().save_count(), 1, "canvas state leaked"); + + let image = support::RgbaImage::from_image(&surface.image_snapshot()); + assert_eq!(image.at(1, 1), [0, 0, 0, 255]); + assert_eq!(image.at(8, 8), [225, 29, 72, 255]); + assert_eq!(image.at(8, 24), [34, 197, 94, 255]); + assert_eq!(image.at(24, 24), [37, 99, 235, 255]); + assert_eq!(image.at(8, 40), [225, 29, 72, 255]); + assert_eq!(image.at(64, 8), [225, 29, 72, 255]); + assert_eq!(image.at(84, 24), [0, 0, 0, 255]); + assert_eq!(image.at(64, 40), [225, 29, 72, 255]); +} diff --git a/model-v2/engine/tests/grida_xml_social_feed.rs b/model-v2/engine/tests/grida_xml_social_feed.rs new file mode 100644 index 0000000000..8c40a56d14 --- /dev/null +++ b/model-v2/engine/tests/grida_xml_social_feed.rs @@ -0,0 +1,265 @@ +//! Executable smoke proof for the Version 3 social-feed showcase. +//! +//! The rich example is not a reftest or a probe fixture. Structural and +//! resolved-layout assertions keep the authored demo executable; the focused +//! solid-color slot fixture owns deterministic pixel observations. + +use anchor_engine::drawlist::ItemKind; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml_source::{self, SourceProvider, SourceSnapshot}; +use anchor_lab::math::Affine; +use anchor_lab::model::{Paint, ResourceRef}; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{surfaces, Color}; +use std::collections::BTreeSet; + +const ENTRY_SOURCE: &str = include_str!("../rig/examples/social-feed/entry.grida.xml"); +const COMPONENT_SOURCE: &str = include_str!("../rig/examples/social-feed/post-card.grida.xml"); +const ENTRY_ID: &str = "example:social-feed/entry"; +const COMPONENT_ID: &str = "example:social-feed/post-card"; +const SOURCE_BASE: &str = "example:/social-feed/"; +const COASTAL_IMAGE: &[u8] = + include_bytes!("../rig/examples/social-feed/assets/post-coastal-cabin.webp"); +const STUDIO_IMAGE: &[u8] = + include_bytes!("../rig/examples/social-feed/assets/post-orange-studio.webp"); +const WIDTH: i32 = 1920; +const HEIGHT: i32 = 1080; + +#[derive(Default)] +struct SocialFeedSources { + requests: usize, +} + +impl SourceProvider for SocialFeedSources { + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result { + self.requests += 1; + if containing.identity() != ENTRY_ID || location != "./post-card.grida.xml" { + return Err(format!( + "unexpected social-feed reference from {} to {location}", + containing.identity() + )); + } + Ok(SourceSnapshot::new( + COMPONENT_ID, + SOURCE_BASE, + COMPONENT_SOURCE, + )) + } +} + +fn options() -> ResolveOptions { + options_for(WIDTH as f32, HEIGHT as f32) +} + +fn options_for(width: f32, height: f32) -> ResolveOptions { + ResolveOptions { + viewport: (width, height), + ..Default::default() + } +} + +#[test] +fn desktop_social_feed_keeps_shared_shells_caller_media_and_loaded_resources() { + let mut sources = SocialFeedSources::default(); + let output = grida_xml_source::materialize( + SourceSnapshot::new(ENTRY_ID, SOURCE_BASE, ENTRY_SOURCE), + &mut sources, + ) + .expect("the Version 3 social feed materializes"); + + assert_eq!(sources.requests, 1); + assert_eq!(output.program.units().len(), 2); + assert_eq!( + output.specializations.len(), + 13, + "six stories, five suggestions, and two posts are specialized" + ); + assert_eq!(output.slot_projections.len(), 2); + assert!(output.slot_projections.iter().all(|projection| { + projection.definition.source == COMPONENT_ID + && projection.definition.component.id == "post-card" + && projection.definition.name == "media" + && projection.assignments.len() == 1 + && projection.assignments[0].site.source == ENTRY_ID + })); + let mut authored_resources = output + .resources + .iter() + .map(|resource| resource.authored.as_str()) + .collect::>(); + assert!(output + .resources + .iter() + .all(|resource| { resource.source == ENTRY_ID && resource.base == SOURCE_BASE })); + authored_resources.sort_unstable(); + assert_eq!( + authored_resources, + [ + "./assets/post-coastal-cabin.webp", + "./assets/post-orange-studio.webp", + ] + ); + let expected_image_rids = output + .resources + .iter() + .map(|resource| resource.runtime_rid.clone()) + .collect::>(); + + let scene = output.document.get(output.document.root).children[0]; + let scene_children = &output.document.get(scene).children; + assert_eq!(scene_children.len(), 5); + let timeline = scene_children[2]; + let posts = &output.document.get(timeline).children; + assert_eq!(posts.len(), 2); + let first_media = output.document.get(posts[0]).children[1]; + let second_media = output.document.get(posts[1]).children[1]; + let first_art = output.document.get(first_media).children[0]; + let second_art = output.document.get(second_media).children[0]; + assert_eq!(output.provenance[&first_art].source, ENTRY_ID); + assert_eq!(output.provenance[&second_art].source, ENTRY_ID); + + let resolved = resolve(&output.document, &options()); + assert!(resolved.reports.is_empty(), "{:?}", resolved.reports); + assert_eq!(resolved.xywh(posts[0]), (0.0, 0.0, 468.0, 780.0)); + assert_eq!(resolved.xywh(posts[1]), (0.0, 800.0, 468.0, 780.0)); + assert_eq!(resolved.world_of(posts[0]), Affine::translate(620.0, 166.0)); + assert_eq!(resolved.world_of(posts[1]), Affine::translate(620.0, 966.0)); + assert_eq!(resolved.xywh(first_media), (0.0, 52.0, 468.0, 585.0)); + assert_eq!(resolved.xywh(second_media), (0.0, 52.0, 468.0, 585.0)); + assert_eq!( + resolved.world_of(first_art), + Affine::translate(620.0, 218.0) + ); + assert_eq!( + resolved.world_of(second_art), + Affine::translate(620.0, 1018.0) + ); + + let mut surface = surfaces::raster_n32_premul((WIDTH, HEIGHT)).expect("raster surface"); + surface.canvas().clear(Color::WHITE); + let mut paint_ctx = PaintCtx::new(None); + for resource in &output.resources { + let encoded = match resource.authored.as_str() { + "./assets/post-coastal-cabin.webp" => COASTAL_IMAGE, + "./assets/post-orange-studio.webp" => STUDIO_IMAGE, + authored => panic!("unexpected social-feed resource {authored}"), + }; + paint_ctx + .insert_encoded(resource.runtime_rid.clone(), encoded) + .expect("checked-in social media asset decodes"); + assert!(paint_ctx.contains_image(&resource.runtime_rid)); + } + let (_, drawlist, _) = frame::render( + surface.canvas(), + &output.document, + &options(), + &Affine::IDENTITY, + &paint_ctx, + ); + assert!(!drawlist.items.is_empty()); + let mut drawn_image_rids = BTreeSet::new(); + for item in &drawlist.items { + let ItemKind::RectFill { paints, .. } = &item.kind else { + continue; + }; + for paint in paints.iter() { + let Paint::Image(image) = paint else { + continue; + }; + let ResourceRef::Rid(rid) = &image.image else { + panic!("social-feed image paint did not retain a runtime RID"); + }; + drawn_image_rids.insert(rid.clone()); + } + } + assert_eq!(drawn_image_rids, expected_image_rids); + assert_eq!(surface.canvas().save_count(), 1, "canvas state leaked"); +} + +/// This is a layout data test, not a visual reftest. One materialized source +/// program is resolved against each viewport; fixed-size post content is +/// expected to clip once the viewport becomes narrower than its anchors. +#[test] +fn social_feed_resolves_one_document_across_the_viewport_matrix() { + let mut sources = SocialFeedSources::default(); + let output = grida_xml_source::materialize( + SourceSnapshot::new(ENTRY_ID, SOURCE_BASE, ENTRY_SOURCE), + &mut sources, + ) + .expect("the responsive Version 3 social feed materializes once"); + assert_eq!(sources.requests, 1); + + let scene = output.document.get(output.document.root).children[0]; + let [left_rail, stories, timeline, suggestions, messages] = + output.document.get(scene).children.as_slice() + else { + panic!("expected the five responsive scene regions") + }; + let [_, navigation, rail_footer] = output.document.get(*left_rail).children.as_slice() else { + panic!("expected the left rail mark, navigation, and footer") + }; + let posts = &output.document.get(*timeline).children; + let first_media = output.document.get(posts[0]).children[1]; + let second_media = output.document.get(posts[1]).children[1]; + let first_art = output.document.get(first_media).children[0]; + let second_art = output.document.get(second_media).children[0]; + + for (width, height) in [ + (1920.0, 1080.0), + (1440.0, 900.0), + (1280.0, 800.0), + (1024.0, 768.0), + (768.0, 1024.0), + (390.0, 844.0), + ] { + let resolved = resolve(&output.document, &options_for(width, height)); + assert!( + resolved.reports.is_empty(), + "{width}x{height}: {:?}", + resolved.reports + ); + + let feed_x = width / 2.0 - 340.0; + assert_eq!(resolved.xywh(scene), (0.0, 0.0, width, height)); + assert_eq!(resolved.xywh(*left_rail), (0.0, 0.0, 480.0, height)); + assert_eq!( + resolved.xywh(*navigation), + (24.0, (height - 430.0) / 2.0 - 1.0, 30.0, 430.0) + ); + assert_eq!( + resolved.xywh(*rail_footer), + (24.0, height - 76.0, 30.0, 52.0) + ); + assert_eq!( + resolved.xywh(*stories), + (width / 2.0 - 412.0, 30.0, 612.0, 108.0) + ); + assert_eq!(resolved.xywh(*timeline), (feed_x, 166.0, 468.0, 1640.0)); + assert_eq!( + resolved.xywh(*suggestions), + (width / 2.0 + 300.0, 32.0, 320.0, 500.0) + ); + assert_eq!( + resolved.xywh(*messages), + (width - 260.0, height - 70.0, 220.0, 48.0) + ); + + assert_eq!(resolved.xywh(posts[0]), (0.0, 0.0, 468.0, 780.0)); + assert_eq!(resolved.xywh(posts[1]), (0.0, 800.0, 468.0, 780.0)); + assert_eq!(resolved.xywh(first_media), (0.0, 52.0, 468.0, 585.0)); + assert_eq!(resolved.xywh(second_media), (0.0, 52.0, 468.0, 585.0)); + assert_eq!( + resolved.world_of(first_art), + Affine::translate(feed_x, 218.0) + ); + assert_eq!( + resolved.world_of(second_art), + Affine::translate(feed_x, 1018.0) + ); + } +} diff --git a/model-v2/engine/tests/grida_xml_source.rs b/model-v2/engine/tests/grida_xml_source.rs new file mode 100644 index 0000000000..934603fd73 --- /dev/null +++ b/model-v2/engine/tests/grida_xml_source.rs @@ -0,0 +1,186 @@ +//! Engine integration proof for linked Grida XML source programs. +//! +//! Component syntax is consumed before the engine boundary. The resolver, +//! display-list builder, and frame entry receive only the ordinary `Document` +//! produced by the source materializer. + +#[allow(dead_code)] +mod support; + +use anchor_engine::drawlist::{build_glyphless, Item, ItemKind}; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml_source::{self, SourceProvider, SourceSnapshot, ValueSelection}; +use anchor_lab::math::Affine; +use anchor_lab::model::{ + Color as ModelColor, CornerSmoothing, Paints, Payload, RectangularCornerRadius, +}; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{surfaces, Color}; + +const ENTRY_SOURCE: &str = include_str!("../rig/fixtures/component-program/entry.grida.xml"); +const COMPONENT_SOURCE: &str = include_str!("../rig/fixtures/component-program/swatch.grida.xml"); +const ENTRY_ID: &str = "fixture:component-program/entry"; +const COMPONENT_ID: &str = "fixture:component-program/swatch"; +const SOURCE_BASE: &str = "fixture:/component-program/"; +const WIDTH: i32 = 96; +const HEIGHT: i32 = 40; + +#[derive(Default)] +struct FixtureSources { + requests: usize, +} + +impl SourceProvider for FixtureSources { + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result { + self.requests += 1; + if containing.identity() != ENTRY_ID || location != "./swatch.grida.xml" { + return Err(format!( + "unexpected fixture reference from {} to {location}", + containing.identity() + )); + } + Ok(SourceSnapshot::new( + COMPONENT_ID, + SOURCE_BASE, + COMPONENT_SOURCE, + )) + } +} + +fn options() -> ResolveOptions { + ResolveOptions { + viewport: (WIDTH as f32, HEIGHT as f32), + ..Default::default() + } +} + +fn assert_rect_fill(item: &Item, node: u32, world: Affine, width: f32, color: u32) { + assert_eq!(item.node, node); + assert_eq!(item.world, world); + assert_eq!( + item.kind, + ItemKind::RectFill { + w: width, + h: 24.0, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + paints: Paints::solid(ModelColor(color)), + } + ); +} + +#[test] +fn version2_component_program_materializes_before_the_component_blind_frame() { + let mut sources = FixtureSources::default(); + let output = grida_xml_source::materialize( + SourceSnapshot::new(ENTRY_ID, SOURCE_BASE, ENTRY_SOURCE), + &mut sources, + ) + .expect("external Version 2 component program materializes"); + + assert_eq!( + sources.requests, 1, + "one external source snapshot is reused" + ); + assert_eq!(output.program.units().len(), 2); + assert!(output.resources.is_empty()); + + // The ordinary model needs no component/use/prop/arg variants. This + // fixture lowers to the implicit document root, one scene container, and + // two independent ordinary component containers. + assert_eq!(output.document.len(), 4); + for id in 0..output.document.capacity() as u32 { + let Some(node) = output.document.get_opt(id) else { + continue; + }; + assert!(matches!(node.payload, Payload::Frame { .. }), "node {id}"); + } + + let scene = output.document.get(output.document.root).children[0]; + let instances = &output.document.get(scene).children; + assert_eq!(instances.len(), 2); + assert_ne!(instances[0], instances[1]); + + let expected_names = ["red-instance", "blue-instance"]; + for (&instance, expected_name) in instances.iter().zip(expected_names) { + let provenance = output.provenance.get(&instance).unwrap(); + let component = provenance.component.as_ref().unwrap(); + assert_eq!(component.source, COMPONENT_ID); + assert_eq!(component.id, "swatch"); + assert_eq!(provenance.use_chain.len(), 1); + assert_eq!(provenance.use_chain[0].name.as_deref(), Some(expected_name)); + } + + assert_eq!(output.specializations.len(), 2); + let expected_tones = ["#FF0000", "#0000FF"]; + for (((specialization, &instance), expected_name), expected_tone) in output + .specializations + .iter() + .zip(instances) + .zip(expected_names) + .zip(expected_tones) + { + assert_eq!(specialization.component.source, COMPONENT_ID); + assert_eq!(specialization.component.id, "swatch"); + assert_eq!( + specialization.use_chain[0].name.as_deref(), + Some(expected_name) + ); + assert_eq!(specialization.props.len(), 1); + let tone = &specialization.props[0]; + assert_eq!(tone.name, "tone"); + assert_eq!(tone.value, expected_tone); + assert!(matches!(tone.selection, ValueSelection::Supplied { .. })); + assert_eq!(tone.materialized_occurrences.len(), 1); + assert_eq!(tone.materialized_occurrences[0].node, instance); + } + + // From this point onward, only the ordinary document crosses the engine + // boundary. Resolve and display-list order prove the two specializations + // remain independent before the pixel observation. + let resolved = resolve(&output.document, &options()); + assert_eq!(resolved.xywh(instances[0]), (8.0, 8.0, 40.0, 24.0)); + assert_eq!(resolved.xywh(instances[1]), (40.0, 8.0, 40.0, 24.0)); + let list = build_glyphless(&output.document, &resolved); + assert_eq!(list.items.len(), 3); + assert_eq!(list.items[0].node, scene); + assert_rect_fill( + &list.items[1], + instances[0], + Affine::translate(8.0, 8.0), + 40.0, + 0xFFFF_0000, + ); + assert_rect_fill( + &list.items[2], + instances[1], + Affine::translate(40.0, 8.0), + 40.0, + 0xFF00_00FF, + ); + + let paint_ctx = PaintCtx::new(None); + let mut surface = surfaces::raster_n32_premul((WIDTH, HEIGHT)).expect("raster surface"); + surface.canvas().clear(Color::BLACK); + let (_, frame_list, _) = frame::render( + surface.canvas(), + &output.document, + &options(), + &Affine::IDENTITY, + &paint_ctx, + ); + assert_eq!(frame_list, list); + assert_eq!(surface.canvas().save_count(), 1, "canvas state leaked"); + + let image = support::RgbaImage::from_image(&surface.image_snapshot()); + assert_eq!(image.at(4, 20), [255, 255, 255, 255]); + assert_eq!(image.at(16, 20), [255, 0, 0, 255]); + assert_eq!(image.at(44, 20), [0, 0, 255, 255]); + assert_eq!(image.at(72, 20), [0, 0, 255, 255]); + assert_eq!(image.at(44, 36), [255, 255, 255, 255]); +} From b8888f5d6de9c7a8da49584a81654b32484f9160 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 16:39:10 +0900 Subject: [PATCH 27/33] feat(model-v2): establish durable effective-value frames --- model-v2/a/lab/src/grida_xml.rs | 5 +- model-v2/a/lab/src/grida_xml_source.rs | 484 +++++- model-v2/a/lab/src/lib.rs | 3 + model-v2/a/lab/src/model.rs | 208 ++- model-v2/a/lab/src/ops.rs | 4 +- model-v2/a/lab/src/pick.rs | 55 +- model-v2/a/lab/src/properties.rs | 1339 +++++++++++++++++ model-v2/a/lab/src/renderability.rs | 531 +++++++ model-v2/a/lab/src/resolve.rs | 471 ++++-- model-v2/a/lab/src/rounded_box.rs | 395 +++++ model-v2/a/lab/src/textir.rs | 232 +-- model-v2/a/lab/tests/arena_pick.rs | 20 +- model-v2/a/lab/tests/generations.rs | 51 + model-v2/a/lab/tests/grida_xml_paints.rs | 44 +- model-v2/a/lab/tests/grida_xml_source_v2.rs | 4 +- model-v2/a/lab/tests/grida_xml_source_v4.rs | 382 +++++ model-v2/a/lab/tests/properties.rs | 989 ++++++++++++ model-v2/a/spike-canvas/Cargo.lock | 18 + model-v2/a/spike-canvas/src/main.rs | 24 +- model-v2/a/spike-canvas/src/shell/app.rs | 38 +- .../rig/fixtures/durable-addressing.grida.xml | 35 + model-v2/engine/src/bin/gate.rs | 28 +- model-v2/engine/src/bin/grida_xml_render.rs | 10 +- model-v2/engine/src/bin/probe.rs | 41 +- model-v2/engine/src/bin/probe_gpu.rs | 4 +- model-v2/engine/src/cache.rs | 212 ++- model-v2/engine/src/damage.rs | 146 +- model-v2/engine/src/drawlist.rs | 202 ++- model-v2/engine/src/frame.rs | 294 +++- model-v2/engine/src/ident.rs | 34 +- model-v2/engine/src/lib.rs | 3 +- model-v2/engine/src/paint.rs | 634 +++++++- model-v2/engine/src/query.rs | 10 +- model-v2/engine/src/replay.rs | 9 +- model-v2/engine/tests/cache.rs | 354 ++++- model-v2/engine/tests/damage.rs | 28 +- model-v2/engine/tests/drawlist.rs | 22 +- model-v2/engine/tests/frame.rs | 471 ++++++ model-v2/engine/tests/grida_xml.rs | 4 +- model-v2/engine/tests/grida_xml_examples.rs | 13 +- model-v2/engine/tests/grida_xml_slots.rs | 11 +- .../engine/tests/grida_xml_social_feed.rs | 9 +- model-v2/engine/tests/grida_xml_source.rs | 95 +- model-v2/engine/tests/ident.rs | 39 +- model-v2/engine/tests/paints.rs | 26 + model-v2/engine/tests/paths.rs | 8 +- model-v2/engine/tests/query.rs | 68 +- model-v2/engine/tests/support/mod.rs | 6 +- model-v2/engine/tests/text.rs | 16 +- model-v2/engine/tests/text_layout.rs | 19 +- model-v2/engine/tests/values.rs | 542 +++++++ 51 files changed, 7880 insertions(+), 810 deletions(-) create mode 100644 model-v2/a/lab/src/properties.rs create mode 100644 model-v2/a/lab/src/renderability.rs create mode 100644 model-v2/a/lab/src/rounded_box.rs create mode 100644 model-v2/a/lab/tests/grida_xml_source_v4.rs create mode 100644 model-v2/a/lab/tests/properties.rs create mode 100644 model-v2/engine/rig/fixtures/durable-addressing.grida.xml create mode 100644 model-v2/engine/tests/frame.rs create mode 100644 model-v2/engine/tests/values.rs diff --git a/model-v2/a/lab/src/grida_xml.rs b/model-v2/a/lab/src/grida_xml.rs index ce1ddb8423..119fad5e3c 100644 --- a/model-v2/a/lab/src/grida_xml.rs +++ b/model-v2/a/lab/src/grida_xml.rs @@ -9,6 +9,7 @@ //! input belong exclusively to [`crate::textir`]. use crate::model::{DocBuilder, Document, NodeId}; +use crate::renderability; use crate::textir; use std::collections::BTreeSet; use std::fmt::Write as _; @@ -204,8 +205,8 @@ pub fn print(doc: &Document) -> Result { }; textir::validate_path_for_write(node).map_err(PrintError::InvalidDocument)?; for stroke in &node.strokes { - textir::validate_stroke_for_write(stroke, &node.payload, node.corner_smoothing) - .map_err(PrintError::InvalidDocument)?; + renderability::validate_stroke(stroke, &node.payload, node.corner_smoothing) + .map_err(|error| PrintError::InvalidDocument(error.to_string()))?; } } if !semantic_document_eq(doc, doc) { diff --git a/model-v2/a/lab/src/grida_xml_source.rs b/model-v2/a/lab/src/grida_xml_source.rs index 8bed241851..0dedb916e0 100644 --- a/model-v2/a/lab/src/grida_xml_source.rs +++ b/model-v2/a/lab/src/grida_xml_source.rs @@ -3,8 +3,9 @@ //! [`crate::grida_xml`] remains the exact Draft 0 `&str -> Document` //! contract. This sibling owns source identity, dependency resolution, //! Version 1 component linking, Version 2 typed scalar specialization, Version -//! 3 named static slot projection, and lowering back to that ordinary Draft 0 -//! contract. It performs no filesystem or resource I/O. +//! 3 named static slot projection, Version 4 durable authored addresses, and +//! lowering back to that ordinary Draft 0 contract. It performs no filesystem +//! or resource I/O. // Source errors are cold-path values that intentionally retain structured // resolution, specialization, and projection provenance. Boxing every result @@ -12,11 +13,11 @@ #![allow(clippy::result_large_err)] use crate::grida_xml; -use crate::model::{Color, Document, NodeId, Payload, ShapeDesc}; +use crate::model::{Color, Document, NodeId, NodeKey, Payload, ShapeDesc}; use quick_xml::events::attributes::Attributes; use quick_xml::events::{BytesDecl, BytesStart, Event}; use quick_xml::Reader; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{btree_map, BTreeMap, BTreeSet}; use std::fmt::Write as _; use std::sync::Arc; @@ -87,6 +88,7 @@ pub enum SourceVersion { Draft1, Version2, Version3, + Version4, } impl SourceVersion { @@ -96,12 +98,20 @@ impl SourceVersion { SourceVersion::Draft1 => "1", SourceVersion::Version2 => "2", SourceVersion::Version3 => "3", + SourceVersion::Version4 => "4", } } } fn has_scalar_specialization(version: SourceVersion) -> bool { - matches!(version, SourceVersion::Version2 | SourceVersion::Version3) + matches!( + version, + SourceVersion::Version2 | SourceVersion::Version3 | SourceVersion::Version4 + ) +} + +fn has_static_slots(version: SourceVersion) -> bool { + matches!(version, SourceVersion::Version3 | SourceVersion::Version4) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -132,6 +142,8 @@ pub struct AuthoredUseSite { pub struct SourceError { pub phase: ErrorPhase, pub source: String, + /// Immediate local source location when the failing syntax owns one. + pub span: Option, pub component: Option, pub use_chain: Vec, /// The immediate authored edge when resolution failed before a canonical @@ -154,6 +166,7 @@ impl SourceError { Self { phase: ErrorPhase::Parse, source: source.into(), + span: None, component: None, use_chain: vec![], authored_use: None, @@ -163,6 +176,12 @@ impl SourceError { } } + fn parse_at(source: &str, span: SourceSpan, message: impl Into) -> Self { + let mut error = Self::parse(source, message); + error.span = Some(span); + error + } + fn at_phase( phase: ErrorPhase, source: &str, @@ -173,6 +192,7 @@ impl SourceError { Self { phase, source: source.into(), + span: None, component: component.map(str::to_owned), use_chain: use_chain.to_vec(), authored_use: None, @@ -206,6 +226,9 @@ impl SourceError { impl std::fmt::Display for SourceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "grida_xml_source {:?} in {}", self.phase, self.source)?; + if let Some(span) = self.span { + write!(f, ":{}", span.start)?; + } if let Some(component) = &self.component { write!(f, "#{component}")?; } @@ -255,6 +278,80 @@ impl std::fmt::Display for ComponentIdentity { } } +/// Lexical owner of one Version 4 render member or component occurrence. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum AuthoredOwner { + Scene { source: String }, + Component(ComponentIdentity), +} + +/// A component definition's render root is already named by its export and +/// therefore uses a structural identity. Every other Version 4 render member +/// carries an authored lowercase-kebab id. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum AuthoredMemberId { + ComponentRoot, + Id(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct AuthoredMember { + pub owner: AuthoredOwner, + pub id: AuthoredMemberId, +} + +/// One durable authored `` occurrence in an outer-to-inner path. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct AuthoredUseOccurrence { + pub owner: AuthoredOwner, + pub id: String, +} + +/// Canonical address of one materialized ordinary node. +/// +/// Source spans, names, element positions, and arena slots are deliberately +/// absent. `use_path` is ordered outermost to innermost. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MaterializedNodeAddress { + pub member: AuthoredMember, + pub use_path: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddressLookupError { + UnknownAddress { address: MaterializedNodeAddress }, + StaleAddress { address: MaterializedNodeAddress }, + UnknownNode { node: NodeKey }, + NodeHasNoDurableAddress { node: NodeKey }, +} + +impl std::fmt::Display for AddressLookupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AddressLookupError::UnknownAddress { address } => { + write!(f, "unknown materialized address {address:?}") + } + AddressLookupError::StaleAddress { address } => { + write!( + f, + "materialized address no longer names a live node: {address:?}" + ) + } + AddressLookupError::UnknownNode { node } => { + write!( + f, + "node key is not live in this materialized program: {node:?}" + ) + } + AddressLookupError::NodeHasNoDurableAddress { node } => { + write!(f, "node has no durable Version 4 address: {node:?}") + } + } + } +} + +impl std::error::Error for AddressLookupError {} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct UseSite { pub source: String, @@ -303,7 +400,7 @@ pub struct ValueOrigin { pub source: String, pub span: SourceSpan, /// The XML-decoded literal at its ultimate declaration/default or argument - /// site, before Version 2/3 brace escapes are collapsed. + /// site, before Version 2–4 brace escapes are collapsed. pub authored: String, } @@ -331,6 +428,9 @@ pub struct BindingTarget { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MaterializedBindingOccurrence { pub target: BindingTarget, + /// Slot in the initial materialization snapshot. This provenance record + /// is not a live runtime handle; after document mutation, compile through + /// the Version 4 address map rather than reusing this bare slot. pub node: NodeId, } @@ -397,6 +497,8 @@ pub struct SlotProjectionErrorSite { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MaterializedSlotAssignment { pub site: SlotAssignmentSite, + /// Slot in the initial materialization snapshot; see + /// [`MaterializedBindingOccurrence::node`]. pub node: NodeId, } @@ -468,10 +570,94 @@ impl SourceProgram { pub struct MaterializedProgram { pub document: Document, pub program: SourceProgram, + /// Initial-materialization source snapshots keyed by arena slot. These + /// records support diagnostics for that lowering pass; they are not live + /// identities after mutation. Version 4 live lookup uses the private + /// generation-stamped address maps exposed by [`Self::addresses`], + /// [`Self::node_for_address`], and [`Self::address_for_node`]. pub provenance: BTreeMap, pub resources: Vec, pub specializations: Vec, pub slot_projections: Vec, + addresses_by_node: BTreeMap, + nodes_by_address: BTreeMap, +} + +/// Live durable addresses remaining in a materialized program. +/// +/// `MaterializedProgram::document` is intentionally mutable. Removing or +/// replacing a materialized node leaves its retained source address stale; +/// this iterator omits that entry while [`MaterializedProgram::node_for_address`] +/// continues to report the precise stale-address failure for direct lookup. +pub struct LiveAddresses<'a> { + document: &'a Document, + inner: btree_map::Iter<'a, MaterializedNodeAddress, NodeKey>, +} + +impl<'a> Iterator for LiveAddresses<'a> { + type Item = (&'a MaterializedNodeAddress, NodeKey); + + fn next(&mut self) -> Option { + self.inner + .by_ref() + .find(|(_, node)| self.document.contains_key(**node)) + .map(|(address, node)| (address, *node)) + } + + fn size_hint(&self) -> (usize, Option) { + let len = self.len(); + (len, Some(len)) + } +} + +impl ExactSizeIterator for LiveAddresses<'_> { + fn len(&self) -> usize { + self.inner + .clone() + .filter(|(_, node)| self.document.contains_key(**node)) + .count() + } +} + +impl std::iter::FusedIterator for LiveAddresses<'_> {} + +impl MaterializedProgram { + /// Enumerate only addresses whose generation-stamped node remains live. + pub fn addresses(&self) -> LiveAddresses<'_> { + LiveAddresses { + document: &self.document, + inner: self.nodes_by_address.iter(), + } + } + + pub fn node_for_address( + &self, + address: &MaterializedNodeAddress, + ) -> Result { + let node = self.nodes_by_address.get(address).copied().ok_or_else(|| { + AddressLookupError::UnknownAddress { + address: address.clone(), + } + })?; + if !self.document.contains_key(node) { + return Err(AddressLookupError::StaleAddress { + address: address.clone(), + }); + } + Ok(node) + } + + pub fn address_for_node( + &self, + node: NodeKey, + ) -> Result<&MaterializedNodeAddress, AddressLookupError> { + if !self.document.contains_key(node) { + return Err(AddressLookupError::UnknownNode { node }); + } + self.addresses_by_node + .get(&node) + .ok_or(AddressLookupError::NodeHasNoDurableAddress { node }) + } } #[derive(Debug, Clone)] @@ -743,6 +929,59 @@ fn valid_component_id(value: &str) -> bool { }) } +fn validate_version4_ids( + source: &str, + owner: &str, + root: &Element, + component_root: bool, +) -> Result<(), SourceError> { + fn visit( + source: &str, + owner: &str, + element: &Element, + skip_current: bool, + seen: &mut BTreeSet, + ) -> Result<(), SourceError> { + let requires_id = + !skip_current && (is_render_element(&element.name) || element.name == "use"); + if requires_id { + let id = element.attribute("id").ok_or_else(|| { + SourceError::parse_at( + source, + element.span, + format!( + "Version 4 <{}> requires a durable lowercase-kebab `id` in {owner}", + element.name + ), + ) + })?; + if !valid_component_id(id) { + return Err(SourceError::parse_at( + source, + element.span, + format!( + "Version 4 id `{id}` on <{}> must be lowercase kebab-case", + element.name + ), + )); + } + if !seen.insert(id.to_owned()) { + return Err(SourceError::parse_at( + source, + element.span, + format!("duplicate Version 4 member/use id `{id}` in {owner}"), + )); + } + } + for child in element.element_children() { + visit(source, owner, child, false, seen)?; + } + Ok(()) + } + + visit(source, owner, root, component_root, &mut BTreeSet::new()) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum BindingSegment { Literal(String), @@ -1250,6 +1489,7 @@ pub fn parse_source(snapshot: SourceSnapshot) -> Result "1" => SourceVersion::Draft1, "2" => SourceVersion::Version2, "3" => SourceVersion::Version3, + "4" => SourceVersion::Version4, version => { return Err(SourceError::parse( snapshot.identity(), @@ -1304,6 +1544,14 @@ pub fn parse_source(snapshot: SourceSnapshot) -> Result format!("duplicate component `{id}` in the same source unit"), )); } + if version == SourceVersion::Version4 { + validate_version4_ids( + snapshot.identity(), + &format!("component {}#{id}", snapshot.identity()), + element, + true, + )?; + } components.push(parse_component(snapshot.identity(), version, element)?); } Content::Element(element) if element.name == "container" => { @@ -1313,6 +1561,14 @@ pub fn parse_source(snapshot: SourceSnapshot) -> Result "a source unit may contain at most one scene root", )); } + if version == SourceVersion::Version4 { + validate_version4_ids( + snapshot.identity(), + &format!("scene {}", snapshot.identity()), + element, + false, + )?; + } validate_render_tree(snapshot.identity(), version, element, false, false)?; } Content::Element(element) => { @@ -1412,7 +1668,7 @@ fn parse_component( if !has_scalar_specialization(version) { return Err(SourceError::parse( source, - " requires Grida XML Version 2 or 3", + " requires Grida XML Version 2, 3, or 4", )); } if body_started { @@ -1477,16 +1733,19 @@ fn validate_slot_declaration( element: &Element, inside_component: bool, ) -> Result<(), SourceError> { - if version != SourceVersion::Version3 { + if !has_static_slots(version) { return Err(SourceError::parse( source, - " requires Grida XML Version 3", + " requires Grida XML Version 3 or 4", )); } if !inside_component { return Err(SourceError::parse( source, - " is valid only in a Version 3 component render body", + format!( + " is valid only in a Version {} component render body", + version.as_str() + ), )); } if !accepts_render_children(&parent.name) { @@ -1522,21 +1781,31 @@ fn validate_slot_assignment_attribute( if slot.is_some() { return Err(SourceError::parse( source, - "`slot` is valid only on a direct Version 3 render assignment", + format!( + "`slot` is valid only on a direct Version {} render assignment", + if version == SourceVersion::Version4 { + "4" + } else { + "3" + } + ), )); } return Ok(()); } - if version != SourceVersion::Version3 { + if !has_static_slots(version) { return Err(SourceError::parse( source, - "render slot assignments require Grida XML Version 3", + "render slot assignments require Grida XML Version 3 or 4", )); } let name = slot.ok_or_else(|| { SourceError::parse( source, - "each direct Version 3 render assignment requires `slot`", + format!( + "each direct Version {} render assignment requires `slot`", + version.as_str() + ), ) })?; if !valid_component_id(name) { @@ -1648,6 +1917,9 @@ fn validate_use( if attribute.name == "slot" && assignment_root { continue; } + if attribute.name == "id" && version == SourceVersion::Version4 { + continue; + } if !allowed.contains(&attribute.name.as_str()) { return Err(SourceError::parse( source, @@ -1715,7 +1987,7 @@ fn validate_use( } } } - SourceVersion::Version3 => { + SourceVersion::Version3 | SourceVersion::Version4 => { let mut names = BTreeSet::new(); let mut assignments_started = false; for child in &element.children { @@ -1724,7 +1996,10 @@ fn validate_use( Content::Text(_) => { return Err(SourceError::parse( source, - "Version 3 accepts leading children followed by direct render assignments", + format!( + "Version {} accepts leading children followed by direct render assignments", + version.as_str() + ), )); } Content::Element(argument) @@ -1759,7 +2034,10 @@ fn validate_use( Content::Element(argument) if argument.name == "arg" => { return Err(SourceError::parse( source, - "Version 3 children must precede every render assignment", + format!( + "Version {} children must precede every render assignment", + version.as_str() + ), )); } Content::Element(assignment) => { @@ -1778,8 +2056,8 @@ fn validate_use( return Err(SourceError::parse( source, format!( - "Version 3 render assignments must be direct render roots, found <{}>", - assignment.name + "Version {} render assignments must be direct render roots, found <{}>", + version.as_str(), assignment.name ), )); } @@ -2081,7 +2359,12 @@ fn validate_unit_as_draft0_templates(unit: &SourceUnit) -> Result<(), SourceErro if let Some(scene) = &unit.scene { validate_template( unit.snapshot.identity(), - template_element(scene, false, &BTreeMap::new()), + template_element( + scene, + false, + unit.version == SourceVersion::Version4, + &BTreeMap::new(), + ), )?; validate_assignment_templates( unit.snapshot.identity(), @@ -2102,12 +2385,17 @@ fn validate_assignment_templates( element: &Element, witnesses: &BTreeMap, ) -> Result<(), SourceError> { - if element.name == "use" && version == SourceVersion::Version3 { + if element.name == "use" && has_static_slots(version) { for assignment in element .element_children() .filter(|element| element.name != "arg") { - let mut projected = template_element(assignment, false, witnesses); + let mut projected = template_element( + assignment, + false, + version == SourceVersion::Version4, + witnesses, + ); projected.attributes.retain(|attribute| { !matches!(attribute.name.as_str(), "slot" | "flow" | "grow" | "align") }); @@ -2211,7 +2499,12 @@ fn validate_component_templates( *states += 1; let result = validate_template( unit.snapshot.identity(), - template_element(&component.element, true, witnesses), + template_element( + &component.element, + true, + unit.version == SourceVersion::Version4, + witnesses, + ), ) .and_then(|_| { validate_assignment_templates( @@ -2280,7 +2573,7 @@ fn validate_component_templates( fn template_scalar_value(attribute: &Attribute, witnesses: &BTreeMap) -> String { match scan_attribute(&attribute.value) - .expect("Version 2/3 bindings validated before template lowering") + .expect("Version 2–4 bindings validated before template lowering") { AttributeExpression::Literal(value) => value, AttributeExpression::Binding(name) => witnesses @@ -2292,7 +2585,7 @@ fn template_scalar_value(attribute: &Attribute, witnesses: &BTreeMap) -> String { scan_bindings(value) - .expect("Version 2/3 text bindings validated before template lowering") + .expect("Version 2–4 text bindings validated before template lowering") .into_iter() .map(|segment| match segment { BindingSegment::Literal(value) => value, @@ -2307,6 +2600,7 @@ fn template_text(value: &str, witnesses: &BTreeMap) -> String { fn template_element( element: &Element, component_root: bool, + strip_durable_ids: bool, witnesses: &BTreeMap, ) -> Element { if element.name == "use" { @@ -2340,7 +2634,13 @@ fn template_element( let attributes = element .attributes .iter() - .filter(|attribute| attribute.name != "slot" && (!component_root || attribute.name != "id")) + .filter(|attribute| { + attribute.name != "slot" + && (!component_root || attribute.name != "id") + && (!strip_durable_ids + || attribute.name != "id" + || !is_render_element(&element.name)) + }) .map(|attribute| Attribute { name: attribute.name.clone(), value: if witnesses.is_empty() @@ -2364,7 +2664,10 @@ fn template_element( } Content::Text(text) => Some(Content::Text(text.clone())), Content::Element(element) => Some(Content::Element(template_element( - element, false, witnesses, + element, + false, + strip_durable_ids, + witnesses, ))), }) .collect(); @@ -2382,6 +2685,7 @@ struct ExpandedElement { attributes: Vec, children: Vec, provenance: NodeProvenance, + address: Option, bindings: Vec, slot_assignments: Vec, } @@ -2717,6 +3021,7 @@ struct Linker<'a, P> { resolutions: BTreeMap<(String, String), String>, component_stack: Vec, use_chain: Vec, + durable_use_path: Vec>, resources: Vec, resource_keys: BTreeMap<(String, String), String>, specializations: Vec, @@ -2735,6 +3040,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { resolutions: BTreeMap::new(), component_stack: vec![], use_chain: vec![], + durable_use_path: vec![], resources: vec![], resource_keys: BTreeMap::new(), specializations: vec![], @@ -2791,11 +3097,15 @@ impl<'a, P: SourceProvider> Linker<'a, P> { )); } let mut provenance = BTreeMap::new(); + let mut addresses_by_node = BTreeMap::new(); + let mut nodes_by_address = BTreeMap::new(); attach_node_provenance( &root, &document, authored_roots[0], &mut provenance, + &mut addresses_by_node, + &mut nodes_by_address, &mut self.specializations, &mut self.slot_projections, ) @@ -2811,6 +3121,15 @@ impl<'a, P: SourceProvider> Linker<'a, P> { "materialized source contains nodes unreachable through the provenance tree", )); } + if version == SourceVersion::Version4 && addresses_by_node.len() + 1 != document.len() { + return Err(SourceError::at_phase( + ErrorPhase::Materialize, + &self.entry, + None, + &[], + "Version 4 materialization requires one durable address for every ordinary node except the implicit document root", + )); + } let slot_projections = self .slot_projections .into_iter() @@ -2856,6 +3175,46 @@ impl<'a, P: SourceProvider> Linker<'a, P> { resources: self.resources, specializations: self.specializations, slot_projections, + addresses_by_node, + nodes_by_address, + }) + } + + fn durable_address( + &self, + context: &ExpansionContext, + element: &Element, + ) -> Option { + if context.version != SourceVersion::Version4 + || !(is_render_element(&element.name) || element.name == "component") + { + return None; + } + let owner = context + .component + .clone() + .map(AuthoredOwner::Component) + .unwrap_or_else(|| AuthoredOwner::Scene { + source: context.source.clone(), + }); + let id = if element.name == "component" { + AuthoredMemberId::ComponentRoot + } else { + AuthoredMemberId::Id( + element + .attribute("id") + .expect("Version 4 render ids validated") + .to_owned(), + ) + }; + let use_path = self + .durable_use_path + .iter() + .cloned() + .collect::>>()?; + Some(MaterializedNodeAddress { + member: AuthoredMember { owner, id }, + use_path, }) } @@ -2871,12 +3230,19 @@ impl<'a, P: SourceProvider> Linker<'a, P> { component: context.component.clone(), use_chain: self.use_chain.clone(), }; + let address = self.durable_address(context, element); let mut attributes = Vec::with_capacity(element.attributes.len()); let mut bindings = vec![]; for attribute in &element.attributes { if attribute.name == "slot" { continue; } + if attribute.name == "id" + && context.version == SourceVersion::Version4 + && (is_render_element(&element.name) || element.name == "component") + { + continue; + } let mut scalar = self.materialize_attribute( &context.source, context.version, @@ -2959,6 +3325,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { attributes, children, provenance, + address, bindings, slot_assignments: vec![], }) @@ -2979,7 +3346,10 @@ impl<'a, P: SourceProvider> Linker<'a, P> { .as_ref() .map(|component| component.id.as_str()), &self.use_chain, - format!("slot `{name}` has no containing Version 3 component use"), + format!( + "slot `{name}` has no containing Version {} component use", + context.version.as_str() + ), ) })?; if context.component.as_ref() != Some(&instantiation.component) { @@ -3094,6 +3464,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { SourceVersion::Version3, SourceVersion::Draft1 | SourceVersion::Version2 | SourceVersion::Version3 ) + | (SourceVersion::Version4, SourceVersion::Version4) ); if !compatible { return Err(SourceError::at_phase( @@ -3134,6 +3505,20 @@ impl<'a, P: SourceProvider> Linker<'a, P> { target: target.clone(), name, }; + let durable_use = + (caller_version == SourceVersion::Version4).then(|| AuthoredUseOccurrence { + owner: caller + .component + .clone() + .map(AuthoredOwner::Component) + .unwrap_or_else(|| AuthoredOwner::Scene { + source: caller.source.clone(), + }), + id: use_element + .attribute("id") + .expect("Version 4 use ids validated") + .to_owned(), + }); let mut current_chain = self.use_chain.clone(); current_chain.push(site.clone()); if let Some(index) = self.component_stack.iter().position(|item| item == &target) { @@ -3178,14 +3563,19 @@ impl<'a, P: SourceProvider> Linker<'a, P> { .filter(|element| element.name != "arg") .cloned() .collect::>(); - if !assignment_elements.is_empty() && target_version != SourceVersion::Version3 { + if !assignment_elements.is_empty() && !has_static_slots(target_version) { + let required_target = if caller_version == SourceVersion::Version4 { + "Version 4" + } else { + "Version 3" + }; return Err(SourceError::at_phase( ErrorPhase::Link, source, Some(component_id), ¤t_chain, format!( - "render slot assignments require a Version 3 target; `{component_id}` is Version {}", + "render slot assignments require a {required_target} target; `{component_id}` is Version {}", target_version.as_str() ), )); @@ -3194,7 +3584,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { for assignment in assignment_elements { let name = assignment .attribute("slot") - .expect("Version 3 assignment source validated"); + .expect("slot assignment source validated"); if definition.slot(name).is_none() { return Err(SourceError::at_phase( ErrorPhase::Link, @@ -3220,7 +3610,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { context: caller.clone(), }); } - let slots = (target_version == SourceVersion::Version3).then(|| { + let slots = has_static_slots(target_version).then(|| { Arc::new(SlotInstantiation { component: target.clone(), use_chain: current_chain.clone(), @@ -3229,6 +3619,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { }); self.component_stack.push(target.clone()); self.use_chain.push(site); + self.durable_use_path.push(durable_use); let environment = self.specialize_component( source, caller_version, @@ -3251,6 +3642,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { slots, ) }); + self.durable_use_path.pop(); self.use_chain.pop(); self.component_stack.pop(); result @@ -3296,7 +3688,7 @@ impl<'a, P: SourceProvider> Linker<'a, P> { { let value = argument.attribute("value").expect("argument validated"); if let AttributeExpression::Binding(prop) = scan_attribute(value) - .expect("Version 2/3 argument bindings validated before expansion") + .expect("Version 2–4 argument bindings validated before expansion") { root.bindings.push(ExpandedBinding { specialization: outer_specialization, @@ -3962,8 +4354,10 @@ impl<'a, P: SourceProvider> Linker<'a, P> { } /// Link and materialize one render entry through a host-supplied pure source -/// provider. Draft 0 input is accepted as a one-unit program. Version 1/2/3 -/// input resolves only the closure reachable from the entry scene. +/// provider. Draft 0 input is accepted as a one-unit program. Version 1/2/3/4 +/// input resolves only the closure reachable from the entry scene. Version 4 +/// links only Version 4 sources and requires every ordinary materialized node +/// except the implicit document root to have one durable address. pub fn materialize( entry: SourceSnapshot, provider: &mut P, @@ -4029,6 +4423,8 @@ fn attach_node_provenance( document: &Document, node_id: NodeId, out: &mut BTreeMap, + addresses_by_node: &mut BTreeMap, + nodes_by_address: &mut BTreeMap, specializations: &mut [SpecializationProvenance], slot_projections: &mut [PendingSlotProjection], ) -> Result<(), String> { @@ -4053,6 +4449,22 @@ fn attach_node_provenance( "materialized node {node_id} received duplicate provenance" )); } + if let Some(address) = &element.address { + let key = document + .key_of(node_id) + .ok_or_else(|| format!("materialized node {node_id} is not live"))?; + if addresses_by_node.insert(key, address.clone()).is_some() { + return Err(format!( + "materialized node {node_id} received more than one durable address" + )); + } + if let Some(previous) = nodes_by_address.insert(address.clone(), key) { + return Err(format!( + "durable address {address:?} is ambiguous between nodes {} and {node_id}", + previous.id() + )); + } + } for marker in &element.slot_assignments { let projection = slot_projections .get_mut(marker.projection) @@ -4109,6 +4521,8 @@ fn attach_node_provenance( document, child_id, out, + addresses_by_node, + nodes_by_address, specializations, slot_projections, )?; diff --git a/model-v2/a/lab/src/lib.rs b/model-v2/a/lab/src/lib.rs index 58adf40f97..eb77c4c310 100644 --- a/model-v2/a/lab/src/lib.rs +++ b/model-v2/a/lab/src/lib.rs @@ -16,7 +16,10 @@ pub mod model; pub mod ops; pub mod path; pub mod pick; +pub mod properties; +pub mod renderability; pub mod resolve; +pub mod rounded_box; pub mod svgout; pub mod text_layout; pub mod textir; diff --git a/model-v2/a/lab/src/model.rs b/model-v2/a/lab/src/model.rs index eaf3cdcb72..998384bbb6 100644 --- a/model-v2/a/lab/src/model.rs +++ b/model-v2/a/lab/src/model.rs @@ -15,10 +15,58 @@ use crate::math::Affine; pub use crate::path::FillRule; use crate::path::PathArtifact; use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; pub type NodeId = u32; +static NEXT_ARENA_ID: AtomicU64 = AtomicU64::new(1); + +/// Process-local identity of one concrete document arena. +/// +/// This is a runtime storage fact, not authored document identity and never a +/// serialized value. A document clone receives a fresh arena id so keys cannot +/// be carried into a clone that may subsequently diverge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ArenaId(u64); + +fn fresh_arena_id() -> ArenaId { + let id = NEXT_ARENA_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| { + next.checked_add(1) + }) + .expect("document arena identity space exhausted"); + ArenaId(id) +} + +/// One live arena identity. +/// +/// A bare [`NodeId`] is only a slot. `NodeKey` also carries the slot's +/// generation, so retaining a key across deletion can never alias a later +/// occupant of the same slot. Keys are minted and validated only by +/// [`Document`]; their fields stay opaque so callers cannot manufacture a +/// supposedly-live identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NodeKey { + arena: ArenaId, + id: NodeId, + generation: u32, +} + +impl NodeKey { + pub fn arena(self) -> ArenaId { + self.arena + } + + pub fn id(self) -> NodeId { + self.id + } + + pub fn generation(self) -> u32 { + self.generation + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum AnchorEdge { Start, @@ -1365,7 +1413,7 @@ pub struct Node { /// tier is SOA in `resolve::Resolved`). This kills the O(n) `parent_of` /// scan the map-based lab store had — the same defect class as the /// legacy engine's pointer-chasing lookups. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct Document { slots: Vec>, /// Parent link column, index-aligned with `slots`. Maintained by the @@ -1374,16 +1422,30 @@ pub struct Document { /// Generation per slot (ENG-2.3), index-aligned with `slots`: /// incremented when a slot is tombstoned, so a future reused slot /// cannot alias a prior node's cache identity (`engine::ident::Key`). - /// The arena is append-only today (`add_child` asserts a fresh slot), - /// so every live node sits at generation 0 and the column is the - /// dormant guard the cache tier will key on. A storage artifact — + /// `add_child` accepts only a vacant slot and preserves its current + /// generation, so reuse creates a distinct live key. A storage artifact /// ignored by the semantic `PartialEq`, like tombstones. generations: Vec, + /// Runtime identity of this exact arena. Excluded from semantic equality + /// and refreshed by `Clone`; see [`NodeKey`]. + arena: ArenaId, /// Scene root: a frame whose bindings span the viewport (a.md §3 — the /// InitialContainer regularized). pub root: NodeId, } +impl Clone for Document { + fn clone(&self) -> Self { + Self { + slots: self.slots.clone(), + parents: self.parents.clone(), + generations: self.generations.clone(), + arena: fresh_arena_id(), + root: self.root, + } + } +} + /// Semantic equality: same root, same alive nodes, same parenting. /// Tombstones and arena capacity are storage artifacts, not document /// content — MM-7 (add then delete restores) holds by this definition. @@ -1409,6 +1471,27 @@ impl Document { pub fn get_opt(&self, id: NodeId) -> Option<&Node> { self.slots.get(id as usize).and_then(|s| s.as_ref()) } + + /// Mint a generation-stamped identity only for a currently live slot. + pub fn key_of(&self, id: NodeId) -> Option { + self.get_opt(id).map(|_| NodeKey { + arena: self.arena, + id, + generation: self.generations[id as usize], + }) + } + + /// Whether `key` still identifies the same live arena occupant. + pub fn contains_key(&self, key: NodeKey) -> bool { + key.arena == self.arena + && self.get_opt(key.id).is_some() + && self.generations.get(key.id as usize).copied() == Some(key.generation) + } + + /// Read one node only when both its slot and generation remain live. + pub fn node_for_key(&self, key: NodeKey) -> Option<&Node> { + self.contains_key(key).then(|| self.get(key.id)) + } /// O(1) — the parent column, not a scan. pub fn parent_of(&self, id: NodeId) -> Option { self.parents.get(id as usize).copied().flatten() @@ -1425,8 +1508,9 @@ impl Document { self.slots.len() } - /// The slot's generation (ENG-2.3) — pair with the [`NodeId`] to form - /// an `engine::ident::Key`. Out-of-range ids read 0. + /// Diagnostic/test observation of the slot's generation (ENG-2.3). + /// Runtime identity must be minted through [`Self::key_of`], which also + /// includes the arena incarnation. Out-of-range ids read 0. pub fn gen_of(&self, id: NodeId) -> u32 { self.generations.get(id as usize).copied().unwrap_or(0) } @@ -1444,9 +1528,12 @@ impl Document { /// place a slot dies — both remove paths funnel through it, so the /// generation guard can never be bypassed. fn vacate(&mut self, id: NodeId) { + let next_generation = self.generations[id as usize] + .checked_add(1) + .expect("node generation space exhausted"); self.slots[id as usize] = None; self.parents[id as usize] = None; - self.generations[id as usize] += 1; + self.generations[id as usize] = next_generation; } /// Structural insert: registers the node at `node.id` and attaches it @@ -1460,7 +1547,10 @@ impl Document { ); let id = node.id; self.ensure_slot(id); - debug_assert!(self.slots[id as usize].is_none(), "slot occupied"); + assert!( + self.slots[id as usize].is_none(), + "cannot replace occupied node slot {id} without vacating it" + ); self.slots[id as usize] = Some(node); self.parents[id as usize] = Some(parent); self.get_mut(parent).children.push(id); @@ -1470,11 +1560,24 @@ impl Document { /// Structural remove: detaches `id` from its parent and tombstones the /// whole subtree. Returns the number of nodes removed. pub fn remove_subtree(&mut self, id: NodeId) -> usize { + self.assert_subtree_generations_available(id); if let Some(p) = self.parent_of(id) { self.get_mut(p).children.retain(|c| *c != id); } self.tombstone_rec(id) } + + fn assert_subtree_generations_available(&self, id: NodeId) { + let Some(node) = self.get_opt(id) else { + return; + }; + self.generations[id as usize] + .checked_add(1) + .expect("node generation space exhausted"); + for &child in &node.children { + self.assert_subtree_generations_available(child); + } + } fn tombstone_rec(&mut self, id: NodeId) -> usize { let children = match self.get_opt(id) { Some(n) => n.children.clone(), @@ -1543,6 +1646,7 @@ impl Document { slots, parents, generations: vec![0; cap], + arena: fresh_arena_id(), root, } } @@ -1635,3 +1739,91 @@ impl Default for DocBuilder { Self::new() } } + +#[cfg(test)] +mod identity_tests { + use super::*; + + #[test] + fn exhausted_generation_fails_before_detaching_the_subtree() { + let mut builder = DocBuilder::new(); + let child = builder.add( + 0, + Header::new(SizeIntent::Fixed(1.0), SizeIntent::Fixed(1.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut document = builder.build(); + document.generations[child as usize] = u32::MAX; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + document.remove_subtree(child); + })); + assert!(result.is_err()); + assert!(document.get_opt(child).is_some()); + assert_eq!(document.parent_of(child), Some(document.root)); + assert_eq!(document.get(document.root).children, [child]); + assert_eq!(document.gen_of(child), u32::MAX); + } + + #[test] + fn exhausted_generation_fails_before_vacating_a_single_slot() { + let mut builder = DocBuilder::new(); + let child = builder.add( + 0, + Header::new(SizeIntent::Fixed(1.0), SizeIntent::Fixed(1.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut document = builder.build(); + document.generations[child as usize] = u32::MAX; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + document.remove_slot(child); + })); + assert!(result.is_err()); + assert!(document.get_opt(child).is_some()); + assert_eq!(document.parent_of(child), Some(document.root)); + assert_eq!(document.gen_of(child), u32::MAX); + } + + #[test] + fn occupied_slot_insertion_fails_without_aliasing_the_retained_key() { + let mut builder = DocBuilder::new(); + let child = builder.add( + 0, + Header::new(SizeIntent::Fixed(1.0), SizeIntent::Fixed(2.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut document = builder.build(); + let retained_key = document.key_of(child).unwrap(); + let retained_node = document.get(child).clone(); + let retained_children = document.get(document.root).children.clone(); + let replacement = Node { + id: child, + header: Header::new(SizeIntent::Fixed(30.0), SizeIntent::Fixed(40.0)), + payload: Payload::Shape { + desc: ShapeDesc::Ellipse, + }, + children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + fills: Paints::default(), + strokes: vec![], + }; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + document.add_child(document.root, replacement); + })); + assert!(result.is_err()); + assert!(document.contains_key(retained_key)); + assert_eq!(document.node_for_key(retained_key), Some(&retained_node)); + assert_eq!(document.parent_of(child), Some(document.root)); + assert_eq!(document.get(document.root).children, retained_children); + assert_eq!(document.gen_of(child), retained_key.generation()); + } +} diff --git a/model-v2/a/lab/src/ops.rs b/model-v2/a/lab/src/ops.rs index 4ffa50ec69..e477196d43 100644 --- a/model-v2/a/lab/src/ops.rs +++ b/model-v2/a/lab/src/ops.rs @@ -263,8 +263,8 @@ impl ResizeDrag { /// Writes per call: extent (1) + position (1, free context only — under /// flex, layout owns position and the write set shrinks to extent+flip) /// + flip (1, only when the crossing state changed). Crossing back across -/// the anchor toggles the flip off again: a drag out and back is the -/// identity on the document. +/// the anchor toggles the flip off again: a drag out and back is the +/// identity on the document. pub fn resize_drag( doc: &mut Document, resolved: &Resolved, diff --git a/model-v2/a/lab/src/pick.rs b/model-v2/a/lab/src/pick.rs index 3fcba5a6f1..1c59a54419 100644 --- a/model-v2/a/lab/src/pick.rs +++ b/model-v2/a/lab/src/pick.rs @@ -9,47 +9,51 @@ use crate::model::*; use crate::resolve::Resolved; +use crate::rounded_box; /// Hairline kinds (a zero-height line, a zero-extent box mid-gesture) get /// a symmetric hit slop in LOCAL px so they stay grabbable. const HAIRLINE_SLOP: f32 = 3.0; -pub fn pick(doc: &Document, resolved: &Resolved, x: f32, y: f32) -> Option { - hit_subtree(doc, resolved, doc.root, (x, y)).map(|hit| promote(doc, hit)) +/// Hit-test one immutable resolved frame. This is the canonical narrowphase; +/// it has no authored document or effective-value input that can be paired +/// with the wrong frame. +pub fn pick(resolved: &Resolved, x: f32, y: f32) -> Option { + hit_subtree(resolved, resolved.query_root()?, (x, y)) } -fn hit_subtree(doc: &Document, r: &Resolved, id: NodeId, p: (f32, f32)) -> Option { +fn hit_subtree(r: &Resolved, id: NodeId, p: (f32, f32)) -> Option { r.world_opt(id)?; // hidden subtree - let node = doc.get(id); + let query = r.query_node_opt(id)?; // Children first, topmost-first (paint order = document order). // A container clip scopes descendants only: outside it, skip the child // traversal but still test the container's own fill/strokes below. The // inverse world transform makes this exact for rotated/flipped containers // and naturally enforces every clip encountered along the ancestor walk. - let children_visible = if matches!( - &node.payload, - Payload::Frame { - clips_content: true, - .. - } - ) { + let children_visible = query.content_clip.is_none_or(|clip| { r.world_of(id).invert().is_some_and(|inverse| { let (lx, ly) = inverse.apply(p); let b = r.box_of(id); - lx >= 0.0 && lx <= b.w && ly >= 0.0 && ly <= b.h + rounded_box::contains( + b.w, + b.h, + clip.corner_radius, + clip.corner_smoothing, + (lx, ly), + ) }) - } else { - true - }; + }); if children_visible { - for &c in node.children.iter().rev() { - if let Some(hit) = hit_subtree(doc, r, c, p) { - return Some(hit); + for &child in query.children.iter().rev() { + if let Some(hit) = hit_subtree(r, child, p) { + // Transparent-select promotes through every derived ancestor + // while recursion unwinds, leaving the outermost one selected. + return Some(if query.box_is_derived { id } else { hit }); } } } // Own ink: derived kinds have none (their bounds come from children). - if node.payload.box_is_derived() { + if query.box_is_derived { return None; } let inv = r.world_of(id).invert()?; @@ -71,16 +75,3 @@ fn hit_subtree(doc: &Document, r: &Resolved, id: NodeId, p: (f32, f32)) -> Optio None } } - -/// Transparent-select: the OUTERMOST derived ancestor claims the hit. -fn promote(doc: &Document, hit: NodeId) -> NodeId { - let mut chosen = hit; - let mut cur = hit; - while let Some(parent) = doc.parent_of(cur) { - if doc.get(parent).payload.box_is_derived() { - chosen = parent; - } - cur = parent; - } - chosen -} diff --git a/model-v2/a/lab/src/properties.rs b/model-v2/a/lab/src/properties.rs new file mode 100644 index 0000000000..1975b89ee9 --- /dev/null +++ b/model-v2/a/lab/src/properties.rs @@ -0,0 +1,1339 @@ +//! Closed, typed node-property values and their immutable read view. +//! +//! This is deliberately a node-level contract. Structural facts (payload +//! kind, parent, children, order) are never properties, and nested paint, +//! stroke, stop, text-run, and lens-operation members have no targets until +//! those subobjects earn durable identity of their own. + +use crate::model::*; +use crate::renderability; +use std::collections::BTreeMap; + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PropertyKey { + X, + Y, + Width, + Height, + MinWidth, + MaxWidth, + MinHeight, + MaxHeight, + AspectRatio, + Active, + Rotation, + FlipX, + FlipY, + Flow, + Grow, + SelfAlign, + Opacity, + Layout, + ClipsContent, + CornerRadius, + CornerSmoothing, + Fills, + Strokes, +} + +impl PropertyKey { + pub const ALL: [PropertyKey; 23] = [ + PropertyKey::X, + PropertyKey::Y, + PropertyKey::Width, + PropertyKey::Height, + PropertyKey::MinWidth, + PropertyKey::MaxWidth, + PropertyKey::MinHeight, + PropertyKey::MaxHeight, + PropertyKey::AspectRatio, + PropertyKey::Active, + PropertyKey::Rotation, + PropertyKey::FlipX, + PropertyKey::FlipY, + PropertyKey::Flow, + PropertyKey::Grow, + PropertyKey::SelfAlign, + PropertyKey::Opacity, + PropertyKey::Layout, + PropertyKey::ClipsContent, + PropertyKey::CornerRadius, + PropertyKey::CornerSmoothing, + PropertyKey::Fills, + PropertyKey::Strokes, + ]; + + #[inline] + pub fn spec(self) -> &'static PropertySpec { + let spec = &PROPERTY_REGISTRY[self as usize]; + debug_assert_eq!(spec.key, self); + spec + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropertyValueKind { + AxisBinding, + SizeIntent, + OptionalNumber, + OptionalAspectRatio, + Boolean, + Number, + Flow, + SelfAlign, + Layout, + CornerRadius, + Paints, + Strokes, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PropertyValue { + AxisBinding(AxisBinding), + SizeIntent(SizeIntent), + /// An explicit `None` clears an authored constraint. This remains + /// distinct from an absent [`PropertyTarget`], which reads authored base. + OptionalNumber(Option), + OptionalAspectRatio(Option<(f32, f32)>), + Boolean(bool), + Number(f32), + Flow(Flow), + SelfAlign(SelfAlign), + Layout(LayoutBehavior), + CornerRadius(RectangularCornerRadius), + Paints(Paints), + Strokes(Vec), +} + +impl PropertyValue { + pub fn kind(&self) -> PropertyValueKind { + match self { + PropertyValue::AxisBinding(_) => PropertyValueKind::AxisBinding, + PropertyValue::SizeIntent(_) => PropertyValueKind::SizeIntent, + PropertyValue::OptionalNumber(_) => PropertyValueKind::OptionalNumber, + PropertyValue::OptionalAspectRatio(_) => PropertyValueKind::OptionalAspectRatio, + PropertyValue::Boolean(_) => PropertyValueKind::Boolean, + PropertyValue::Number(_) => PropertyValueKind::Number, + PropertyValue::Flow(_) => PropertyValueKind::Flow, + PropertyValue::SelfAlign(_) => PropertyValueKind::SelfAlign, + PropertyValue::Layout(_) => PropertyValueKind::Layout, + PropertyValue::CornerRadius(_) => PropertyValueKind::CornerRadius, + PropertyValue::Paints(_) => PropertyValueKind::Paints, + PropertyValue::Strokes(_) => PropertyValueKind::Strokes, + } + } + + fn as_ref(&self) -> PropertyValueRef<'_> { + match self { + PropertyValue::AxisBinding(value) => PropertyValueRef::AxisBinding(*value), + PropertyValue::SizeIntent(value) => PropertyValueRef::SizeIntent(*value), + PropertyValue::OptionalNumber(value) => PropertyValueRef::OptionalNumber(*value), + PropertyValue::OptionalAspectRatio(value) => { + PropertyValueRef::OptionalAspectRatio(*value) + } + PropertyValue::Boolean(value) => PropertyValueRef::Boolean(*value), + PropertyValue::Number(value) => PropertyValueRef::Number(*value), + PropertyValue::Flow(value) => PropertyValueRef::Flow(*value), + PropertyValue::SelfAlign(value) => PropertyValueRef::SelfAlign(*value), + PropertyValue::Layout(value) => PropertyValueRef::Layout(*value), + PropertyValue::CornerRadius(value) => PropertyValueRef::CornerRadius(*value), + PropertyValue::Paints(value) => PropertyValueRef::Paints(value), + PropertyValue::Strokes(value) => PropertyValueRef::Strokes(value), + } + } +} + +/// Borrowed property value. Large paint/stroke values stay borrowed while +/// scalar values remain cheap copies. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PropertyValueRef<'a> { + AxisBinding(AxisBinding), + SizeIntent(SizeIntent), + OptionalNumber(Option), + OptionalAspectRatio(Option<(f32, f32)>), + Boolean(bool), + Number(f32), + Flow(Flow), + SelfAlign(SelfAlign), + Layout(LayoutBehavior), + CornerRadius(RectangularCornerRadius), + Paints(&'a Paints), + Strokes(&'a [Stroke]), +} + +impl PropertyValueRef<'_> { + pub fn kind(self) -> PropertyValueKind { + match self { + PropertyValueRef::AxisBinding(_) => PropertyValueKind::AxisBinding, + PropertyValueRef::SizeIntent(_) => PropertyValueKind::SizeIntent, + PropertyValueRef::OptionalNumber(_) => PropertyValueKind::OptionalNumber, + PropertyValueRef::OptionalAspectRatio(_) => PropertyValueKind::OptionalAspectRatio, + PropertyValueRef::Boolean(_) => PropertyValueKind::Boolean, + PropertyValueRef::Number(_) => PropertyValueKind::Number, + PropertyValueRef::Flow(_) => PropertyValueKind::Flow, + PropertyValueRef::SelfAlign(_) => PropertyValueKind::SelfAlign, + PropertyValueRef::Layout(_) => PropertyValueKind::Layout, + PropertyValueRef::CornerRadius(_) => PropertyValueKind::CornerRadius, + PropertyValueRef::Paints(_) => PropertyValueKind::Paints, + PropertyValueRef::Strokes(_) => PropertyValueKind::Strokes, + } + } + + pub fn to_owned(self) -> PropertyValue { + match self { + PropertyValueRef::AxisBinding(value) => PropertyValue::AxisBinding(value), + PropertyValueRef::SizeIntent(value) => PropertyValue::SizeIntent(value), + PropertyValueRef::OptionalNumber(value) => PropertyValue::OptionalNumber(value), + PropertyValueRef::OptionalAspectRatio(value) => { + PropertyValue::OptionalAspectRatio(value) + } + PropertyValueRef::Boolean(value) => PropertyValue::Boolean(value), + PropertyValueRef::Number(value) => PropertyValue::Number(value), + PropertyValueRef::Flow(value) => PropertyValue::Flow(value), + PropertyValueRef::SelfAlign(value) => PropertyValue::SelfAlign(value), + PropertyValueRef::Layout(value) => PropertyValue::Layout(value), + PropertyValueRef::CornerRadius(value) => PropertyValue::CornerRadius(value), + PropertyValueRef::Paints(value) => PropertyValue::Paints(value.clone()), + PropertyValueRef::Strokes(value) => PropertyValue::Strokes(value.to_vec()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PropertyTarget { + pub node: NodeKey, + pub property: PropertyKey, +} + +impl PropertyTarget { + pub fn new(node: NodeKey, property: PropertyKey) -> Self { + Self { node, property } + } +} + +/// Conservative stage impact. Multiple flags may be present. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PropertyImpact(u8); + +impl PropertyImpact { + pub const MEASURE: Self = Self(1 << 0); + pub const LAYOUT: Self = Self(1 << 1); + pub const TRANSFORM: Self = Self(1 << 2); + pub const BOUNDS: Self = Self(1 << 3); + pub const PAINT: Self = Self(1 << 4); + pub const RESOURCE: Self = Self(1 << 5); + + const fn from_bits(bits: u8) -> Self { + Self(bits) + } + + pub const fn bits(self) -> u8 { + self.0 + } + + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +impl std::ops::BitOr for PropertyImpact { + type Output = Self; + + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropertyApplicability { + EveryNode, + HorizontalExtent, + VerticalExtent, + AspectRatioShape, + Frame, + RoundedBox, + FillPaintable, + StrokePaintable, +} + +impl PropertyApplicability { + #[inline] + pub fn accepts(self, node: &Node) -> bool { + match self { + PropertyApplicability::EveryNode => true, + PropertyApplicability::HorizontalExtent => !node.payload.box_is_derived(), + PropertyApplicability::VerticalExtent => { + !node.payload.box_is_derived() + && !matches!( + node.payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ) + } + PropertyApplicability::AspectRatioShape => matches!( + node.payload, + Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse | ShapeDesc::Path(_) + } + ), + PropertyApplicability::Frame => matches!(node.payload, Payload::Frame { .. }), + PropertyApplicability::RoundedBox => matches!( + node.payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect + } + ), + PropertyApplicability::FillPaintable => matches!( + node.payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse | ShapeDesc::Path(_) + } + | Payload::Text { .. } + | Payload::AttributedText { .. } + ), + PropertyApplicability::StrokePaintable => matches!( + node.payload, + Payload::Frame { .. } + | Payload::Shape { .. } + | Payload::Text { .. } + | Payload::AttributedText { .. } + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PropertySpec { + pub key: PropertyKey, + pub value_kind: PropertyValueKind, + pub applicability: PropertyApplicability, + pub impact: PropertyImpact, +} + +impl PropertySpec { + #[inline] + pub fn applies_to(self, node: &Node) -> bool { + self.applicability.accepts(node) + } + + /// Read the authored base value. `None` means this key does not apply to + /// the node; it never means a registered nullable value is null. + pub fn base_value(self, node: &Node) -> Option { + self.applies_to(node) + .then(|| base_value_ref(node, self.key).to_owned()) + } +} + +const GEOMETRY: PropertyImpact = PropertyImpact::from_bits( + PropertyImpact::MEASURE.bits() + | PropertyImpact::LAYOUT.bits() + | PropertyImpact::TRANSFORM.bits() + | PropertyImpact::BOUNDS.bits() + | PropertyImpact::PAINT.bits(), +); +const BOUNDS_PAINT: PropertyImpact = + PropertyImpact::from_bits(PropertyImpact::BOUNDS.bits() | PropertyImpact::PAINT.bits()); +const PAINT_RESOURCE: PropertyImpact = + PropertyImpact::from_bits(PropertyImpact::PAINT.bits() | PropertyImpact::RESOURCE.bits()); +const BOUNDS_PAINT_RESOURCE: PropertyImpact = PropertyImpact::from_bits( + PropertyImpact::BOUNDS.bits() | PropertyImpact::PAINT.bits() | PropertyImpact::RESOURCE.bits(), +); +const ALL_STAGES: PropertyImpact = PropertyImpact::from_bits( + PropertyImpact::MEASURE.bits() + | PropertyImpact::LAYOUT.bits() + | PropertyImpact::TRANSFORM.bits() + | PropertyImpact::BOUNDS.bits() + | PropertyImpact::PAINT.bits() + | PropertyImpact::RESOURCE.bits(), +); + +static PROPERTY_REGISTRY: [PropertySpec; PropertyKey::ALL.len()] = [ + PropertySpec { + key: PropertyKey::X, + value_kind: PropertyValueKind::AxisBinding, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::Y, + value_kind: PropertyValueKind::AxisBinding, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::Width, + value_kind: PropertyValueKind::SizeIntent, + applicability: PropertyApplicability::HorizontalExtent, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::Height, + value_kind: PropertyValueKind::SizeIntent, + applicability: PropertyApplicability::VerticalExtent, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::MinWidth, + value_kind: PropertyValueKind::OptionalNumber, + applicability: PropertyApplicability::HorizontalExtent, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::MaxWidth, + value_kind: PropertyValueKind::OptionalNumber, + applicability: PropertyApplicability::HorizontalExtent, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::MinHeight, + value_kind: PropertyValueKind::OptionalNumber, + applicability: PropertyApplicability::VerticalExtent, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::MaxHeight, + value_kind: PropertyValueKind::OptionalNumber, + applicability: PropertyApplicability::VerticalExtent, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::AspectRatio, + value_kind: PropertyValueKind::OptionalAspectRatio, + applicability: PropertyApplicability::AspectRatioShape, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::Active, + value_kind: PropertyValueKind::Boolean, + applicability: PropertyApplicability::EveryNode, + impact: ALL_STAGES, + }, + PropertySpec { + key: PropertyKey::Rotation, + value_kind: PropertyValueKind::Number, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::FlipX, + value_kind: PropertyValueKind::Boolean, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::FlipY, + value_kind: PropertyValueKind::Boolean, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::Flow, + value_kind: PropertyValueKind::Flow, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::Grow, + value_kind: PropertyValueKind::Number, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::SelfAlign, + value_kind: PropertyValueKind::SelfAlign, + applicability: PropertyApplicability::EveryNode, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::Opacity, + value_kind: PropertyValueKind::Number, + applicability: PropertyApplicability::EveryNode, + impact: PropertyImpact::PAINT, + }, + PropertySpec { + key: PropertyKey::Layout, + value_kind: PropertyValueKind::Layout, + applicability: PropertyApplicability::Frame, + impact: GEOMETRY, + }, + PropertySpec { + key: PropertyKey::ClipsContent, + value_kind: PropertyValueKind::Boolean, + applicability: PropertyApplicability::Frame, + impact: BOUNDS_PAINT, + }, + PropertySpec { + key: PropertyKey::CornerRadius, + value_kind: PropertyValueKind::CornerRadius, + applicability: PropertyApplicability::RoundedBox, + impact: BOUNDS_PAINT, + }, + PropertySpec { + key: PropertyKey::CornerSmoothing, + value_kind: PropertyValueKind::Number, + applicability: PropertyApplicability::RoundedBox, + impact: BOUNDS_PAINT, + }, + PropertySpec { + key: PropertyKey::Fills, + value_kind: PropertyValueKind::Paints, + applicability: PropertyApplicability::FillPaintable, + impact: PAINT_RESOURCE, + }, + PropertySpec { + key: PropertyKey::Strokes, + value_kind: PropertyValueKind::Strokes, + applicability: PropertyApplicability::StrokePaintable, + impact: BOUNDS_PAINT_RESOURCE, + }, +]; + +pub fn property_registry() -> &'static [PropertySpec] { + &PROPERTY_REGISTRY +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PropertyError { + DuplicateTarget { + target: PropertyTarget, + }, + StaleTarget { + target: PropertyTarget, + }, + WrongValueKind { + target: PropertyTarget, + expected: PropertyValueKind, + actual: PropertyValueKind, + }, + Inapplicable { + target: PropertyTarget, + applicability: PropertyApplicability, + }, + InvalidValue { + target: PropertyTarget, + reason: String, + }, + InvalidEffectiveState { + node: NodeKey, + properties: Vec, + reason: String, + }, +} + +impl std::fmt::Display for PropertyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PropertyError::DuplicateTarget { target } => { + write!(f, "duplicate property target {target:?}") + } + PropertyError::StaleTarget { target } => { + write!( + f, + "property target is not live in this document: {target:?}" + ) + } + PropertyError::WrongValueKind { + target, + expected, + actual, + } => write!( + f, + "wrong value kind for {target:?}: expected {expected:?}, found {actual:?}" + ), + PropertyError::Inapplicable { + target, + applicability, + } => write!( + f, + "property {target:?} does not satisfy {applicability:?} applicability" + ), + PropertyError::InvalidValue { target, reason } => { + write!(f, "invalid value for {target:?}: {reason}") + } + PropertyError::InvalidEffectiveState { + node, + properties, + reason, + } => write!( + f, + "invalid effective state for {node:?} across {properties:?}: {reason}" + ), + } + } +} + +impl std::error::Error for PropertyError {} + +/// Immutable, sorted, unique node-property values. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PropertyValues { + entries: BTreeMap, +} + +impl PropertyValues { + pub fn new( + document: &Document, + entries: impl IntoIterator, + ) -> Result { + let mut values = Self::default(); + for (target, value) in entries { + if values.entries.contains_key(&target) { + return Err(PropertyError::DuplicateTarget { target }); + } + validate_entry(document, target, &value)?; + values.entries.insert(target, value); + } + values.validate_effective_states(document)?; + Ok(values) + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn get(&self, target: PropertyTarget) -> Option<&PropertyValue> { + self.entries.get(&target) + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.entries.iter().map(|(target, value)| (*target, value)) + } + + fn validate(&self, document: &Document) -> Result<(), PropertyError> { + for (&target, value) in &self.entries { + validate_entry(document, target, value)?; + } + self.validate_effective_states(document) + } + + fn validate_effective_states(&self, document: &Document) -> Result<(), PropertyError> { + let mut touched = BTreeMap::>::new(); + for target in self.entries.keys() { + touched + .entry(target.node) + .or_default() + .push(target.property); + } + + for (node_key, keys) in touched { + let node = document + .node_for_key(node_key) + .expect("entry validation rejects stale property targets"); + let target = |property| PropertyTarget::new(node_key, property); + let geometry_properties = [ + PropertyKey::X, + PropertyKey::Y, + PropertyKey::Width, + PropertyKey::Height, + PropertyKey::MinWidth, + PropertyKey::MaxWidth, + PropertyKey::MinHeight, + PropertyKey::MaxHeight, + PropertyKey::AspectRatio, + ]; + let geometry_touched = geometry_properties + .into_iter() + .filter(|property| keys.contains(property)) + .collect::>(); + if !geometry_touched.is_empty() { + let mut header = node.header.clone(); + for property in geometry_properties { + let Some(value) = self.entries.get(&target(property)) else { + continue; + }; + match (property, value) { + (PropertyKey::X, PropertyValue::AxisBinding(value)) => header.x = *value, + (PropertyKey::Y, PropertyValue::AxisBinding(value)) => header.y = *value, + (PropertyKey::Width, PropertyValue::SizeIntent(value)) => { + header.width = *value + } + (PropertyKey::Height, PropertyValue::SizeIntent(value)) => { + header.height = *value + } + (PropertyKey::MinWidth, PropertyValue::OptionalNumber(value)) => { + header.min_width = *value + } + (PropertyKey::MaxWidth, PropertyValue::OptionalNumber(value)) => { + header.max_width = *value + } + (PropertyKey::MinHeight, PropertyValue::OptionalNumber(value)) => { + header.min_height = *value + } + (PropertyKey::MaxHeight, PropertyValue::OptionalNumber(value)) => { + header.max_height = *value + } + (PropertyKey::AspectRatio, PropertyValue::OptionalAspectRatio(value)) => { + header.aspect_ratio = *value + } + _ => unreachable!("entry validation fixes the registered value kind"), + } + } + renderability::validate_geometry(&header, &node.payload).map_err(|error| { + PropertyError::InvalidEffectiveState { + node: node_key, + properties: geometry_touched, + reason: error.to_string(), + } + })?; + } + let corner_radius = match self.entries.get(&target(PropertyKey::CornerRadius)) { + Some(PropertyValue::CornerRadius(value)) => *value, + None => node.corner_radius, + Some(_) => unreachable!("entry validation fixes the registered value kind"), + }; + let corner_smoothing = match self.entries.get(&target(PropertyKey::CornerSmoothing)) { + Some(PropertyValue::Number(value)) => CornerSmoothing(*value), + None => node.corner_smoothing, + Some(_) => unreachable!("entry validation fixes the registered value kind"), + }; + + if keys.contains(&PropertyKey::CornerRadius) + || keys.contains(&PropertyKey::CornerSmoothing) + { + renderability::validate_smooth_corner_radii(corner_radius, corner_smoothing) + .map_err(|error| PropertyError::InvalidEffectiveState { + node: node_key, + properties: vec![PropertyKey::CornerRadius, PropertyKey::CornerSmoothing], + reason: error.to_string(), + })?; + } + + if keys.contains(&PropertyKey::Strokes) || keys.contains(&PropertyKey::CornerSmoothing) + { + let strokes = match self.entries.get(&target(PropertyKey::Strokes)) { + Some(PropertyValue::Strokes(value)) => value.as_slice(), + None => node.strokes.as_slice(), + Some(_) => unreachable!("entry validation fixes the registered value kind"), + }; + validate_strokes(&node.payload, strokes, corner_smoothing).map_err(|reason| { + PropertyError::InvalidEffectiveState { + node: node_key, + properties: vec![PropertyKey::CornerSmoothing, PropertyKey::Strokes], + reason, + } + })?; + } + } + Ok(()) + } +} + +/// One validated immutable view over authored structure plus optional typed +/// property values. All registered property reads go through this type; +/// structural reads still come from [`Self::document`]. +/// +/// Each typed accessor keeps an explicit authored-base branch. Do not funnel +/// that branch through [`PropertyValueRef`]: resolution performs many reads per +/// node, and materializing then decoding the generic union there doubled the +/// flat-canvas release gate. The effective branch still resolves a complete +/// generational [`PropertyTarget`] before reading sparse values. +#[derive(Debug)] +pub struct ValueView<'a> { + document: &'a Document, + values: Option<&'a PropertyValues>, +} + +impl<'a> ValueView<'a> { + pub fn base(document: &'a Document) -> Self { + Self { + document, + values: None, + } + } + + pub fn new(document: &'a Document, values: &'a PropertyValues) -> Result { + values.validate(document)?; + Ok(Self { + document, + values: Some(values), + }) + } + + #[inline] + pub fn document(&self) -> &'a Document { + self.document + } + + #[inline] + fn target(&self, id: NodeId, property: PropertyKey) -> PropertyTarget { + PropertyTarget::new( + self.document + .key_of(id) + .expect("resolver traverses only live nodes"), + property, + ) + } + + #[inline] + fn assert_applicable(&self, id: NodeId, property: PropertyKey) { + assert!( + property.spec().applies_to(self.document.get(id)), + "ValueView read inapplicable property {property:?} on node {id}" + ); + } + + #[inline] + fn debug_assert_applicable(&self, id: NodeId, property: PropertyKey) { + debug_assert!( + property.spec().applies_to(self.document.get(id)), + "resolver read inapplicable property {property:?} on node {id}" + ); + } + + #[inline] + fn effective_value_for_id(&self, id: NodeId, property: PropertyKey) -> PropertyValueRef<'_> { + let values = self.values.expect("effective read requires PropertyValues"); + let target = self.target(id, property); + values + .get(target) + .map(PropertyValue::as_ref) + .unwrap_or_else(|| base_value_ref(self.document.get(id), property)) + } + + #[inline] + pub fn x(&self, id: NodeId) -> AxisBinding { + if self.values.is_none() { + return self.document.get(id).header.x; + } + match self.effective_value_for_id(id, PropertyKey::X) { + PropertyValueRef::AxisBinding(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn y(&self, id: NodeId) -> AxisBinding { + if self.values.is_none() { + return self.document.get(id).header.y; + } + match self.effective_value_for_id(id, PropertyKey::Y) { + PropertyValueRef::AxisBinding(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn width(&self, id: NodeId) -> SizeIntent { + self.assert_applicable(id, PropertyKey::Width); + self.width_unchecked(id) + } + + #[inline] + pub(crate) fn width_unchecked(&self, id: NodeId) -> SizeIntent { + self.debug_assert_applicable(id, PropertyKey::Width); + if self.values.is_none() { + return self.document.get(id).header.width; + } + match self.effective_value_for_id(id, PropertyKey::Width) { + PropertyValueRef::SizeIntent(value) => value, + _ => unreachable!(), + } + } + + /// Authored value used only to report the model's ignored-by-rule state + /// on derived boxes. Overrides for that inapplicable target are rejected. + pub(crate) fn authored_width(&self, id: NodeId) -> SizeIntent { + self.document.get(id).header.width + } + + #[inline] + pub fn height(&self, id: NodeId) -> SizeIntent { + self.assert_applicable(id, PropertyKey::Height); + self.height_unchecked(id) + } + + #[inline] + pub(crate) fn height_unchecked(&self, id: NodeId) -> SizeIntent { + self.debug_assert_applicable(id, PropertyKey::Height); + if self.values.is_none() { + return self.document.get(id).header.height; + } + match self.effective_value_for_id(id, PropertyKey::Height) { + PropertyValueRef::SizeIntent(value) => value, + _ => unreachable!(), + } + } + + /// See [`Self::authored_width`]. + pub(crate) fn authored_height(&self, id: NodeId) -> SizeIntent { + self.document.get(id).header.height + } + + #[inline] + pub fn min_width(&self, id: NodeId) -> Option { + self.optional_number(id, PropertyKey::MinWidth) + } + + #[inline] + pub(crate) fn min_width_unchecked(&self, id: NodeId) -> Option { + self.debug_assert_applicable(id, PropertyKey::MinWidth); + self.optional_number_unchecked(id, PropertyKey::MinWidth) + } + + #[inline] + pub fn max_width(&self, id: NodeId) -> Option { + self.optional_number(id, PropertyKey::MaxWidth) + } + + #[inline] + pub(crate) fn max_width_unchecked(&self, id: NodeId) -> Option { + self.debug_assert_applicable(id, PropertyKey::MaxWidth); + self.optional_number_unchecked(id, PropertyKey::MaxWidth) + } + + #[inline] + pub fn min_height(&self, id: NodeId) -> Option { + self.optional_number(id, PropertyKey::MinHeight) + } + + #[inline] + pub(crate) fn min_height_unchecked(&self, id: NodeId) -> Option { + self.debug_assert_applicable(id, PropertyKey::MinHeight); + self.optional_number_unchecked(id, PropertyKey::MinHeight) + } + + #[inline] + pub fn max_height(&self, id: NodeId) -> Option { + self.optional_number(id, PropertyKey::MaxHeight) + } + + #[inline] + pub(crate) fn max_height_unchecked(&self, id: NodeId) -> Option { + self.debug_assert_applicable(id, PropertyKey::MaxHeight); + self.optional_number_unchecked(id, PropertyKey::MaxHeight) + } + + #[inline] + fn optional_number(&self, id: NodeId, key: PropertyKey) -> Option { + self.assert_applicable(id, key); + self.optional_number_unchecked(id, key) + } + + #[inline] + fn optional_number_unchecked(&self, id: NodeId, key: PropertyKey) -> Option { + if self.values.is_none() { + let header = &self.document.get(id).header; + return match key { + PropertyKey::MinWidth => header.min_width, + PropertyKey::MaxWidth => header.max_width, + PropertyKey::MinHeight => header.min_height, + PropertyKey::MaxHeight => header.max_height, + _ => unreachable!("optional-number helper accepts only constraint keys"), + }; + } + match self.effective_value_for_id(id, key) { + PropertyValueRef::OptionalNumber(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn aspect_ratio(&self, id: NodeId) -> Option<(f32, f32)> { + self.assert_applicable(id, PropertyKey::AspectRatio); + self.aspect_ratio_unchecked(id) + } + + #[inline] + pub(crate) fn aspect_ratio_unchecked(&self, id: NodeId) -> Option<(f32, f32)> { + self.debug_assert_applicable(id, PropertyKey::AspectRatio); + if self.values.is_none() { + return self.document.get(id).header.aspect_ratio; + } + match self.effective_value_for_id(id, PropertyKey::AspectRatio) { + PropertyValueRef::OptionalAspectRatio(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn active(&self, id: NodeId) -> bool { + self.boolean_unchecked(id, PropertyKey::Active) + } + + #[inline] + pub fn flip_x(&self, id: NodeId) -> bool { + self.boolean_unchecked(id, PropertyKey::FlipX) + } + + #[inline] + pub fn flip_y(&self, id: NodeId) -> bool { + self.boolean_unchecked(id, PropertyKey::FlipY) + } + + #[inline] + pub fn clips_content(&self, id: NodeId) -> bool { + self.boolean(id, PropertyKey::ClipsContent) + } + + #[inline] + fn boolean(&self, id: NodeId, key: PropertyKey) -> bool { + self.assert_applicable(id, key); + self.boolean_unchecked(id, key) + } + + #[inline] + fn boolean_unchecked(&self, id: NodeId, key: PropertyKey) -> bool { + if self.values.is_none() { + let node = self.document.get(id); + return match key { + PropertyKey::Active => node.header.active, + PropertyKey::FlipX => node.header.flip_x, + PropertyKey::FlipY => node.header.flip_y, + PropertyKey::ClipsContent => match node.payload { + Payload::Frame { clips_content, .. } => clips_content, + _ => unreachable!("registry applicability rejects non-frame clip reads"), + }, + _ => unreachable!("boolean helper accepts only registered boolean keys"), + }; + } + match self.effective_value_for_id(id, key) { + PropertyValueRef::Boolean(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn rotation(&self, id: NodeId) -> f32 { + self.number_unchecked(id, PropertyKey::Rotation) + } + + #[inline] + pub fn grow(&self, id: NodeId) -> f32 { + self.number_unchecked(id, PropertyKey::Grow) + } + + #[inline] + pub fn opacity(&self, id: NodeId) -> f32 { + self.number_unchecked(id, PropertyKey::Opacity) + } + + #[inline] + pub fn corner_smoothing(&self, id: NodeId) -> CornerSmoothing { + self.assert_applicable(id, PropertyKey::CornerSmoothing); + self.corner_smoothing_unchecked(id) + } + + #[inline] + pub(crate) fn corner_smoothing_unchecked(&self, id: NodeId) -> CornerSmoothing { + self.debug_assert_applicable(id, PropertyKey::CornerSmoothing); + if self.values.is_none() { + return self.document.get(id).corner_smoothing; + } + match self.effective_value_for_id(id, PropertyKey::CornerSmoothing) { + PropertyValueRef::Number(value) => CornerSmoothing(value), + _ => unreachable!(), + } + } + + #[inline] + fn number_unchecked(&self, id: NodeId, key: PropertyKey) -> f32 { + if self.values.is_none() { + let header = &self.document.get(id).header; + return match key { + PropertyKey::Rotation => header.rotation, + PropertyKey::Grow => header.grow, + PropertyKey::Opacity => header.opacity, + PropertyKey::CornerSmoothing => self.document.get(id).corner_smoothing.value(), + _ => unreachable!("number helper accepts only registered number keys"), + }; + } + match self.effective_value_for_id(id, key) { + PropertyValueRef::Number(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn flow(&self, id: NodeId) -> Flow { + if self.values.is_none() { + return self.document.get(id).header.flow; + } + match self.effective_value_for_id(id, PropertyKey::Flow) { + PropertyValueRef::Flow(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn self_align(&self, id: NodeId) -> SelfAlign { + if self.values.is_none() { + return self.document.get(id).header.self_align; + } + match self.effective_value_for_id(id, PropertyKey::SelfAlign) { + PropertyValueRef::SelfAlign(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn layout(&self, id: NodeId) -> LayoutBehavior { + self.assert_applicable(id, PropertyKey::Layout); + if self.values.is_none() { + return match self.document.get(id).payload { + Payload::Frame { layout, .. } => layout, + _ => unreachable!("registry applicability rejects non-frame layout reads"), + }; + } + match self.effective_value_for_id(id, PropertyKey::Layout) { + PropertyValueRef::Layout(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn corner_radius(&self, id: NodeId) -> RectangularCornerRadius { + self.assert_applicable(id, PropertyKey::CornerRadius); + if self.values.is_none() { + return self.document.get(id).corner_radius; + } + match self.effective_value_for_id(id, PropertyKey::CornerRadius) { + PropertyValueRef::CornerRadius(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn fills(&self, id: NodeId) -> &Paints { + self.assert_applicable(id, PropertyKey::Fills); + if self.values.is_none() { + return &self.document.get(id).fills; + } + match self.effective_value_for_id(id, PropertyKey::Fills) { + PropertyValueRef::Paints(value) => value, + _ => unreachable!(), + } + } + + #[inline] + pub fn strokes(&self, id: NodeId) -> &[Stroke] { + self.assert_applicable(id, PropertyKey::Strokes); + self.strokes_unchecked(id) + } + + #[inline] + pub(crate) fn strokes_unchecked(&self, id: NodeId) -> &[Stroke] { + self.debug_assert_applicable(id, PropertyKey::Strokes); + if self.values.is_none() { + return &self.document.get(id).strokes; + } + match self.effective_value_for_id(id, PropertyKey::Strokes) { + PropertyValueRef::Strokes(value) => value, + _ => unreachable!(), + } + } +} + +#[inline] +fn base_value_ref(node: &Node, key: PropertyKey) -> PropertyValueRef<'_> { + match key { + PropertyKey::X => PropertyValueRef::AxisBinding(node.header.x), + PropertyKey::Y => PropertyValueRef::AxisBinding(node.header.y), + PropertyKey::Width => PropertyValueRef::SizeIntent(node.header.width), + PropertyKey::Height => PropertyValueRef::SizeIntent(node.header.height), + PropertyKey::MinWidth => PropertyValueRef::OptionalNumber(node.header.min_width), + PropertyKey::MaxWidth => PropertyValueRef::OptionalNumber(node.header.max_width), + PropertyKey::MinHeight => PropertyValueRef::OptionalNumber(node.header.min_height), + PropertyKey::MaxHeight => PropertyValueRef::OptionalNumber(node.header.max_height), + PropertyKey::AspectRatio => PropertyValueRef::OptionalAspectRatio(node.header.aspect_ratio), + PropertyKey::Active => PropertyValueRef::Boolean(node.header.active), + PropertyKey::Rotation => PropertyValueRef::Number(node.header.rotation), + PropertyKey::FlipX => PropertyValueRef::Boolean(node.header.flip_x), + PropertyKey::FlipY => PropertyValueRef::Boolean(node.header.flip_y), + PropertyKey::Flow => PropertyValueRef::Flow(node.header.flow), + PropertyKey::Grow => PropertyValueRef::Number(node.header.grow), + PropertyKey::SelfAlign => PropertyValueRef::SelfAlign(node.header.self_align), + PropertyKey::Opacity => PropertyValueRef::Number(node.header.opacity), + PropertyKey::Layout => match node.payload { + Payload::Frame { layout, .. } => PropertyValueRef::Layout(layout), + _ => unreachable!("registry applicability rejects non-frame layout reads"), + }, + PropertyKey::ClipsContent => match node.payload { + Payload::Frame { clips_content, .. } => PropertyValueRef::Boolean(clips_content), + _ => unreachable!("registry applicability rejects non-frame clip reads"), + }, + PropertyKey::CornerRadius => PropertyValueRef::CornerRadius(node.corner_radius), + PropertyKey::CornerSmoothing => PropertyValueRef::Number(node.corner_smoothing.value()), + PropertyKey::Fills => PropertyValueRef::Paints(&node.fills), + PropertyKey::Strokes => PropertyValueRef::Strokes(&node.strokes), + } +} + +fn validate_entry( + document: &Document, + target: PropertyTarget, + value: &PropertyValue, +) -> Result<(), PropertyError> { + let node = document + .node_for_key(target.node) + .ok_or(PropertyError::StaleTarget { target })?; + let spec = target.property.spec(); + if value.kind() != spec.value_kind { + return Err(PropertyError::WrongValueKind { + target, + expected: spec.value_kind, + actual: value.kind(), + }); + } + if !spec.applies_to(node) { + return Err(PropertyError::Inapplicable { + target, + applicability: spec.applicability, + }); + } + validate_value(target, node, value) +} + +fn invalid(target: PropertyTarget, reason: impl Into) -> PropertyError { + PropertyError::InvalidValue { + target, + reason: reason.into(), + } +} + +fn validate_value( + target: PropertyTarget, + node: &Node, + value: &PropertyValue, +) -> Result<(), PropertyError> { + match (target.property, value) { + (PropertyKey::X | PropertyKey::Y, PropertyValue::AxisBinding(binding)) => { + let finite = match binding { + AxisBinding::Pin { offset, .. } => offset.is_finite(), + AxisBinding::Span { start, end } => start.is_finite() && end.is_finite(), + }; + finite + .then_some(()) + .ok_or_else(|| invalid(target, "axis bindings must contain finite numbers")) + } + (PropertyKey::Width | PropertyKey::Height, PropertyValue::SizeIntent(intent)) => { + match intent { + SizeIntent::Auto => Ok(()), + SizeIntent::Fixed(value) if value.is_finite() && *value >= 0.0 => Ok(()), + SizeIntent::Fixed(_) => Err(invalid( + target, + "fixed size must be finite and non-negative", + )), + } + } + ( + PropertyKey::MinWidth + | PropertyKey::MaxWidth + | PropertyKey::MinHeight + | PropertyKey::MaxHeight, + PropertyValue::OptionalNumber(value), + ) => value + .is_none_or(|value| value.is_finite() && value >= 0.0) + .then_some(()) + .ok_or_else(|| invalid(target, "optional size constraints must be non-negative")), + (PropertyKey::AspectRatio, PropertyValue::OptionalAspectRatio(value)) => value + .is_none_or(|(width, height)| { + width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 + }) + .then_some(()) + .ok_or_else(|| { + invalid( + target, + "an aspect ratio must contain two finite positive numbers", + ) + }), + (PropertyKey::Rotation, PropertyValue::Number(value)) => value + .is_finite() + .then_some(()) + .ok_or_else(|| invalid(target, "rotation must be finite")), + (PropertyKey::Grow, PropertyValue::Number(value)) => (value.is_finite() && *value >= 0.0) + .then_some(()) + .ok_or_else(|| invalid(target, "grow must be finite and non-negative")), + (PropertyKey::Opacity, PropertyValue::Number(value)) => (value.is_finite() + && (0.0..=1.0).contains(value)) + .then_some(()) + .ok_or_else(|| invalid(target, "opacity must be within [0, 1]")), + (PropertyKey::Layout, PropertyValue::Layout(layout)) => { + validate_layout(*layout).map_err(|reason| invalid(target, reason)) + } + (PropertyKey::CornerRadius, PropertyValue::CornerRadius(radius)) => { + validate_corner_radius(*radius).map_err(|reason| invalid(target, reason)) + } + (PropertyKey::CornerSmoothing, PropertyValue::Number(value)) => (value.is_finite() + && (0.0..=1.0).contains(value)) + .then_some(()) + .ok_or_else(|| invalid(target, "corner smoothing must be within [0, 1]")), + (PropertyKey::Fills, PropertyValue::Paints(paints)) => { + renderability::validate_paints(paints) + .map_err(|reason| invalid(target, reason.to_string())) + } + (PropertyKey::Strokes, PropertyValue::Strokes(strokes)) => { + validate_strokes(&node.payload, strokes, CornerSmoothing::default()) + .map_err(|reason| invalid(target, reason)) + } + ( + PropertyKey::Active + | PropertyKey::FlipX + | PropertyKey::FlipY + | PropertyKey::ClipsContent, + PropertyValue::Boolean(_), + ) + | (PropertyKey::Flow, PropertyValue::Flow(_)) + | (PropertyKey::SelfAlign, PropertyValue::SelfAlign(_)) => Ok(()), + _ => unreachable!("value kind validation precedes key-specific validation"), + } +} + +fn validate_layout(layout: LayoutBehavior) -> Result<(), &'static str> { + let numbers = [ + layout.padding.top, + layout.padding.right, + layout.padding.bottom, + layout.padding.left, + layout.gap_main, + layout.gap_cross, + ]; + if numbers + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + { + Ok(()) + } else { + Err("layout padding and gaps must be finite and non-negative") + } +} + +fn validate_corner_radius(radius: RectangularCornerRadius) -> Result<(), &'static str> { + let values = [ + radius.tl.rx, + radius.tl.ry, + radius.tr.rx, + radius.tr.ry, + radius.br.rx, + radius.br.ry, + radius.bl.rx, + radius.bl.ry, + ]; + if values + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + { + Ok(()) + } else { + Err("corner radii must be finite and non-negative") + } +} + +fn validate_strokes( + payload: &Payload, + strokes: &[Stroke], + corner_smoothing: CornerSmoothing, +) -> Result<(), String> { + for stroke in strokes { + renderability::validate_stroke(stroke, payload, corner_smoothing) + .map_err(|error| error.to_string())?; + } + Ok(()) +} diff --git a/model-v2/a/lab/src/renderability.rs b/model-v2/a/lab/src/renderability.rs new file mode 100644 index 0000000000..ae6403fba8 --- /dev/null +++ b/model-v2/a/lab/src/renderability.rs @@ -0,0 +1,531 @@ +//! Pure capability validation for model states consumed by the proving +//! renderer. +//! +//! Source writing and immutable effective values share this fence. It rejects +//! model states that either consumer would otherwise silently narrow or drop; +//! source-only spelling restrictions remain at the source boundary. + +use crate::math::Affine; +use crate::model::*; + +fn payload_name(payload: &Payload) -> &'static str { + match payload { + Payload::Frame { .. } => "container", + Payload::Shape { + desc: ShapeDesc::Rect, + } => "rect", + Payload::Shape { + desc: ShapeDesc::Ellipse, + } => "ellipse", + Payload::Shape { + desc: ShapeDesc::Line, + } => "line", + Payload::Shape { + desc: ShapeDesc::Path(_), + } => "path", + Payload::Text { .. } | Payload::AttributedText { .. } => "text", + Payload::Group => "group", + Payload::Lens { .. } => "lens", + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderabilityError(String); + +impl RenderabilityError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } + + pub fn message(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for RenderabilityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RenderabilityError {} + +/// Validate the complete geometry tuple consumed by resolution. This is a +/// whole-state rule: no individual X/Y/size/aspect value can establish these +/// invariants without seeing its related fields. +pub fn validate_geometry(header: &Header, payload: &Payload) -> Result<(), RenderabilityError> { + if matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ) { + if matches!(header.y, AxisBinding::Span { .. }) { + return Err(RenderabilityError::new(" must not declare a y Span")); + } + if header.height != SizeIntent::Fixed(0.0) { + return Err(RenderabilityError::new(" height is locked to zero")); + } + if header.min_height.is_some() || header.max_height.is_some() { + return Err(RenderabilityError::new( + " must not declare min-height/max-height", + )); + } + if header.aspect_ratio.is_some() { + return Err(RenderabilityError::new( + " must not declare aspect-ratio", + )); + } + } + if matches!(header.x, AxisBinding::Span { .. }) + && (matches!(header.width, SizeIntent::Fixed(_)) + || header.min_width.is_some() + || header.max_width.is_some()) + { + return Err(RenderabilityError::new( + "a span x binding cannot also declare width/min-width/max-width", + )); + } + if matches!(header.y, AxisBinding::Span { .. }) + && (matches!(header.height, SizeIntent::Fixed(_)) + || header.min_height.is_some() + || header.max_height.is_some()) + { + return Err(RenderabilityError::new( + "a span y binding cannot also declare height/min-height/max-height", + )); + } + + if payload.box_is_derived() { + if !matches!(header.width, SizeIntent::Auto) + || !matches!(header.height, SizeIntent::Auto) + || header.min_width.is_some() + || header.max_width.is_some() + || header.min_height.is_some() + || header.max_height.is_some() + || header.aspect_ratio.is_some() + { + return Err(RenderabilityError::new( + "a derived box cannot carry size constraints or aspect-ratio", + )); + } + if matches!(header.x, AxisBinding::Span { .. }) + || matches!(header.y, AxisBinding::Span { .. }) + { + return Err(RenderabilityError::new( + "a derived origin cannot use Span bindings", + )); + } + return Ok(()); + } + + let has_aspect = match header.aspect_ratio { + None => false, + Some((width, height)) + if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 => + { + true + } + Some(_) => { + return Err(RenderabilityError::new( + "aspect-ratio terms must be finite and greater than zero", + )); + } + }; + let width_supplied = matches!(header.width, SizeIntent::Fixed(_)) + || matches!(header.x, AxisBinding::Span { .. }); + let height_supplied = matches!(header.height, SizeIntent::Fixed(_)) + || matches!(header.y, AxisBinding::Span { .. }); + + match payload { + Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse, + } => { + if !matches!( + (width_supplied, height_supplied, has_aspect), + (true, true, false) | (true, false, true) | (false, true, true) + ) { + return Err(RenderabilityError::new( + " and require both axes supplied by a fixed size or Span, or exactly one supplied axis plus aspect-ratio", + )); + } + } + Payload::Shape { + desc: ShapeDesc::Path(_), + } => { + if !matches!( + (width_supplied, height_supplied, has_aspect), + (true, true, false) | (true, false, true) | (false, true, true) + ) { + return Err(RenderabilityError::new( + " requires both axes supplied by a fixed size or Span, or exactly one supplied axis plus aspect-ratio", + )); + } + } + Payload::Shape { + desc: ShapeDesc::Line, + } => { + if !width_supplied { + return Err(RenderabilityError::new( + " requires a fixed width or x Span", + )); + } + } + Payload::Frame { .. } | Payload::Text { .. } | Payload::AttributedText { .. } => { + if has_aspect { + return Err(RenderabilityError::new( + "aspect-ratio is only valid on , , or ", + )); + } + } + Payload::Group | Payload::Lens { .. } => unreachable!("handled above"), + } + Ok(()) +} + +fn validate_opacity(opacity: f32, tag: &str) -> Result<(), RenderabilityError> { + if !opacity.is_finite() || !(0.0..=1.0).contains(&opacity) { + return Err(RenderabilityError::new(format!( + "<{tag}> opacity must be finite and between 0 and 1" + ))); + } + Ok(()) +} + +fn validate_affine(transform: Affine, tag: &str) -> Result<(), RenderabilityError> { + if [ + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ] + .iter() + .any(|value| !value.is_finite()) + { + return Err(RenderabilityError::new(format!( + "<{tag}> transform must contain only finite numbers" + ))); + } + + // Skia's gradient factories reject a local matrix when it cannot be + // inverted. The renderer composes this authored transform with the + // resolved paint box before constructing the shader, so Skia's numerical + // conditioning cutoff is box-dependent and does not belong at this pure + // paint boundary. Exact singularity of the authored linear part is the + // invariant case: multiplying by any non-degenerate paint box cannot make + // it invertible. Evaluate the determinant in f64 so finite f32 products do + // not overflow or underflow before the comparison. + let determinant = f64::from(transform.a) * f64::from(transform.d) + - f64::from(transform.b) * f64::from(transform.c); + if !determinant.is_finite() || determinant == 0.0 { + return Err(RenderabilityError::new(format!( + "<{tag}> transform must be invertible" + ))); + } + Ok(()) +} + +/// Linear gradients at or below this unit-space length become a +/// backend-selected degenerate shader/fallback in the pinned Skia backend +/// instead of preserving a linear ramp. Draft 0 rejects that implicit +/// narrowing. +pub const LINEAR_GRADIENT_DEGENERATE_THRESHOLD: f32 = 1.0 / (1 << 15) as f32; + +/// Reproduce Skia's f32 `SkPoint::Length` path. In particular, endpoint +/// subtraction and the ordinary squared length happen in f32; only an +/// overflowing squared length is retried in f64. +fn skia_point_length(dx: f32, dy: f32) -> f32 { + let squared = dx * dx + dy * dy; + if squared.is_finite() { + squared.sqrt() + } else { + let dx = f64::from(dx); + let dy = f64::from(dy); + (dx * dx + dy * dy).sqrt() as f32 + } +} + +fn alignment_uv_component(value: f32) -> f32 { + ((f64::from(value) + 1.0) * 0.5) as f32 +} + +fn validate_linear_gradient_endpoints( + from: Alignment, + to: Alignment, +) -> Result<(), RenderabilityError> { + if !from.0.is_finite() || !from.1.is_finite() || !to.0.is_finite() || !to.1.is_finite() { + return Err(RenderabilityError::new( + " endpoints must be finite", + )); + } + + // Match the renderer's centered-alignment -> UV lowering before applying + // Skia's endpoint test. Distinct model values can quantize to the same f32 + // UV coordinate, so comparing `Alignment` values is insufficient. + let from = ( + alignment_uv_component(from.0), + alignment_uv_component(from.1), + ); + let to = (alignment_uv_component(to.0), alignment_uv_component(to.1)); + let distance = skia_point_length(to.0 - from.0, to.1 - from.1); + if !distance.is_finite() { + return Err(RenderabilityError::new( + " endpoint distance must be finite in unit space", + )); + } + if distance <= LINEAR_GRADIENT_DEGENERATE_THRESHOLD { + return Err(RenderabilityError::new(format!( + " endpoints must be farther apart than {} in unit space", + LINEAR_GRADIENT_DEGENERATE_THRESHOLD + ))); + } + Ok(()) +} + +fn validate_gradient_stops(stops: &[GradientStop], tag: &str) -> Result<(), RenderabilityError> { + if stops.len() < 2 { + return Err(RenderabilityError::new(format!( + "<{tag}> requires at least two stops" + ))); + } + let mut previous = None; + for stop in stops { + if !stop.offset.is_finite() || !(0.0..=1.0).contains(&stop.offset) { + return Err(RenderabilityError::new(format!( + "<{tag}> stop offset must be finite and between 0 and 1" + ))); + } + if previous.is_some_and(|offset| stop.offset < offset) { + return Err(RenderabilityError::new(format!( + "<{tag}> stop offsets must be nondecreasing" + ))); + } + previous = Some(stop.offset); + } + Ok(()) +} + +pub fn validate_paint(paint: &Paint) -> Result<(), RenderabilityError> { + match paint { + Paint::Solid(_) => Ok(()), + Paint::LinearGradient(gradient) => { + validate_opacity(gradient.opacity, "gradient")?; + validate_affine(gradient.transform, "gradient")?; + validate_gradient_stops(&gradient.stops, "gradient")?; + validate_linear_gradient_endpoints(gradient.xy1, gradient.xy2) + } + Paint::RadialGradient(gradient) => { + validate_opacity(gradient.opacity, "gradient")?; + validate_affine(gradient.transform, "gradient")?; + validate_gradient_stops(&gradient.stops, "gradient") + } + Paint::SweepGradient(gradient) => { + validate_opacity(gradient.opacity, "gradient")?; + validate_affine(gradient.transform, "gradient")?; + validate_gradient_stops(&gradient.stops, "gradient") + } + Paint::DiamondGradient(gradient) => { + validate_opacity(gradient.opacity, "gradient")?; + validate_affine(gradient.transform, "gradient")?; + validate_gradient_stops(&gradient.stops, "gradient") + } + Paint::Image(image) => { + validate_opacity(image.opacity, "image")?; + let resource = match &image.image { + ResourceRef::Hash(value) | ResourceRef::Rid(value) => value, + }; + if resource.trim().is_empty() { + return Err(RenderabilityError::new( + " resource must not be empty", + )); + } + if image.quarter_turns != 0 { + return Err(RenderabilityError::new( + "the proving renderer does not support image quarter-turns", + )); + } + if image.alignment != Alignment::CENTER { + return Err(RenderabilityError::new( + "the proving renderer does not support non-centered image alignment", + )); + } + if image.filters != ImageFilters::default() { + return Err(RenderabilityError::new( + "the proving renderer does not support image filters", + )); + } + match image.fit { + ImagePaintFit::Fit(_) => {} + ImagePaintFit::Transform(_) => { + return Err(RenderabilityError::new( + "the proving renderer does not support transformed image fit", + )); + } + ImagePaintFit::Tile(_) => { + return Err(RenderabilityError::new( + "the proving renderer does not support tiled image fit", + )); + } + } + Ok(()) + } + } +} + +pub fn validate_paints(paints: &Paints) -> Result<(), RenderabilityError> { + for paint in paints.iter() { + validate_paint(paint)?; + } + Ok(()) +} + +pub fn validate_smooth_corner_radii( + corner_radius: RectangularCornerRadius, + corner_smoothing: CornerSmoothing, +) -> Result<(), RenderabilityError> { + if !corner_smoothing.is_zero() && !corner_radius.is_circular() { + Err(RenderabilityError::new( + "the proving renderer requires circular corner radii (rx must equal ry) when corner-smoothing is nonzero", + )) + } else { + Ok(()) + } +} + +pub fn validate_stroke( + stroke: &Stroke, + payload: &Payload, + corner_smoothing: CornerSmoothing, +) -> Result<(), RenderabilityError> { + validate_paints(&stroke.paints)?; + let default = Stroke::default_for(payload).ok_or_else(|| { + RenderabilityError::new(format!("<{}> cannot carry strokes", payload_name(payload))) + })?; + match stroke.width { + StrokeWidth::None => {} + StrokeWidth::Uniform(width) => { + if !width.is_finite() || width < 0.0 { + return Err(RenderabilityError::new( + " width must be finite and non-negative", + )); + } + } + StrokeWidth::Rectangular(widths) => { + for (side, width) in ["top", "right", "bottom", "left"] + .into_iter() + .zip(widths.values()) + { + if !width.is_finite() || width < 0.0 { + return Err(RenderabilityError::new(format!( + " width {side} must be finite and non-negative" + ))); + } + } + } + } + if !stroke.miter_limit.is_finite() || stroke.miter_limit <= 0.0 { + return Err(RenderabilityError::new( + " miter-limit must be finite and positive", + )); + } + if let Some(values) = &stroke.dash_array { + if values.is_empty() + || values + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + || values.iter().all(|value| *value == 0.0) + { + return Err(RenderabilityError::new( + " dash-array must contain non-negative finite values and not be all zero", + )); + } + } + + match payload { + Payload::Shape { + desc: ShapeDesc::Line, + } => { + if stroke.align != StrokeAlign::Center { + return Err(RenderabilityError::new( + "a stroke must use align=\"center\"", + )); + } + if stroke.join != default.join || stroke.miter_limit != default.miter_limit { + return Err(RenderabilityError::new( + "a stroke cannot carry join or miter-limit state", + )); + } + } + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect, + } => { + if stroke.cap != default.cap { + return Err(RenderabilityError::new( + "a container/rect stroke cannot carry cap state", + )); + } + } + Payload::Shape { + desc: ShapeDesc::Ellipse, + } => { + if stroke.cap != default.cap + || stroke.join != default.join + || stroke.miter_limit != default.miter_limit + { + return Err(RenderabilityError::new( + "an ellipse stroke cannot carry cap, join, or miter-limit state", + )); + } + } + Payload::Shape { + desc: ShapeDesc::Path(path), + } => { + if !path.all_contours_closed && stroke.align != StrokeAlign::Center { + return Err(RenderabilityError::new( + "a stroke may use inside/outside alignment only when every drawable contour is explicitly closed", + )); + } + } + Payload::Text { .. } | Payload::AttributedText { .. } => { + if stroke.cap != default.cap + || stroke.join != default.join + || stroke.miter_limit != default.miter_limit + || stroke.dash_array.is_some() + { + return Err(RenderabilityError::new( + "a text stroke cannot carry cap, join, miter-limit, or dash-array state", + )); + } + } + Payload::Group | Payload::Lens { .. } => unreachable!("checked above"), + } + if matches!(stroke.width.normalized(), StrokeWidth::Rectangular(_)) { + let supports_per_side = matches!(payload, Payload::Frame { .. }) + || matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Rect + } + ); + if !supports_per_side { + return Err(RenderabilityError::new(format!( + "<{}> cannot carry per-side stroke width", + payload_name(payload) + ))); + } + if !corner_smoothing.is_zero() { + return Err(RenderabilityError::new( + "the proving renderer requires corner-smoothing=\"0\" for per-side stroke width", + )); + } + if stroke.join != default.join || stroke.miter_limit != default.miter_limit { + return Err(RenderabilityError::new( + "the proving renderer requires the default join=\"miter\" and miter-limit=\"4\" for per-side stroke width", + )); + } + } + Ok(()) +} diff --git a/model-v2/a/lab/src/resolve.rs b/model-v2/a/lab/src/resolve.rs index 39ad9cc31c..8e0979d490 100644 --- a/model-v2/a/lab/src/resolve.rs +++ b/model-v2/a/lab/src/resolve.rs @@ -15,6 +15,7 @@ use crate::math::{rotated_aabb_size, Affine, RectF}; use crate::model::*; use crate::path::{materialize, ResolvedPathArtifact}; +use crate::properties::ValueView; use crate::text_layout::{TextLayout, TextLayoutOracle, STUB_TEXT_LAYOUT_ORACLE}; use std::sync::Arc; use taffy::prelude::{auto, length, AvailableSpace, TaffyTree}; @@ -74,11 +75,51 @@ pub enum Report { }, } +/// Effective descendant-clip geometry captured by resolution for the spatial +/// read tier. Paint and query therefore consume the same clip decision without +/// querying authored state or an independently pairable [`ValueView`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct ResolvedContentClip { + pub(crate) corner_radius: RectangularCornerRadius, + pub(crate) corner_smoothing: CornerSmoothing, +} + +const NO_QUERY_CLIP: u32 = u32::MAX; +const NO_QUERY_NODE: u32 = u32::MAX; + +/// Compact immutable traversal state needed by the narrowphase. Child order, +/// transparent-select behavior, and effective clipping are frame outputs, not +/// late reads from a possibly newer or differently evaluated document. +/// +/// Children and clips live in frame-owned flat pools. Keeping only offsets in +/// this per-slot column avoids one heap-owning `Vec` and one full corner record +/// for every node while preserving a self-contained query snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ResolvedQueryNode { + children_start: u32, + children_len: u32, + content_clip: u32, + pub(crate) box_is_derived: bool, +} + +const UNRESOLVED_QUERY_NODE: ResolvedQueryNode = ResolvedQueryNode { + children_start: NO_QUERY_NODE, + children_len: 0, + content_clip: NO_QUERY_CLIP, + box_is_derived: false, +}; + +pub(crate) struct ResolvedQueryNodeView<'a> { + pub(crate) children: &'a [NodeId], + pub(crate) box_is_derived: bool, + pub(crate) content_clip: Option, +} + /// The resolved tier is **SOA**: index-aligned columns over the node /// arena (`NodeId` = index), written once per resolve, read every frame /// by paint/HUD/pick — the hot half of the hot/cold split (cold intent /// stays AoS in the arena). `None` = not resolved (hidden subtree). -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct Resolved { /// Unrotated box in parent space (derived kinds: the placed union box). pub(crate) box_in_parent: Vec>, @@ -91,6 +132,13 @@ pub struct Resolved { /// Box-mapped path commands and bounds, quantized once for every internal /// consumer while retaining the validated unit-reference source. pub(crate) resolved_paths: Vec>>, + /// Root and per-node traversal facts for the spatial narrowphase. These + /// snapshot the exact structure and effective clip state used by this + /// resolution, so query never pairs the hot columns with another view. + pub(crate) query_root: Option, + pub(crate) query_nodes: Vec, + pub(crate) query_children: Vec, + pub(crate) query_clips: Vec, pub reports: Vec, } @@ -103,6 +151,10 @@ impl Resolved { world_aabb: vec![None; cap], text_layouts: vec![None; cap], resolved_paths: vec![None; cap], + query_root: None, + query_nodes: vec![UNRESOLVED_QUERY_NODE; cap], + query_children: Vec::with_capacity(cap.saturating_sub(1)), + query_clips: Vec::new(), reports: Vec::new(), } } @@ -147,6 +199,72 @@ impl Resolved { pub fn resolved_path_opt(&self, id: NodeId) -> Option<&Arc> { self.resolved_paths.get(id as usize)?.as_ref() } + pub(crate) fn query_root(&self) -> Option { + self.query_root + } + pub(crate) fn query_node_opt(&self, id: NodeId) -> Option> { + let node = self.query_nodes.get(id as usize)?; + if node.children_start == NO_QUERY_NODE { + return None; + } + let start = node.children_start as usize; + let end = start + node.children_len as usize; + let content_clip = (node.content_clip != NO_QUERY_CLIP) + .then(|| self.query_clips[node.content_clip as usize]); + Some(ResolvedQueryNodeView { + children: &self.query_children[start..end], + box_is_derived: node.box_is_derived, + content_clip, + }) + } + /// Bit-exact equality of the resolved spatial traversal/clip snapshot. + /// Kept as a closed comparison so downstream determinism oracles do not + /// need access to the private narrowphase representation. + pub fn query_snapshot_bits_eq(&self, other: &Resolved) -> bool { + fn scalar(a: f32, b: f32) -> bool { + a.to_bits() == b.to_bits() + } + fn clip(a: ResolvedContentClip, b: ResolvedContentClip) -> bool { + let a_radii = [ + a.corner_radius.tl.rx, + a.corner_radius.tl.ry, + a.corner_radius.tr.rx, + a.corner_radius.tr.ry, + a.corner_radius.br.rx, + a.corner_radius.br.ry, + a.corner_radius.bl.rx, + a.corner_radius.bl.ry, + a.corner_smoothing.value(), + ]; + let b_radii = [ + b.corner_radius.tl.rx, + b.corner_radius.tl.ry, + b.corner_radius.tr.rx, + b.corner_radius.tr.ry, + b.corner_radius.br.rx, + b.corner_radius.br.ry, + b.corner_radius.bl.rx, + b.corner_radius.bl.ry, + b.corner_smoothing.value(), + ]; + a_radii.into_iter().zip(b_radii).all(|(a, b)| scalar(a, b)) + } + self.query_root == other.query_root + && self.query_nodes.len() == other.query_nodes.len() + && self.query_children == other.query_children + && self.query_clips.len() == other.query_clips.len() + && self + .query_clips + .iter() + .copied() + .zip(other.query_clips.iter().copied()) + .all(|(a, b)| clip(a, b)) + && self + .query_nodes + .iter() + .zip(&other.query_nodes) + .all(|(a, b)| a == b) + } /// Number of slots in the resolved columns (matches the document's /// arena capacity) — the upper bound for a full-column walk. Distinct /// from [`Self::resolved_count`], which counts only the `Some` entries. @@ -168,7 +286,7 @@ impl Resolved { } struct Ctx<'a> { - doc: &'a Document, + view: &'a ValueView<'a>, text_layout: &'a dyn TextLayoutOracle, opts: ResolveOptions, out: Resolved, @@ -185,7 +303,7 @@ struct Ctx<'a> { } pub fn resolve(doc: &Document, opts: &ResolveOptions) -> Resolved { - resolve_with_text_layout(doc, opts, &STUB_TEXT_LAYOUT_ORACLE) + resolve_view(&ValueView::base(doc), opts) } /// Resolve with an explicit text-layout authority. @@ -194,14 +312,33 @@ pub fn resolve_with_text_layout( opts: &ResolveOptions, text_layout: &dyn TextLayoutOracle, ) -> Resolved { + resolve_view_with_text_layout(&ValueView::base(doc), opts, text_layout) +} + +/// Resolve one validated authored-plus-property view. +pub fn resolve_view(view: &ValueView<'_>, opts: &ResolveOptions) -> Resolved { + resolve_view_with_text_layout(view, opts, &STUB_TEXT_LAYOUT_ORACLE) +} + +/// Resolve one validated value view with an explicit text-layout authority. +pub fn resolve_view_with_text_layout( + view: &ValueView<'_>, + opts: &ResolveOptions, + text_layout: &dyn TextLayoutOracle, +) -> Resolved { + let doc = view.document(); + if !view.active(doc.root) { + return Resolved::with_capacity(doc.capacity()); + } let mut cx = Ctx { - doc, + view, text_layout, opts: *opts, out: Resolved::with_capacity(doc.capacity()), union_cache: vec![None; doc.capacity()], ops_cache: vec![None; doc.capacity()], }; + cx.out.query_root = Some(doc.root); let vp = RectF { x: 0.0, y: 0.0, @@ -285,12 +422,12 @@ pub fn resolve_with_text_layout( /// Resolved extent of a node (its unrotated box size), independent of /// position. `parent_extent` is needed only by Span bindings. fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f32, f32) { - let node = cx.doc.get(id); + let node = cx.view.document().get(id); if node.payload.box_is_derived() { // width/height intents are ignored-by-rule on derived kinds (§8). - if !matches!(node.header.width, SizeIntent::Auto) - || !matches!(node.header.height, SizeIntent::Auto) + if !matches!(cx.view.authored_width(id), SizeIntent::Auto) + || !matches!(cx.view.authored_height(id), SizeIntent::Auto) { cx.out.reports.push(Report::IgnoredByRule { node: id, @@ -304,14 +441,20 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 // Span owns the axis extent (§2.1); SizeIntent on a spanned axis is // ignored-by-rule. - let span_w = span_extent(id, node.header.x, parent_extent.map(|e| e.0), "x", cx); - let span_h = span_extent(id, node.header.y, parent_extent.map(|e| e.1), "y", cx); + let span_w = span_extent(id, cx.view.x(id), parent_extent.map(|e| e.0), "x", cx); + let span_h = span_extent(id, cx.view.y(id), parent_extent.map(|e| e.1), "y", cx); + let is_line = matches!( + node.payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ); let mut w = span_w; - let mut h = span_h; - let text_auto_height = h.is_none() - && matches!(node.header.height, SizeIntent::Auto) - && node.payload.as_text().is_some(); + let mut h = if is_line { Some(0.0) } else { span_h }; + let text_auto_height = node.payload.as_text().is_some() + && h.is_none() + && matches!(cx.view.height_unchecked(id), SizeIntent::Auto); if w.is_none() { w = intent_extent_x(id, cx); @@ -321,7 +464,15 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 } // aspect_ratio resolves an under-specified axis only (G-5). - if let Some((ar_w, ar_h)) = node.header.aspect_ratio { + let aspect_ratio = matches!( + node.payload, + Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse | ShapeDesc::Path(_) + } + ) + .then(|| cx.view.aspect_ratio_unchecked(id)) + .flatten(); + if let Some((ar_w, ar_h)) = aspect_ratio { if ar_w > 0.0 && ar_h > 0.0 { match (w, h) { (Some(wv), None) => h = Some(wv * ar_h / ar_w), @@ -347,17 +498,17 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 id, "width", wv, - node.header.min_width, - node.header.max_width, + cx.view.min_width_unchecked(id), + cx.view.max_width_unchecked(id), cx, ); if text_auto_height { - let node = cx.doc.get(id); + let node = cx.view.document().get(id); let Some(text) = node.payload.as_text() else { unreachable!("text_auto_height is set only for text") }; let wrap_width = if span_w.is_some() - || matches!(node.header.width, SizeIntent::Fixed(_)) + || matches!(cx.view.width_unchecked(id), SizeIntent::Fixed(_)) || width_before_clamp.is_some_and(|before| wv < before) { Some(wv) @@ -381,14 +532,16 @@ fn extent_of(id: NodeId, parent_extent: Option<(f32, f32)>, cx: &mut Ctx) -> (f3 // Height clamps apply after any width-driven text re-measure. Min beats // max on both axes (G-4 declared rule). - hv = clamp_axis( - id, - "height", - hv, - node.header.min_height, - node.header.max_height, - cx, - ); + if !is_line { + hv = clamp_axis( + id, + "height", + hv, + cx.view.min_height_unchecked(id), + cx.view.max_height_unchecked(id), + cx, + ); + } (wv, hv) } @@ -455,8 +608,8 @@ fn span_extent( /// Natural width per kind, None when the kind has no natural width. fn intent_extent_x(id: NodeId, cx: &mut Ctx) -> Option { - let node = cx.doc.get(id); - match node.header.width { + let node = cx.view.document().get(id); + match cx.view.width_unchecked(id) { SizeIntent::Fixed(v) => Some(v), SizeIntent::Auto => match &node.payload { payload if payload.as_text().is_some() => Some( @@ -473,8 +626,8 @@ fn intent_extent_x(id: NodeId, cx: &mut Ctx) -> Option { /// Natural height per kind. Auto-height text is deliberately absent here: it /// remeasures after final width constraints in [`extent_of`]. fn intent_extent_y(id: NodeId, cx: &mut Ctx) -> Option { - let node = cx.doc.get(id); - match node.header.height { + let node = cx.view.document().get(id); + match cx.view.height_unchecked(id) { SizeIntent::Fixed(v) => Some(v), SizeIntent::Auto => match &node.payload { Payload::Frame { .. } => Some(hug_size(id, cx).1), @@ -485,18 +638,15 @@ fn intent_extent_y(id: NodeId, cx: &mut Ctx) -> Option { /// Hug (frame + Auto): the frame's natural content size (L-E1: padding box). fn hug_size(id: NodeId, cx: &mut Ctx) -> (f32, f32) { - let node = cx.doc.get(id); - let layout = match &node.payload { - Payload::Frame { layout, .. } => *layout, - _ => unreachable!("hug_size on non-frame"), - }; + let node = cx.view.document().get(id); + let layout = cx.view.layout(id); match layout.mode { LayoutMode::Flex => { - let definite_w = match node.header.width { + let definite_w = match cx.view.width_unchecked(id) { SizeIntent::Fixed(v) => Some(v), SizeIntent::Auto => None, }; - let definite_h = match node.header.height { + let definite_h = match cx.view.height_unchecked(id) { SizeIntent::Fixed(v) => Some(v), SizeIntent::Auto => None, }; @@ -512,11 +662,11 @@ fn hug_size(id: NodeId, cx: &mut Ctx) -> (f32, f32) { let mut max_x: f32 = 0.0; let mut max_y: f32 = 0.0; for child_id in children { - if !cx.doc.get(child_id).header.active { + if !cx.view.active(child_id) { continue; } let (cw, ch) = extent_of(child_id, None, cx); - let is_derived = cx.doc.get(child_id).payload.box_is_derived(); + let is_derived = cx.view.document().get(child_id).payload.box_is_derived(); let u = if is_derived { cx.union_cache[child_id as usize].unwrap_or(RectF::EMPTY) } else { @@ -527,9 +677,8 @@ fn hug_size(id: NodeId, cx: &mut Ctx) -> (f32, f32) { h: ch, } }; - let child = cx.doc.get(child_id); - let ox = start_offset_or_report(child_id, child.header.x, "x", cx); - let oy = start_offset_or_report(child_id, child.header.y, "y", cx); + let ox = start_offset_or_report(child_id, cx.view.x(child_id), "x", cx); + let oy = start_offset_or_report(child_id, cx.view.y(child_id), "y", cx); let aabb = if cx.opts.rotation_in_flow == RotationInFlow::VisualOnly { // V-3 (dec0-visual-only.md): CSS-pure hug — measurement // ignores rotation/flips; the child contributes its @@ -545,8 +694,8 @@ fn hug_size(id: NodeId, cx: &mut Ctx) -> (f32, f32) { // child's own pivot (census fix: derived kinds pivot at // the origin, not the box center), composition mirroring // commit exactly (flip included). - let theta = child.header.rotation; - let (cfx, cfy) = (child.header.flip_x, child.header.flip_y); + let theta = cx.view.rotation(child_id); + let (cfx, cfy) = (cx.view.flip_x(child_id), cx.view.flip_y(child_id)); let local = if is_derived { Affine::translate(ox, oy) .then(&Affine::rotate_deg(theta)) @@ -603,10 +752,10 @@ fn start_offset_or_report( /// writes re-target (ops::set_x works in deltas). fn place_by_bindings(id: NodeId, parent_extent: (f32, f32), cx: &mut Ctx) -> RectF { let (w, h) = extent_of(id, Some(parent_extent), cx); - let node = cx.doc.get(id); + let node = cx.view.document().get(id); if node.payload.box_is_derived() { let u = cx.union_cache[id as usize].unwrap_or(RectF::EMPTY); - let ox = match node.header.x { + let ox = match cx.view.x(id) { AxisBinding::Pin { anchor: AnchorEdge::Start, offset, @@ -628,7 +777,7 @@ fn place_by_bindings(id: NodeId, parent_extent: (f32, f32), cx: &mut Ctx) -> Rec start } }; - let oy = match node.header.y { + let oy = match cx.view.y(id) { AxisBinding::Pin { anchor: AnchorEdge::Start, offset, @@ -658,7 +807,7 @@ fn place_by_bindings(id: NodeId, parent_extent: (f32, f32), cx: &mut Ctx) -> Rec h, }; } - let x = match node.header.x { + let x = match cx.view.x(id) { AxisBinding::Pin { anchor: AnchorEdge::Start, offset, @@ -673,7 +822,7 @@ fn place_by_bindings(id: NodeId, parent_extent: (f32, f32), cx: &mut Ctx) -> Rec } => (parent_extent.0 - w) / 2.0 + offset, AxisBinding::Span { start, .. } => start, }; - let y = match node.header.y { + let y = match cx.view.y(id) { AxisBinding::Pin { anchor: AnchorEdge::Start, offset, @@ -701,10 +850,10 @@ fn union_of_derived(id: NodeId, cx: &mut Ctx) -> RectF { if let Some(u) = cx.union_cache[id as usize] { return u; } - let children: Vec = cx.doc.get(id).children.clone(); + let children: Vec = cx.view.document().get(id).children.clone(); let mut union: Option = None; for child_id in children { - if !cx.doc.get(child_id).header.active { + if !cx.view.active(child_id) { continue; } // Children of derived-box parents resolve against no extent: @@ -712,15 +861,14 @@ fn union_of_derived(id: NodeId, cx: &mut Ctx) -> RectF { let (cw, ch) = extent_of(child_id, None, cx); // Derived children: pins place the ORIGIN; the box = origin + own // union offset (census fix: nested unions must not swallow it). - let derived_off = if cx.doc.get(child_id).payload.box_is_derived() { + let derived_off = if cx.view.document().get(child_id).payload.box_is_derived() { let u = cx.union_cache[child_id as usize].unwrap_or(RectF::EMPTY); (u.x, u.y) } else { (0.0, 0.0) }; - let child = cx.doc.get(child_id); - let ox = start_offset_or_report(child_id, child.header.x, "x", cx) + derived_off.0; - let oy = start_offset_or_report(child_id, child.header.y, "y", cx) + derived_off.1; + let ox = start_offset_or_report(child_id, cx.view.x(child_id), "x", cx) + derived_off.0; + let oy = start_offset_or_report(child_id, cx.view.y(child_id), "y", cx) + derived_off.1; let child_box = RectF { x: ox, y: oy, @@ -792,9 +940,44 @@ fn fold_lens_ops(ops: &[LensOp], u: RectF) -> Affine { /// `box_rect` is the node's unrotated box in parent space (derived kinds: /// where the union box lands). fn commit(id: NodeId, box_rect: RectF, cx: &mut Ctx) { - let node = cx.doc.get(id); - let theta = node.header.rotation; - let (fx, fy) = (node.header.flip_x, node.header.flip_y); + let node = cx.view.document().get(id); + let theta = cx.view.rotation(id); + let (fx, fy) = (cx.view.flip_x(id), cx.view.flip_y(id)); + + let content_clip = if matches!(node.payload, Payload::Frame { .. }) && cx.view.clips_content(id) + { + Some(ResolvedContentClip { + corner_radius: cx.view.corner_radius(id), + corner_smoothing: cx.view.corner_smoothing_unchecked(id), + }) + } else { + None + }; + let children_start = u32::try_from(cx.out.query_children.len()) + .expect("resolved query child pool exceeds the NodeId address space"); + assert_ne!( + children_start, NO_QUERY_NODE, + "resolved query child pool exhausted" + ); + let children_len = u32::try_from(node.children.len()) + .expect("one resolved node has more children than NodeId can address"); + cx.out.query_children.extend_from_slice(&node.children); + let content_clip = match content_clip { + Some(clip) => { + let index = u32::try_from(cx.out.query_clips.len()) + .expect("resolved query clip pool exceeds the NodeId address space"); + assert_ne!(index, NO_QUERY_CLIP, "resolved query clip pool exhausted"); + cx.out.query_clips.push(clip); + index + } + None => NO_QUERY_CLIP, + }; + cx.out.query_nodes[id as usize] = ResolvedQueryNode { + children_start, + children_len, + box_is_derived: node.payload.box_is_derived(), + content_clip, + }; let local = if node.payload.box_is_derived() { // Pivot = the node's own local origin (§5); the origin is recovered @@ -833,8 +1016,8 @@ fn commit(id: NodeId, box_rect: RectF, cx: &mut Ctx) { Derived, Leaf, } - let child_program = match &cx.doc.get(id).payload { - Payload::Frame { layout, .. } => ChildProgram::Frame(*layout), + let child_program = match &cx.view.document().get(id).payload { + Payload::Frame { .. } => ChildProgram::Frame(cx.view.layout(id)), Payload::Shape { .. } => ChildProgram::Free, Payload::Group | Payload::Lens { .. } => ChildProgram::Derived, Payload::Text { .. } | Payload::AttributedText { .. } => ChildProgram::Leaf, @@ -848,7 +1031,7 @@ fn commit(id: NodeId, box_rect: RectF, cx: &mut Ctx) { } ChildProgram::Derived => {} ChildProgram::Leaf => { - if !cx.doc.get(id).children.is_empty() { + if !cx.view.document().get(id).children.is_empty() { cx.out.reports.push(Report::ErrorByRule { node: id, field: "children", @@ -863,11 +1046,11 @@ fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), match layout.mode { LayoutMode::None => layout_free_children(id, extent, layout.padding, cx), LayoutMode::Flex => { - let children: Vec = cx.doc.get(id).children.clone(); + let children: Vec = cx.view.document().get(id).children.clone(); let out = flex_layout(id, layout, (Some(extent.0), Some(extent.1)), cx); for (child_id, slot, basis) in out.slots { - let child = cx.doc.get(child_id); - let theta = child.header.rotation; + let child = cx.view.document().get(child_id); + let theta = cx.view.rotation(child_id); let rotated = theta.rem_euclid(360.0) != 0.0; let is_derived = child.payload.box_is_derived(); let b = if is_derived { @@ -880,7 +1063,10 @@ fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), // Post-transform union center (R·F — same composition // as commit) lands on the slot center. let d = Affine::rotate_deg(theta) - .then(&Affine::flip(child.header.flip_x, child.header.flip_y)) + .then(&Affine::flip( + cx.view.flip_x(child_id), + cx.view.flip_y(child_id), + )) .apply((u.x + u.w / 2.0, u.y + u.h / 2.0)); RectF { x: scx - d.0 + u.x, @@ -915,8 +1101,7 @@ fn layout_frame_children(id: NodeId, layout: LayoutBehavior, extent: (f32, f32), // Absolute children: excluded from flow, resolve against the // parent box (L-4). for child_id in children { - let child = cx.doc.get(child_id); - if !child.header.active || child.header.flow != Flow::Absolute { + if !cx.view.active(child_id) || cx.view.flow(child_id) != Flow::Absolute { continue; } let b = place_by_bindings(child_id, extent, cx); @@ -936,9 +1121,9 @@ fn layout_free_children(id: NodeId, extent: (f32, f32), padding: EdgeInsets, cx: (extent.0 - padding.left - padding.right).max(0.0), (extent.1 - padding.top - padding.bottom).max(0.0), ); - let children: Vec = cx.doc.get(id).children.clone(); + let children: Vec = cx.view.document().get(id).children.clone(); for child_id in children { - if !cx.doc.get(child_id).header.active { + if !cx.view.active(child_id) { continue; } report_flow_fields_inert(child_id, cx); @@ -952,15 +1137,14 @@ fn layout_free_children(id: NodeId, extent: (f32, f32), padding: EdgeInsets, cx: /// §8: x/y and grow/self_align are inert outside their contexts — report /// non-default values so tests can assert the matrix is enforced. fn report_flow_fields_inert(id: NodeId, cx: &mut Ctx) { - let h = &cx.doc.get(id).header; - if h.grow != 0.0 { + if cx.view.grow(id) != 0.0 { cx.out.reports.push(Report::IgnoredByRule { node: id, field: "grow", rule: "no layout parent", }); } - if h.self_align != SelfAlign::Auto { + if cx.view.self_align(id) != SelfAlign::Auto { cx.out.reports.push(Report::IgnoredByRule { node: id, field: "self_align", @@ -987,7 +1171,7 @@ fn flex_layout( definite: (Option, Option), cx: &mut Ctx, ) -> FlexOut { - let children: Vec = cx.doc.get(id).children.clone(); + let children: Vec = cx.view.document().get(id).children.clone(); let mut tree: TaffyTree = TaffyTree::new(); // L-7 (declared POL): resolution is unquantized; pixel snapping is a @@ -1008,19 +1192,21 @@ fn flex_layout( let mut entries: Vec<(NodeId, taffy::NodeId, (f32, f32))> = Vec::new(); for child_id in &children { - let child = cx.doc.get(*child_id); - if !child.header.active || child.header.flow == Flow::Absolute { + let child = cx.view.document().get(*child_id); + if !cx.view.active(*child_id) || cx.view.flow(*child_id) == Flow::Absolute { continue; } // §8: x/y are ignored-by-rule under flow (layout owns them). - if child.header.x != AxisBinding::default() || child.header.y != AxisBinding::default() { + if cx.view.x(*child_id) != AxisBinding::default() + || cx.view.y(*child_id) != AxisBinding::default() + { cx.out.reports.push(Report::IgnoredByRule { node: *child_id, field: "x/y", rule: "in-flow under flex: layout owns position", }); } - let theta = child.header.rotation; + let theta = cx.view.rotation(*child_id); let rotated = theta.rem_euclid(360.0) != 0.0; let contribute_aabb = rotated && cx.opts.rotation_in_flow == RotationInFlow::AabbParticipates; @@ -1029,11 +1215,11 @@ fn flex_layout( // leaf display: taffy default (flex); Block distorts intrinsic sizing flex_shrink: 0.0, // X-SELF-8: canvas items don't shrink implicitly flex_grow: if main_is_definite { - child.header.grow + cx.view.grow(*child_id) } else { 0.0 }, - align_self: match child.header.self_align { + align_self: match cx.view.self_align(*child_id) { SelfAlign::Auto => None, SelfAlign::Start => Some(AlignSelf::Start), SelfAlign::Center => Some(AlignSelf::Center), @@ -1042,17 +1228,30 @@ fn flex_layout( }, ..Style::default() }; - if let Some(v) = child.header.min_width { - style.min_size.width = length(v); - } - if let Some(v) = child.header.max_width { - style.max_size.width = length(v); - } - if let Some(v) = child.header.min_height { - style.min_size.height = length(v); - } - if let Some(v) = child.header.max_height { - style.max_size.height = length(v); + let box_is_derived = child.payload.box_is_derived(); + let is_line = matches!( + child.payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ); + if !box_is_derived { + if let Some(v) = cx.view.min_width_unchecked(*child_id) { + style.min_size.width = length(v); + } + if let Some(v) = cx.view.max_width_unchecked(*child_id) { + style.max_size.width = length(v); + } + // Line height is geometry-locked to zero, so height constraints + // are not registered properties for that kind. + if !is_line { + if let Some(v) = cx.view.min_height_unchecked(*child_id) { + style.min_size.height = length(v); + } + if let Some(v) = cx.view.max_height_unchecked(*child_id) { + style.max_size.height = length(v); + } + } } let is_text = child.payload.as_text().is_some(); @@ -1069,18 +1268,18 @@ fn flex_layout( // Measured kind: taffy drives re-measure at layout-imposed // extents (L-5) through the measure closure. let text_payload = child.payload.clone(); - match child.header.width { + match cx.view.width_unchecked(*child_id) { SizeIntent::Fixed(v) => style.size.width = length(v), SizeIntent::Auto => style.size.width = auto(), } - match child.header.height { + match cx.view.height_unchecked(*child_id) { SizeIntent::Fixed(v) => style.size.height = length(v), SizeIntent::Auto => style.size.height = auto(), } // Per-child stretch is an explicit cross-axis fill override, even // when text authored a fixed cross size. Container-level stretch // still applies only to the Auto values left above. - if child.header.self_align == SelfAlign::Stretch { + if cx.view.self_align(*child_id) == SelfAlign::Stretch { match layout.direction { Direction::Row => style.size.height = auto(), Direction::Column => style.size.width = auto(), @@ -1095,29 +1294,29 @@ fn flex_layout( style.size.width = length(b.0); style.size.height = length(b.1); // An explicit per-child stretch overrides even a fixed cross size. - // Container-level stretch applies only when the authored cross + // Container-level stretch applies only when the effective cross // size remains Auto; retaining the resolved basis as Definite here - // would erase that authored intent before Taffy sees it. - let authored_cross_is_auto = match layout.direction { - Direction::Row => matches!(child.header.height, SizeIntent::Auto), - Direction::Column => matches!(child.header.width, SizeIntent::Auto), - }; + // would erase that effective intent before Taffy sees it. // A line's vertical extent is geometry, not a flex-owned box // axis. Even explicit self-stretch cannot turn its locked zero // height into a non-degenerate paint box in a row container. - let line_height_is_locked = layout.direction == Direction::Row - && matches!( - &child.payload, - Payload::Shape { - desc: ShapeDesc::Line + let line_height_is_locked = layout.direction == Direction::Row && is_line; + let cross_is_auto = !box_is_derived + && match layout.direction { + Direction::Row if line_height_is_locked => false, + Direction::Row => { + matches!(cx.view.height_unchecked(*child_id), SizeIntent::Auto) } - ); + Direction::Column => { + matches!(cx.view.width_unchecked(*child_id), SizeIntent::Auto) + } + }; let stretches_cross = !line_height_is_locked - && (child.header.self_align == SelfAlign::Stretch - || (!child.payload.box_is_derived() - && child.header.self_align == SelfAlign::Auto + && (cx.view.self_align(*child_id) == SelfAlign::Stretch + || (!box_is_derived + && cx.view.self_align(*child_id) == SelfAlign::Auto && layout.cross_align == CrossAlign::Stretch - && authored_cross_is_auto)); + && cross_is_auto)); if stretches_cross { match layout.direction { Direction::Row => style.size.height = auto(), @@ -1268,7 +1467,7 @@ fn compose_world(id: NodeId, parent_world: Affine, cx: &mut Ctx) { Some(ops) => world.then(&ops), None => world, }; - let children: Vec = cx.doc.get(id).children.clone(); + let children: Vec = cx.view.document().get(id).children.clone(); for c in children { if cx.out.local[c as usize].is_some() { compose_world(c, child_basis, cx); @@ -1297,7 +1496,10 @@ impl StrokeOutsets { /// coordinates. Layout remains based on the source box; only visual bounds /// read this expansion. Repeated stroke geometries overlap independently, so /// each edge takes their maximum outward reach rather than summing widths. -fn effective_stroke_outsets(node: &Node) -> StrokeOutsets { +fn effective_stroke_outsets(id: NodeId, node: &Node, view: &ValueView<'_>) -> StrokeOutsets { + if matches!(node.payload, Payload::Group | Payload::Lens { .. }) { + return StrokeOutsets::default(); + } let is_line = matches!( &node.payload, Payload::Shape { @@ -1311,10 +1513,21 @@ fn effective_stroke_outsets(node: &Node) -> StrokeOutsets { } ); let mut outsets = StrokeOutsets::default(); - for stroke in node - .strokes + let corner_smoothing = if matches!( + node.payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect + } + ) { + view.corner_smoothing_unchecked(id) + } else { + CornerSmoothing::default() + }; + for stroke in view + .strokes_unchecked(id) .iter() - .filter(|stroke| stroke.renderable_for(&node.payload, node.corner_smoothing)) + .filter(|stroke| stroke.renderable_for(&node.payload, corner_smoothing)) { let widths = stroke.width.rectangular(); let mut factor = if is_line { @@ -1357,11 +1570,11 @@ fn intersect_aabbs(a: RectF, b: RectF) -> Option { } fn compute_world_aabb(id: NodeId, cx: &mut Ctx) -> Option { - let node = cx.doc.get(id); + let node = cx.view.document().get(id); cx.out.world[id as usize]?; // hidden subtree let world = cx.out.world[id as usize].unwrap(); let own_box = cx.out.box_in_parent[id as usize].unwrap(); - let stroke_outsets = effective_stroke_outsets(node); + let stroke_outsets = effective_stroke_outsets(id, node, cx.view); // Text owns glyph ink, not its assigned layout box. The layout box still // drives placement and paint-coordinate normalization, while visual // bounds begin at the oracle's base ink and expand by the same authored @@ -1409,22 +1622,16 @@ fn compute_world_aabb(id: NodeId, cx: &mut Ctx) -> Option { // conservative over-approximation of pixels that can actually survive. // A rotated clip remains conservative here; exact polygon clipping is a // pick/narrowphase responsibility. - let child_clip = matches!( - &node.payload, - Payload::Frame { - clips_content: true, - .. - } - ) - .then(|| { - RectF { - x: 0.0, - y: 0.0, - w: own_box.w, - h: own_box.h, - } - .transformed_aabb(&world) - }); + let child_clip = (matches!(&node.payload, Payload::Frame { .. }) && cx.view.clips_content(id)) + .then(|| { + RectF { + x: 0.0, + y: 0.0, + w: own_box.w, + h: own_box.h, + } + .transformed_aabb(&world) + }); let children: Vec = node.children.clone(); for c in children { let child_aabb = compute_world_aabb(c, cx).and_then(|bounds| { diff --git a/model-v2/a/lab/src/rounded_box.rs b/model-v2/a/lab/src/rounded_box.rs new file mode 100644 index 0000000000..a507f9aef9 --- /dev/null +++ b/model-v2/a/lab/src/rounded_box.rs @@ -0,0 +1,395 @@ +//! Pure point containment for the rounded-box geometry shared by clips and +//! the proving renderer. + +use crate::model::{CornerSmoothing, Radius, RectangularCornerRadius}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SmoothCornerParams { + pub radius: f32, + pub extent: f32, + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub bezier_angle: f32, +} + +pub fn smooth_corner_params(radius: f32, smoothing: f32, shortest_side: f32) -> SmoothCornerParams { + let smoothing = smoothing.clamp(0.0, 1.0); + let radius = radius.min(shortest_side / 2.0).max(0.0); + let extent = ((1.0 + smoothing) * radius).min(shortest_side / 2.0); + let (circle_angle, bezier_angle) = if radius > shortest_side / 4.0 { + let change = (radius - shortest_side / 4.0) / (shortest_side / 4.0); + ( + 90.0 * (1.0 - smoothing * (1.0 - change)), + 45.0 * smoothing * (1.0 - change), + ) + } else { + (90.0 * (1.0 - smoothing), 45.0 * smoothing) + }; + let bezier_radians = bezier_angle.to_radians(); + let circle_radians = circle_angle.to_radians(); + let tangent = bezier_radians.tan(); + let longest = radius * (bezier_radians / 2.0).tan(); + let arc_chord = (circle_radians / 2.0).sin() * radius * 2.0_f32.sqrt(); + let c = longest * bezier_radians.cos(); + let d = c * tangent; + let b = ((extent - arc_chord) - (1.0 + tangent) * c) / 3.0; + SmoothCornerParams { + radius, + extent, + a: 2.0 * b, + b, + c, + d, + bezier_angle, + } +} + +/// Normalize ordinary elliptical radii with Skia's `SkRRect` discipline. +/// +/// This is the backend-independent geometry used by hit testing. Raster code +/// should pass authored radii directly to `SkRRect` so the backend performs +/// the same normalization exactly once rather than re-normalizing this result. +pub fn normalize_radii( + width: f32, + height: f32, + mut radius: RectangularCornerRadius, +) -> RectangularCornerRadius { + if !width.is_finite() + || !height.is_finite() + || width <= 0.0 + || height <= 0.0 + || ![ + radius.tl.rx, + radius.tl.ry, + radius.tr.rx, + radius.tr.ry, + radius.br.rx, + radius.br.ry, + radius.bl.rx, + radius.bl.ry, + ] + .into_iter() + .all(f32::is_finite) + { + return RectangularCornerRadius::default(); + } + + // SkRRect makes a corner square when either component is non-positive. + // Do this before computing the global W3 overlap scale: a surviving axis + // of a square corner must not influence the other corners. + for corner in [ + &mut radius.tl, + &mut radius.tr, + &mut radius.br, + &mut radius.bl, + ] { + if corner.rx <= 0.0 || corner.ry <= 0.0 { + *corner = Radius::default(); + } + } + + // Skia intentionally performs these sums and ratios in double precision. + // In f32, two finite extreme radii can overflow their sum and collapse the + // scale to zero (crbug.com/463920). + let width = f64::from(width); + let height = f64::from(height); + let mut scale = 1.0_f64; + for (limit, first, second) in [ + (width, radius.tl.rx, radius.tr.rx), + (height, radius.tr.ry, radius.br.ry), + (width, radius.br.rx, radius.bl.rx), + (height, radius.bl.ry, radius.tl.ry), + ] { + let sum = f64::from(first) + f64::from(second); + if sum > limit { + scale = scale.min(limit / sum); + } + } + + // If float addition cannot distinguish the smaller radius, Skia flushes + // it before applying the already-computed global scale. + flush_swallowed_radius(&mut radius.tl.rx, &mut radius.tr.rx); + flush_swallowed_radius(&mut radius.tr.ry, &mut radius.br.ry); + flush_swallowed_radius(&mut radius.br.rx, &mut radius.bl.rx); + flush_swallowed_radius(&mut radius.bl.ry, &mut radius.tl.ry); + + if scale < 1.0 { + adjust_side(width, scale, &mut radius.tl.rx, &mut radius.tr.rx); + adjust_side(height, scale, &mut radius.tr.ry, &mut radius.br.ry); + adjust_side(width, scale, &mut radius.br.rx, &mut radius.bl.rx); + adjust_side(height, scale, &mut radius.bl.ry, &mut radius.tl.ry); + } + + // A zero x or y radius is a square corner in Skia; its companion cannot + // remain active after flushing or side adjustment. + for corner in [ + &mut radius.tl, + &mut radius.tr, + &mut radius.br, + &mut radius.bl, + ] { + if corner.rx <= 0.0 || corner.ry <= 0.0 { + *corner = Radius::default(); + } + } + + radius +} + +fn flush_swallowed_radius(a: &mut f32, b: &mut f32) { + if *a + *b == *a { + *b = 0.0; + } else if *a + *b == *b { + *a = 0.0; + } +} + +fn adjust_side(limit: f64, scale: f64, a: &mut f32, b: &mut f32) { + *a = (f64::from(*a) * scale) as f32; + *b = (f64::from(*b) * scale) as f32; + + if f64::from(*a + *b) <= limit { + return; + } + + let (smaller, larger) = if *a <= *b { (a, b) } else { (b, a) }; + let smaller_value = *smaller; + let mut larger_value = (limit - f64::from(smaller_value)) as f32; + while f64::from(larger_value + smaller_value) > limit { + larger_value = next_down_nonnegative(larger_value); + } + *larger = larger_value; +} + +fn next_down_nonnegative(value: f32) -> f32 { + if value > 0.0 { + f32::from_bits(value.to_bits() - 1) + } else { + 0.0 + } +} + +fn ordinary_corner_contains(x: f32, y: f32, radius: Radius) -> bool { + if radius.rx == 0.0 || radius.ry == 0.0 || x >= radius.rx || y >= radius.ry { + return true; + } + let nx = (x - radius.rx) / radius.rx; + let ny = (y - radius.ry) / radius.ry; + nx * nx + ny * ny <= 1.0 +} + +fn cubic(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { + let u = 1.0 - t; + u * u * u * p0 + 3.0 * u * u * t * p1 + 3.0 * u * t * t * p2 + t * t * t * p3 +} + +fn smooth_corner_contains(mut x: f32, mut y: f32, corner: SmoothCornerParams) -> bool { + if corner.radius == 0.0 || x >= corner.extent || y >= corner.extent { + return true; + } + if y > x { + std::mem::swap(&mut x, &mut y); + } + let diagonal = corner.radius * (1.0 - std::f32::consts::FRAC_1_SQRT_2); + if y >= diagonal { + return true; + } + let boundary = if corner.d > 0.0 && y <= corner.d { + let t = (y / corner.d).cbrt(); + cubic( + corner.extent, + corner.extent - corner.a, + corner.extent - corner.a - corner.b, + corner.extent - corner.a - corner.b - corner.c, + t, + ) + } else { + let dy = y - corner.radius; + corner.radius - (corner.radius * corner.radius - dy * dy).max(0.0).sqrt() + }; + x >= boundary +} + +/// Test a local-space point against the renderer's ordinary elliptical +/// rounded rectangle or its production circular smooth-corner path. Boundary +/// points are contained; no curve flattening or surrogate superellipse is +/// used. +pub fn contains( + width: f32, + height: f32, + corner_radius: RectangularCornerRadius, + corner_smoothing: CornerSmoothing, + point: (f32, f32), +) -> bool { + let (x, y) = point; + if ![width, height, x, y].iter().all(|value| value.is_finite()) + || width <= 0.0 + || height <= 0.0 + || x < 0.0 + || x > width + || y < 0.0 + || y > height + { + return false; + } + if corner_radius.is_zero() { + return true; + } + if corner_smoothing.is_zero() { + let radius = normalize_radii(width, height, corner_radius); + return ordinary_corner_contains(x, y, radius.tl) + && ordinary_corner_contains(width - x, y, radius.tr) + && ordinary_corner_contains(width - x, height - y, radius.br) + && ordinary_corner_contains(x, height - y, radius.bl); + } + + let shortest_side = width.min(height); + let smoothing = corner_smoothing.value(); + [ + (x, y, corner_radius.tl), + (width - x, y, corner_radius.tr), + (width - x, height - y, corner_radius.br), + (x, height - y, corner_radius.bl), + ] + .into_iter() + .all(|(x, y, radius)| { + smooth_corner_contains( + x, + y, + smooth_corner_params(radius.rx.min(radius.ry), smoothing, shortest_side), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_corner_uses_true_ellipse_geometry() { + let radius = RectangularCornerRadius::circular(30.0); + assert!(!contains( + 100.0, + 80.0, + radius, + CornerSmoothing::default(), + (1.0, 1.0) + )); + assert!(contains( + 100.0, + 80.0, + radius, + CornerSmoothing::default(), + (50.0, 1.0) + )); + assert!(contains( + 100.0, + 80.0, + radius, + CornerSmoothing::default(), + (50.0, 40.0) + )); + } + + #[test] + fn degenerate_rounded_box_has_no_clip_coverage() { + for radius in [ + RectangularCornerRadius::default(), + RectangularCornerRadius::circular(12.0), + ] { + assert!(!contains( + 0.0, + 40.0, + radius, + CornerSmoothing::default(), + (0.0, 20.0) + )); + assert!(!contains( + 40.0, + 0.0, + radius, + CornerSmoothing::default(), + (20.0, 0.0) + )); + } + } + + #[test] + fn ordinary_radii_use_skia_proportional_overlap_normalization() { + assert_eq!( + normalize_radii(100.0, 60.0, RectangularCornerRadius::circular(80.0)), + RectangularCornerRadius::circular(30.0) + ); + let elliptical = RectangularCornerRadius { + tl: Radius { rx: 80.0, ry: 20.0 }, + tr: Radius { rx: 40.0, ry: 10.0 }, + br: Radius { rx: 20.0, ry: 30.0 }, + bl: Radius { rx: 10.0, ry: 40.0 }, + }; + let normalized = normalize_radii(60.0, 60.0, elliptical); + assert_eq!(normalized.tl, Radius { rx: 40.0, ry: 10.0 }); + assert_eq!(normalized.tr, Radius { rx: 20.0, ry: 5.0 }); + assert_eq!(normalized.br, Radius { rx: 10.0, ry: 15.0 }); + assert_eq!(normalized.bl, Radius { rx: 5.0, ry: 20.0 }); + } + + #[test] + fn ordinary_radii_match_skia_for_extreme_finite_values() { + assert_eq!( + normalize_radii(100.0, 60.0, RectangularCornerRadius::circular(f32::MAX)), + RectangularCornerRadius::circular(30.0) + ); + + let extreme = RectangularCornerRadius { + tl: Radius { + rx: f32::MAX, + ry: 48.0, + }, + tr: Radius { rx: 1.0, ry: 7.0 }, + br: Radius { + rx: f32::MAX, + ry: 29.0, + }, + bl: Radius { rx: 3.0, ry: 9.0 }, + }; + let normalized = normalize_radii(100.0, 60.0, extreme); + assert!( + normalized.tl.rx > 0.0, + "finite extremes must not overflow to zero" + ); + assert!( + normalized.br.rx > 0.0, + "finite extremes must not overflow to zero" + ); + assert!(normalized.tl.rx + normalized.tr.rx <= 100.0); + assert!(normalized.br.rx + normalized.bl.rx <= 100.0); + assert!(normalized.tr.ry + normalized.br.ry <= 60.0); + assert!(normalized.bl.ry + normalized.tl.ry <= 60.0); + assert_eq!(normalize_radii(100.0, 60.0, normalized), normalized); + } + + #[test] + fn ordinary_radii_square_a_corner_when_either_axis_is_zero() { + let radius = RectangularCornerRadius { + tl: Radius { rx: 30.0, ry: 0.0 }, + tr: Radius { rx: 8.0, ry: 9.0 }, + br: Radius { rx: 7.0, ry: 6.0 }, + bl: Radius { rx: 5.0, ry: 4.0 }, + }; + let normalized = normalize_radii(100.0, 60.0, radius); + assert_eq!(normalized.tl, Radius::default()); + assert_eq!(normalized.tr, radius.tr); + assert_eq!(normalized.br, radius.br); + assert_eq!(normalized.bl, radius.bl); + } + + #[test] + fn smooth_corner_params_apply_the_production_half_short_side_cap() { + assert_eq!( + smooth_corner_params(80.0, 0.6, 100.0), + smooth_corner_params(50.0, 0.6, 100.0) + ); + } +} diff --git a/model-v2/a/lab/src/textir.rs b/model-v2/a/lab/src/textir.rs index c412466660..c9a9174c72 100644 --- a/model-v2/a/lab/src/textir.rs +++ b/model-v2/a/lab/src/textir.rs @@ -9,6 +9,7 @@ use crate::model::*; use crate::path; +use crate::renderability; use quick_xml::events::attributes::Attributes; use quick_xml::events::{BytesDecl, BytesStart, Event}; use quick_xml::Reader; @@ -565,11 +566,6 @@ fn parse_gradient( { return err("linear gradient endpoints overflow after lowering to model alignment"); } - if xy1 == xy2 { - return err( - "linear gradient from and to must differ after lowering to model alignment", - ); - } let tile_mode = match attributes.remove("tile-mode") { Some(value) => parse_tile_mode(&value)?, None => TileMode::Clamp, @@ -642,7 +638,7 @@ fn parse_paint_element( ) -> Result { let tag = String::from_utf8_lossy(el.name().as_ref()).to_string(); let attributes = collect_attributes(el)?; - match (tag.as_str(), is_empty) { + let paint = match (tag.as_str(), is_empty) { ("solid", true) => parse_solid(attributes), ("image", true) => parse_image_paint(attributes), ("gradient", false) => { @@ -656,7 +652,9 @@ fn parse_paint_element( err("kind-specific gradient tags are not Draft 0; use ") } _ => err(format!("unknown paint element <{tag}> in <{channel_tag}>")), - } + }?; + renderability::validate_paint(&paint).map_err(|error| ParseError(error.to_string()))?; + Ok(paint) } fn parse_paint_channel( @@ -1804,10 +1802,10 @@ fn parse_with_dialect(input: &str, dialect: Dialect) -> Result and , not <{tag}>" )); } - if !corner_smoothing.is_zero() && !corner_radius.is_circular() { - return err( - "nonzero corner-smoothing requires circular corner radii (rx must equal ry) in Draft 0", - ); + if let Err(reason) = + renderability::validate_smooth_corner_radii(corner_radius, corner_smoothing) + { + return err(reason.to_string()); } if matches!(header.x, AxisBinding::Span { .. }) && (width_seen || header.min_width.is_some() || header.max_width.is_some()) @@ -1927,6 +1925,8 @@ fn parse_with_dialect(input: &str, dialect: Dialect) -> Result Payload::Lens { ops: lens_ops }, _ => return err(format!("unknown element <{tag}>")), }; + renderability::validate_geometry(&header, &payload) + .map_err(|error| ParseError(error.to_string()))?; let id = next_id; next_id += 1; @@ -2408,7 +2408,9 @@ fn validate_corner_style_for_write(node: &Node, grida_xml: bool) -> Result<(), S path_aware_kind_name(&node.payload) )); } - if !node.corner_smoothing.is_zero() && !node.corner_radius.is_circular() { + if renderability::validate_smooth_corner_radii(node.corner_radius, node.corner_smoothing) + .is_err() + { return Err(format!( "node {} uses nonzero corner-smoothing with elliptical radii; Draft 0 cannot render that state losslessly", node.id @@ -2523,23 +2525,13 @@ pub(crate) fn validate_path_for_write(node: &Node) -> Result<(), String> { Ok(()) } -fn validate_common_paint(opacity: f32, tag: &str) -> Result<(), String> { - if !opacity.is_finite() || !(0.0..=1.0).contains(&opacity) { - return Err(format!( - "<{tag}> opacity must be finite and between 0 and 1" - )); - } - Ok(()) -} - fn push_common_paint_attrs( out: &mut String, active: bool, opacity: f32, blend_mode: BlendMode, - tag: &str, + _tag: &str, ) -> Result<(), String> { - validate_common_paint(opacity, tag)?; if !active { push_attr(out, "visible", "false"); } @@ -2552,50 +2544,11 @@ fn push_common_paint_attrs( Ok(()) } -fn validate_gradient_stops(stops: &[GradientStop], tag: &str) -> Result<(), String> { - if stops.len() < 2 { - return Err(format!("<{tag}> requires at least two stops")); - } - let mut previous = None; - for stop in stops { - if !stop.offset.is_finite() || !(0.0..=1.0).contains(&stop.offset) { - return Err(format!( - "<{tag}> stop offset must be finite and between 0 and 1" - )); - } - if previous.is_some_and(|offset| stop.offset < offset) { - return Err(format!("<{tag}> stop offsets must be nondecreasing")); - } - previous = Some(stop.offset); - } - Ok(()) -} - -fn validate_affine(transform: crate::math::Affine, tag: &str) -> Result<(), String> { - if [ - transform.a, - transform.b, - transform.c, - transform.d, - transform.e, - transform.f, - ] - .iter() - .any(|value| !value.is_finite()) - { - return Err(format!( - "<{tag}> transform must contain only finite numbers" - )); - } - Ok(()) -} - fn push_transform_attr( out: &mut String, transform: crate::math::Affine, - tag: &str, + _tag: &str, ) -> Result<(), String> { - validate_affine(transform, tag)?; if transform != crate::math::Affine::IDENTITY { push_attr( out, @@ -2616,11 +2569,10 @@ fn push_transform_attr( fn write_gradient_stops( stops: &[GradientStop], - tag: &str, + _tag: &str, depth: usize, out: &mut String, ) -> Result<(), String> { - validate_gradient_stops(stops, tag)?; let indent = " ".repeat(depth); for stop in stops { let _ = write!(out, "{indent} Result<(), S } fn write_paint(paint: &Paint, depth: usize, out: &mut String) -> Result<(), String> { + renderability::validate_paint(paint).map_err(|error| error.to_string())?; let indent = " ".repeat(depth); match paint { Paint::Solid(solid) => { @@ -2751,22 +2704,10 @@ fn write_paint(paint: &Paint, depth: usize, out: &mut String) -> Result<(), Stri return Err("Draft 0 XML cannot represent a hash image resource".into()); } }; - if image.quarter_turns != 0 { - return Err("Draft 0 XML cannot represent image quarter-turns".into()); - } - if image.alignment != Alignment::CENTER { - return Err("Draft 0 XML cannot represent non-centered image alignment".into()); - } - if image.filters != ImageFilters::default() { - return Err("Draft 0 XML cannot represent image filters".into()); - } let fit = match image.fit { ImagePaintFit::Fit(fit) => fit, - ImagePaintFit::Transform(_) => { - return Err("Draft 0 XML cannot represent transformed image fit".into()); - } - ImagePaintFit::Tile(_) => { - return Err("Draft 0 XML cannot represent tiled image fit".into()); + ImagePaintFit::Transform(_) | ImagePaintFit::Tile(_) => { + unreachable!("render capability validation accepts only named image fits") } }; let _ = write!(out, "{indent} Vec { } } -pub(crate) fn validate_stroke_for_write( - stroke: &Stroke, - payload: &Payload, - corner_smoothing: CornerSmoothing, -) -> Result<(), String> { - let default = Stroke::default_for(payload) - .ok_or_else(|| format!("<{}> cannot carry strokes", path_aware_kind_name(payload)))?; - match stroke.width { - StrokeWidth::None => {} - StrokeWidth::Uniform(width) => { - if !width.is_finite() || width < 0.0 { - return Err(" width must be finite and non-negative".into()); - } - } - StrokeWidth::Rectangular(widths) => { - for (side, width) in ["top", "right", "bottom", "left"] - .into_iter() - .zip(widths.values()) - { - if !width.is_finite() || width < 0.0 { - return Err(format!( - " width {side} must be finite and non-negative" - )); - } - } - } - } - if !stroke.miter_limit.is_finite() || stroke.miter_limit <= 0.0 { - return Err(" miter-limit must be finite and positive".into()); - } - if let Some(values) = &stroke.dash_array { - if values.is_empty() - || values - .iter() - .any(|value| !value.is_finite() || *value < 0.0) - || values.iter().all(|value| *value == 0.0) - { - return Err( - " dash-array must contain non-negative finite values and not be all zero" - .into(), - ); - } - } - - match payload { - Payload::Shape { - desc: ShapeDesc::Line, - } => { - if stroke.align != StrokeAlign::Center { - return Err("a stroke must use align=\"center\"".into()); - } - if stroke.join != default.join || stroke.miter_limit != default.miter_limit { - return Err("a stroke cannot carry join or miter-limit state".into()); - } - } - Payload::Frame { .. } - | Payload::Shape { - desc: ShapeDesc::Rect, - } => { - if stroke.cap != default.cap { - return Err("a container/rect stroke cannot carry cap state".into()); - } - } - Payload::Shape { - desc: ShapeDesc::Ellipse, - } => { - if stroke.cap != default.cap - || stroke.join != default.join - || stroke.miter_limit != default.miter_limit - { - return Err( - "an ellipse stroke cannot carry cap, join, or miter-limit state".into(), - ); - } - } - Payload::Shape { - desc: ShapeDesc::Path(path), - } => { - if !path.all_contours_closed && stroke.align != StrokeAlign::Center { - return Err( - "a stroke may use inside/outside alignment only when every drawable contour is explicitly closed" - .into(), - ); - } - } - Payload::Text { .. } | Payload::AttributedText { .. } => { - if stroke.cap != default.cap - || stroke.join != default.join - || stroke.miter_limit != default.miter_limit - || stroke.dash_array.is_some() - { - return Err( - "a text stroke cannot carry cap, join, miter-limit, or dash-array state".into(), - ); - } - } - Payload::Group | Payload::Lens { .. } => unreachable!("checked above"), - } - if matches!(stroke.width.normalized(), StrokeWidth::Rectangular(_)) { - let supports_per_side = matches!(payload, Payload::Frame { .. }) - || matches!( - payload, - Payload::Shape { - desc: ShapeDesc::Rect - } - ); - if !supports_per_side { - return Err(format!( - "<{}> cannot carry per-side stroke width", - path_aware_kind_name(payload) - )); - } - if !corner_smoothing.is_zero() { - return Err("per-side stroke width requires corner-smoothing=\"0\" in Draft 0".into()); - } - if stroke.join != default.join || stroke.miter_limit != default.miter_limit { - return Err( - "per-side stroke width requires the default join=\"miter\" and miter-limit=\"4\" in Draft 0" - .into(), - ); - } - } - Ok(()) -} - fn stroke_is_omitted(stroke: &Stroke, payload: &Payload) -> bool { stroke.paints.is_empty() && stroke.geometry_is_default_for(payload) } @@ -3000,7 +2816,8 @@ fn write_stroke( inline: bool, out: &mut String, ) -> Result<(), String> { - validate_stroke_for_write(stroke, payload, corner_smoothing)?; + renderability::validate_stroke(stroke, payload, corner_smoothing) + .map_err(|error| error.to_string())?; if stroke_is_omitted(stroke, payload) { return Ok(()); } @@ -3129,6 +2946,8 @@ fn print_node( _ => node.payload.kind_name(), }; validate_corner_style_for_write(node, grida_xml)?; + renderability::validate_geometry(&node.header, &node.payload) + .map_err(|error| error.to_string())?; let width_attr = if grida_xml { "width" } else { "w" }; let height_attr = if grida_xml { "height" } else { "h" }; let (min_width_attr, max_width_attr, min_height_attr, max_height_attr, aspect_attr) = @@ -3400,7 +3219,8 @@ fn print_node( Dialect::TextIr => vec![], Dialect::GridaXml => { for stroke in &node.strokes { - validate_stroke_for_write(stroke, &node.payload, node.corner_smoothing)?; + renderability::validate_stroke(stroke, &node.payload, node.corner_smoothing) + .map_err(|error| error.to_string())?; } node.strokes .iter() diff --git a/model-v2/a/lab/tests/arena_pick.rs b/model-v2/a/lab/tests/arena_pick.rs index c538a27519..982ccdecde 100644 --- a/model-v2/a/lab/tests/arena_pick.rs +++ b/model-v2/a/lab/tests/arena_pick.rs @@ -133,12 +133,12 @@ fn pick_is_oriented_not_aabb() { // box center always hits let (cx, cy) = (260.0, 212.0); - assert_eq!(pick(&doc, &r, cx, cy), Some(id)); + assert_eq!(pick(&r, cx, cy), Some(id)); // a point inside the AABB but OUTSIDE the oriented box must miss // (thin 45deg bar: the AABB corner region is empty space) let aabb = r.aabb_of(id); let corner = (aabb.x + 4.0, aabb.y + 4.0); - assert_eq!(pick(&doc, &r, corner.0, corner.1), Some(doc.root)); + assert_eq!(pick(&r, corner.0, corner.1), Some(doc.root)); } #[test] @@ -151,9 +151,9 @@ fn pick_topmost_wins() { let doc = b.build(); let r = resolve(&doc, &opts()); // overlap region: later sibling paints on top and wins the pick - assert_eq!(pick(&doc, &r, 175.0, 175.0), Some(over)); + assert_eq!(pick(&r, 175.0, 175.0), Some(over)); // non-overlapped part of the lower one still hits it - assert_eq!(pick(&doc, &r, 110.0, 110.0), Some(under)); + assert_eq!(pick(&r, 110.0, 110.0), Some(under)); } #[test] @@ -171,7 +171,7 @@ fn pick_promotes_to_outermost_group() { let r = resolve(&doc, &opts()); let _ = (inner, leaf); // clicking the leaf's ink selects the OUTERMOST group - assert_eq!(pick(&doc, &r, 120.0, 120.0), Some(outer)); + assert_eq!(pick(&r, 120.0, 120.0), Some(outer)); } #[test] @@ -193,9 +193,9 @@ fn pick_hits_lens_content_post_ops() { let r = resolve(&doc, &opts()); // the PRE-ops position is empty space (ops moved the ink away)... - assert_eq!(pick(&doc, &r, 330.0, 330.0), Some(doc.root)); + assert_eq!(pick(&r, 330.0, 330.0), Some(doc.root)); // ...the POST-ops position hits, and promotes to the lens - assert_eq!(pick(&doc, &r, 530.0, 330.0), Some(l)); + assert_eq!(pick(&r, 530.0, 330.0), Some(l)); } #[test] @@ -212,7 +212,7 @@ fn pick_hairline_line_is_grabbable() { ); let doc = b.build(); let r = resolve(&doc, &opts()); - assert_eq!(pick(&doc, &r, 180.0, 401.5), Some(line)); + assert_eq!(pick(&r, 180.0, 401.5), Some(line)); } #[test] @@ -236,14 +236,14 @@ fn pick_respects_the_exact_transformed_clip_of_every_ancestor() { let inside_clip = r.world_of(child).apply((10.0, 10.0)); assert_eq!( - pick(&doc, &r, inside_clip.0, inside_clip.1), + pick(&r, inside_clip.0, inside_clip.1), Some(child), "the visible part of the child remains pickable" ); let outside_clip = r.world_of(child).apply((30.0, 10.0)); assert_eq!( - pick(&doc, &r, outside_clip.0, outside_clip.1), + pick(&r, outside_clip.0, outside_clip.1), Some(doc.root), "the child is not pickable through its rotated ancestor clip" ); diff --git a/model-v2/a/lab/tests/generations.rs b/model-v2/a/lab/tests/generations.rs index 3ba6b95cc1..20d423394c 100644 --- a/model-v2/a/lab/tests/generations.rs +++ b/model-v2/a/lab/tests/generations.rs @@ -31,10 +31,61 @@ fn live_nodes_are_generation_zero() { #[test] fn tombstone_bumps_generation() { let (mut doc, c) = one_child(); + let key = doc.key_of(c).unwrap(); + assert!(doc.contains_key(key)); assert_eq!(doc.gen_of(c), 0); let removed = doc.remove_subtree(c); assert_eq!(removed, 1); // The vacated slot's generation advanced; the root was untouched. assert_eq!(doc.gen_of(c), 1); assert_eq!(doc.gen_of(doc.root), 0); + assert!(!doc.contains_key(key)); + assert!(doc.node_for_key(key).is_none()); + assert!(doc.key_of(c).is_none()); +} + +#[test] +fn reused_slot_mints_a_new_generation_without_reviving_the_old_key() { + let (mut doc, child) = one_child(); + let old = doc.key_of(child).unwrap(); + doc.remove_subtree(child); + let replacement = Node { + id: child, + header: Header::new(SizeIntent::Fixed(2.0), SizeIntent::Fixed(3.0)), + payload: Payload::Shape { + desc: ShapeDesc::Ellipse, + }, + children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + fills: Paints::default(), + strokes: vec![], + }; + doc.add_child(doc.root, replacement); + + let current = doc.key_of(child).unwrap(); + assert_eq!(current.generation(), old.generation() + 1); + assert_ne!(current, old); + assert!(doc.contains_key(current)); + assert!(!doc.contains_key(old)); +} + +#[test] +fn keys_are_scoped_to_one_exact_document_arena() { + let (a, child_a) = one_child(); + let (b, child_b) = one_child(); + assert_eq!(child_a, child_b, "the arena slots deliberately coincide"); + let key_a = a.key_of(child_a).unwrap(); + assert!( + !b.contains_key(key_a), + "an identical fresh arena is not owner A" + ); + + let clone = a.clone(); + assert_eq!(clone, a, "arena identity is not semantic document state"); + assert!( + !clone.contains_key(key_a), + "a clone gets a new arena owner because it may diverge" + ); + assert_ne!(clone.key_of(child_a).unwrap().arena(), key_a.arena()); } diff --git a/model-v2/a/lab/tests/grida_xml_paints.rs b/model-v2/a/lab/tests/grida_xml_paints.rs index 6c5c7a1e14..8edb2ee5a6 100644 --- a/model-v2/a/lab/tests/grida_xml_paints.rs +++ b/model-v2/a/lab/tests/grida_xml_paints.rs @@ -318,9 +318,9 @@ fn bad_paint_values_are_rejected_without_coercion() { ("", "visible"), ("", "between 0 and 1"), ("", "at least two stops"), - ("", "from and to must differ"), - ("", "after lowering to model alignment"), - ("", "after lowering to model alignment"), + ("", "farther apart"), + ("", "farther apart"), + ("", "farther apart"), ("", "overflow after lowering"), ("", "nondecreasing"), ("", "between 0 and 1"), @@ -337,6 +337,42 @@ fn bad_paint_values_are_rejected_without_coercion() { } } +#[test] +fn gradient_renderability_boundary_is_enforced_during_source_ingestion() { + let source = |paint: &str| { + format!( + "{paint}" + ) + }; + let stops = ""; + + let singular = source(&format!( + "{stops}" + )); + let error = grida_xml::parse(&singular).unwrap_err(); + assert!( + error.to_string().contains("transform must be invertible"), + "{error}" + ); + + for distance in ["0.0000152587890625", "0.000030517578125"] { + let degenerate = source(&format!( + "{stops}" + )); + let error = grida_xml::parse(°enerate).unwrap_err(); + assert!( + error.to_string().contains("farther apart"), + "distance={distance}: {error}" + ); + } + + let renderable = source(&format!( + "{stops}" + )); + grida_xml::parse(&renderable) + .expect("a linear gradient above Skia's degeneracy threshold remains valid source"); +} + #[test] fn linear_gradient_uv_boundary_is_exact_or_explicitly_unrepresentable() { for (u, v) in [ @@ -427,7 +463,7 @@ fn writer_refuses_unspellable_gradient_model_state_without_repair() { gradient.xy2 = gradient.xy1; assert!(matches!( grida_xml::print(&coincident), - Err(PrintError::InvalidDocument(message)) if message.contains("from and to must differ") + Err(PrintError::InvalidDocument(message)) if message.contains("farther apart") )); } diff --git a/model-v2/a/lab/tests/grida_xml_source_v2.rs b/model-v2/a/lab/tests/grida_xml_source_v2.rs index 685e6e5e9d..b15a0ead9d 100644 --- a/model-v2/a/lab/tests/grida_xml_source_v2.rs +++ b/model-v2/a/lab/tests/grida_xml_source_v2.rs @@ -695,9 +695,7 @@ fn required_props_do_not_acquire_invented_values_during_source_validation() { grida_xml_source::materialize(snapshot("entry", "memory:/", &incompatible), &mut provider) .unwrap_err(); assert_eq!(error.phase, ErrorPhase::Specialize); - assert!(error - .message - .contains("nonzero corner-smoothing requires circular corner radii")); + assert!(error.message.contains("requires circular corner radii")); assert_eq!(error.specialization_sites.len(), 1); assert_eq!(error.specialization_sites[0].prop, "smoothing"); } diff --git a/model-v2/a/lab/tests/grida_xml_source_v4.rs b/model-v2/a/lab/tests/grida_xml_source_v4.rs new file mode 100644 index 0000000000..64dc2b740d --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_source_v4.rs @@ -0,0 +1,382 @@ +//! Version 4 durable authored-member and component-occurrence addresses. + +use anchor_lab::grida_xml_source::{ + self, AddressLookupError, AuthoredMemberId, AuthoredOwner, SourceProvider, SourceSnapshot, + SourceVersion, +}; +use std::collections::BTreeMap; + +fn snapshot(identity: &str, source: &str) -> SourceSnapshot { + SourceSnapshot::new(identity, "memory:/", source) +} + +#[derive(Default)] +struct MemoryProvider { + sources: BTreeMap<(String, String), SourceSnapshot>, +} + +impl MemoryProvider { + fn insert(&mut self, from: &str, location: &str, source: SourceSnapshot) { + self.sources + .insert((from.to_owned(), location.to_owned()), source); + } +} + +impl SourceProvider for MemoryProvider { + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result { + self.sources + .get(&(containing.identity().to_owned(), location.to_owned())) + .cloned() + .ok_or_else(|| "not found".into()) + } +} + +#[test] +fn two_component_uses_give_one_member_distinct_occurrence_addresses() { + let source = r##" + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = grida_xml_source::materialize(snapshot("entry", source), &mut provider).unwrap(); + assert_eq!( + output.program.unit("entry").unwrap().version(), + SourceVersion::Version4 + ); + assert_eq!( + output.addresses().len(), + output.document.len() - 1, + "only the implicit document root is not authored" + ); + + let mut bodies = output + .addresses() + .filter(|(address, _)| address.member.id == AuthoredMemberId::Id("badge-body".into())) + .map(|(address, node)| (address.clone(), node)) + .collect::>(); + bodies.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(bodies.len(), 2); + assert_ne!(bodies[0].0, bodies[1].0); + assert_ne!(bodies[0].1.id(), bodies[1].1.id()); + assert_eq!( + bodies + .iter() + .map(|(address, _)| address.use_path[0].id.as_str()) + .collect::>(), + ["first-badge", "second-badge"] + ); + for (address, node) in bodies { + assert_eq!( + address.member.owner, + AuthoredOwner::Component(grida_xml_source::ComponentIdentity { + source: "entry".into(), + id: "badge".into(), + }) + ); + assert_eq!(output.node_for_address(&address).unwrap(), node); + assert_eq!(output.address_for_node(node).unwrap(), &address); + } +} + +#[test] +fn authored_root_id_is_distinct_from_the_structural_component_root() { + let source = r##" + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = grida_xml_source::materialize(snapshot("entry", source), &mut provider).unwrap(); + let owner = AuthoredOwner::Component(grida_xml_source::ComponentIdentity { + source: "entry".into(), + id: "card".into(), + }); + let structural_root = output + .addresses() + .find(|(address, _)| { + address.member.owner == owner && address.member.id == AuthoredMemberId::ComponentRoot + }) + .unwrap(); + let authored_root = output + .addresses() + .find(|(address, _)| { + address.member.owner == owner + && address.member.id == AuthoredMemberId::Id("root".into()) + }) + .unwrap(); + + assert_ne!(structural_root.0, authored_root.0); + assert_ne!(structural_root.1, authored_root.1); + assert_eq!(structural_root.0.use_path, authored_root.0.use_path); + assert_eq!( + authored_root + .0 + .use_path + .iter() + .map(|occurrence| occurrence.id.as_str()) + .collect::>(), + ["card-use"] + ); +} + +#[test] +fn nested_uses_and_slot_assignments_keep_path_order_and_authored_owner() { + let source = r##" + + + + + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = grida_xml_source::materialize(snapshot("entry", source), &mut provider).unwrap(); + + let caller_rect = output + .addresses() + .find(|(address, _)| address.member.id == AuthoredMemberId::Id("caller-rect".into())) + .unwrap(); + assert_eq!( + caller_rect.0.member.owner, + AuthoredOwner::Scene { + source: "entry".into() + } + ); + assert_eq!( + caller_rect + .0 + .use_path + .iter() + .map(|occurrence| occurrence.id.as_str()) + .collect::>(), + ["shell-use"] + ); + + let leaf_mark = output + .addresses() + .find(|(address, _)| address.member.id == AuthoredMemberId::Id("leaf-mark".into())) + .unwrap(); + assert_eq!( + leaf_mark.0.member.owner, + AuthoredOwner::Component(grida_xml_source::ComponentIdentity { + source: "entry".into(), + id: "leaf".into(), + }) + ); + assert_eq!( + leaf_mark + .0 + .use_path + .iter() + .map(|occurrence| occurrence.id.as_str()) + .collect::>(), + ["shell-use", "nested-leaf-use"] + ); +} + +#[test] +fn version4_ids_are_required_well_formed_and_unique_per_lexical_owner() { + let cases = [ + ( + r##""##, + "requires a durable", + true, + ), + ( + r##""##, + "requires a durable", + true, + ), + ( + r##""##, + "lowercase kebab-case", + true, + ), + ( + r##""##, + "duplicate Version 4 member/use id `same`", + true, + ), + ( + r##""##, + "unknown attribute `id`", + false, + ), + ]; + for (source, expected, local_id_error) in cases { + let error = grida_xml_source::parse_source(snapshot("bad", source)).unwrap_err(); + assert!(error.message.contains(expected), "{error}"); + if local_id_error { + assert!( + error.span.is_some(), + "Version 4 id errors retain a local span" + ); + } + } + + let owner_local = r##" + + + + +"##; + grida_xml_source::parse_source(snapshot("ok", owner_local)).unwrap(); +} + +#[test] +fn versions_zero_through_three_keep_their_exact_id_posture() { + for version in ["1", "2", "3"] { + let source = format!( + r##""## + ); + grida_xml_source::parse_source(snapshot("old", &source)).unwrap(); + } + + let old_with_id = r##""##; + let error = grida_xml_source::parse_source(snapshot("old", old_with_id)).unwrap_err(); + assert!(error.message.contains("unknown attribute `id`"), "{error}"); +} + +#[test] +fn version4_rejects_legacy_component_sources_before_materialization() { + for target_version in ["1", "2", "3"] { + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./library", + snapshot( + &format!("library-v{target_version}"), + &format!( + r##""## + ), + ), + ); + let entry = r##" + + + +"##; + let error = + grida_xml_source::materialize(snapshot("entry", entry), &mut provider).unwrap_err(); + assert_eq!(error.phase, grida_xml_source::ErrorPhase::Link); + assert!( + error.message.contains(&format!( + "Version 4 source cannot link Version {target_version} component source" + )), + "{error}" + ); + } +} + +#[test] +fn version4_to_version4_slot_assignment_has_a_complete_address_closure() { + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./library", + snapshot( + "library-v4", + r##" + + + + + +"##, + ), + ); + let entry = r##" + + + + + + + +"##; + let output = grida_xml_source::materialize(snapshot("entry", entry), &mut provider).unwrap(); + assert_eq!( + output.program.unit("library-v4").unwrap().version(), + SourceVersion::Version4 + ); + assert_eq!(output.addresses().len() + 1, output.document.len()); + for (address, node) in output.addresses() { + assert_eq!(output.node_for_address(address).unwrap(), node); + assert_eq!(output.address_for_node(node).unwrap(), address); + } + + let assigned = output + .addresses() + .find(|(address, _)| address.member.id == AuthoredMemberId::Id("assigned-rect".into())) + .unwrap(); + assert_eq!( + assigned + .0 + .use_path + .iter() + .map(|occurrence| occurrence.id.as_str()) + .collect::>(), + ["host-use"] + ); +} + +#[test] +fn older_nodes_are_unaddressed_and_removed_version4_nodes_fail_closed() { + let old = r##""##; + let mut provider = MemoryProvider::default(); + let old = grida_xml_source::materialize(snapshot("old", old), &mut provider).unwrap(); + assert_eq!(old.addresses().len(), 0); + let old_root = old.document.get(old.document.root).children[0]; + let old_key = old.document.key_of(old_root).unwrap(); + assert!(matches!( + old.address_for_node(old_key), + Err(AddressLookupError::NodeHasNoDurableAddress { .. }) + )); + + let source = r##""##; + let mut output = grida_xml_source::materialize(snapshot("new", source), &mut provider).unwrap(); + let (address, key) = output + .addresses() + .find(|(address, _)| address.member.id == AuthoredMemberId::Id("member".into())) + .map(|(address, key)| (address.clone(), key)) + .unwrap(); + let live_before = output.addresses().len(); + output.document.remove_subtree(key.id()); + assert_eq!(output.addresses().len(), live_before - 1); + assert!(output + .addresses() + .all(|(live_address, live_node)| live_address != &address && live_node != key)); + assert!(matches!( + output.node_for_address(&address), + Err(AddressLookupError::StaleAddress { .. }) + )); + assert!(matches!( + output.address_for_node(key), + Err(AddressLookupError::UnknownNode { .. }) + )); +} diff --git a/model-v2/a/lab/tests/properties.rs b/model-v2/a/lab/tests/properties.rs new file mode 100644 index 0000000000..3a35738369 --- /dev/null +++ b/model-v2/a/lab/tests/properties.rs @@ -0,0 +1,989 @@ +//! Closed typed-property registry and immutable ValueView contract. + +use anchor_lab::grida_xml; +use anchor_lab::model::*; +use anchor_lab::pick::pick; +use anchor_lab::properties::*; +use anchor_lab::resolve::{resolve, resolve_view, ResolveOptions}; +use std::collections::BTreeSet; + +fn target(document: &Document, node: NodeId, property: PropertyKey) -> PropertyTarget { + PropertyTarget::new(document.key_of(node).unwrap(), property) +} + +fn flex_scene() -> (Document, NodeId, NodeId) { + let source = r##" + + + + + + +"##; + let document = grida_xml::parse(source).unwrap(); + let scene = document.get(document.root).children[0]; + let first = document.get(scene).children[0]; + let second = document.get(scene).children[1]; + (document, first, second) +} + +fn per_side_stroke(payload: &Payload) -> Stroke { + let mut stroke = Stroke::default_for(payload).unwrap(); + stroke.width = StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 1.0, + stroke_right_width: 2.0, + stroke_bottom_width: 3.0, + stroke_left_width: 4.0, + }); + stroke +} + +fn stop(offset: f32) -> GradientStop { + GradientStop { + offset, + color: Color::BLACK, + } +} + +#[test] +fn registry_is_closed_unique_and_every_base_accessor_matches_its_value_kind() { + assert_eq!(property_registry().len(), PropertyKey::ALL.len()); + assert_eq!( + property_registry() + .iter() + .map(|spec| spec.key) + .collect::>() + .len(), + PropertyKey::ALL.len() + ); + + let document = grida_xml::parse( + r#" + + + + + + + text + + + + +"#, + ) + .unwrap(); + let frame = document.get(document.root).children[0]; + let children = &document.get(frame).children; + assert_eq!(children.len(), 7); + + const FRAME_INAPPLICABLE: &[PropertyKey] = &[PropertyKey::AspectRatio]; + const NON_FRAME: &[PropertyKey] = &[PropertyKey::Layout, PropertyKey::ClipsContent]; + const NON_ROUNDED: &[PropertyKey] = &[ + PropertyKey::Layout, + PropertyKey::ClipsContent, + PropertyKey::CornerRadius, + PropertyKey::CornerSmoothing, + ]; + const LINE_INAPPLICABLE: &[PropertyKey] = &[ + PropertyKey::Height, + PropertyKey::MinHeight, + PropertyKey::MaxHeight, + PropertyKey::AspectRatio, + PropertyKey::Layout, + PropertyKey::ClipsContent, + PropertyKey::CornerRadius, + PropertyKey::CornerSmoothing, + PropertyKey::Fills, + ]; + const TEXT_INAPPLICABLE: &[PropertyKey] = &[ + PropertyKey::AspectRatio, + PropertyKey::Layout, + PropertyKey::ClipsContent, + PropertyKey::CornerRadius, + PropertyKey::CornerSmoothing, + ]; + const DERIVED_INAPPLICABLE: &[PropertyKey] = &[ + PropertyKey::Width, + PropertyKey::Height, + PropertyKey::MinWidth, + PropertyKey::MaxWidth, + PropertyKey::MinHeight, + PropertyKey::MaxHeight, + PropertyKey::AspectRatio, + PropertyKey::Layout, + PropertyKey::ClipsContent, + PropertyKey::CornerRadius, + PropertyKey::CornerSmoothing, + PropertyKey::Fills, + PropertyKey::Strokes, + ]; + let cases: [(&str, NodeId, &[PropertyKey]); 8] = [ + ("frame", frame, FRAME_INAPPLICABLE), + ("rect", children[0], NON_FRAME), + ("ellipse", children[1], NON_ROUNDED), + ("line", children[2], LINE_INAPPLICABLE), + ("path", children[3], NON_ROUNDED), + ("text", children[4], TEXT_INAPPLICABLE), + ("group", children[5], DERIVED_INAPPLICABLE), + ("lens", children[6], DERIVED_INAPPLICABLE), + ]; + + for (kind, id, inapplicable) in cases { + let node = document.get(id); + for spec in property_registry() { + let expected = !inapplicable.contains(&spec.key); + assert_eq!( + spec.applies_to(node), + expected, + "{kind} {:?} applicability", + spec.key + ); + match spec.base_value(node) { + Some(value) => { + assert!(expected); + assert_eq!(value.kind(), spec.value_kind, "{kind} {:?}", spec.key); + } + None => assert!(!expected, "{kind} {:?} base value", spec.key), + } + } + } + + assert!(!PropertyKey::Fills + .spec() + .applies_to(document.get(children[2]))); + assert!(PropertyKey::Strokes + .spec() + .applies_to(document.get(children[2]))); +} + +#[test] +fn property_values_reject_duplicates_stale_wrong_inapplicable_and_invalid_entries() { + let (mut document, first, _) = flex_scene(); + let width = target(&document, first, PropertyKey::Width); + let duplicate = PropertyValues::new( + &document, + [ + (width, PropertyValue::SizeIntent(SizeIntent::Fixed(30.0))), + (width, PropertyValue::SizeIntent(SizeIntent::Fixed(40.0))), + ], + ) + .unwrap_err(); + assert!(matches!(duplicate, PropertyError::DuplicateTarget { .. })); + + let wrong = PropertyValues::new(&document, [(width, PropertyValue::Number(30.0))]).unwrap_err(); + assert!(matches!(wrong, PropertyError::WrongValueKind { .. })); + + let group = { + let mut builder = DocBuilder::new(); + let group = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); + (builder.build(), group) + }; + let inapplicable = PropertyValues::new( + &group.0, + [( + target(&group.0, group.1, PropertyKey::Width), + PropertyValue::SizeIntent(SizeIntent::Fixed(1.0)), + )], + ) + .unwrap_err(); + assert!(matches!(inapplicable, PropertyError::Inapplicable { .. })); + + let invalid = PropertyValues::new( + &document, + [( + target(&document, first, PropertyKey::Opacity), + PropertyValue::Number(f32::NAN), + )], + ) + .unwrap_err(); + assert!(matches!(invalid, PropertyError::InvalidValue { .. })); + + let other = document.clone(); + let cross_document = PropertyValues::new( + &other, + [(width, PropertyValue::SizeIntent(SizeIntent::Fixed(30.0)))], + ) + .unwrap_err(); + assert!(matches!(cross_document, PropertyError::StaleTarget { .. })); + + let retained = PropertyValues::new( + &document, + [(width, PropertyValue::SizeIntent(SizeIntent::Fixed(30.0)))], + ) + .unwrap(); + document.remove_subtree(first); + let stale = ValueView::new(&document, &retained).unwrap_err(); + assert!(matches!(stale, PropertyError::StaleTarget { .. })); +} + +#[test] +fn paint_and_stroke_values_are_validated_recursively_without_subobject_targets() { + let (document, first, _) = flex_scene(); + let mut gradient = LinearGradientPaint::default(); + gradient.opacity = f32::INFINITY; + let paint_error = PropertyValues::new( + &document, + [( + target(&document, first, PropertyKey::Fills), + PropertyValue::Paints(Paints::new([Paint::LinearGradient(gradient)])), + )], + ) + .unwrap_err(); + assert!(matches!(paint_error, PropertyError::InvalidValue { .. })); + + let mut stroke = Stroke::default_for(&document.get(first).payload).unwrap(); + stroke.width = StrokeWidth::Uniform(-1.0); + let stroke_error = PropertyValues::new( + &document, + [( + target(&document, first, PropertyKey::Strokes), + PropertyValue::Strokes(vec![stroke]), + )], + ) + .unwrap_err(); + assert!(matches!(stroke_error, PropertyError::InvalidValue { .. })); +} + +#[test] +fn effective_gradients_reject_singular_transforms_for_every_gradient_kind() { + let (document, rect, _) = flex_scene(); + let stops = vec![stop(0.0), stop(1.0)]; + let singular = anchor_lab::math::Affine { + a: 1.0, + b: 2.0, + c: 2.0, + d: 4.0, + e: 8.0, + f: 9.0, + }; + let paints = [ + Paint::LinearGradient(LinearGradientPaint { + transform: singular, + stops: stops.clone(), + ..Default::default() + }), + Paint::RadialGradient(RadialGradientPaint { + transform: singular, + stops: stops.clone(), + ..Default::default() + }), + Paint::SweepGradient(SweepGradientPaint { + transform: singular, + stops: stops.clone(), + ..Default::default() + }), + Paint::DiamondGradient(DiamondGradientPaint { + transform: singular, + stops, + ..Default::default() + }), + ]; + + for paint in paints { + let error = PropertyValues::new( + &document, + [( + target(&document, rect, PropertyKey::Fills), + PropertyValue::Paints(Paints::new([paint])), + )], + ) + .unwrap_err(); + assert!( + matches!(&error, PropertyError::InvalidValue { reason, .. } if reason.contains("transform must be invertible")), + "{error}" + ); + } +} + +#[test] +fn effective_linear_gradient_endpoints_follow_skias_f32_degeneracy_boundary() { + let (document, rect, _) = flex_scene(); + let key = target(&document, rect, PropertyKey::Fills); + let threshold = anchor_lab::renderability::LINEAR_GRADIENT_DEGENERATE_THRESHOLD; + let gradient = |distance: f32| { + Paint::LinearGradient(LinearGradientPaint { + xy1: Alignment::from_uv(0.0, 0.5), + xy2: Alignment::from_uv(distance, 0.5), + stops: vec![stop(0.0), stop(1.0)], + ..Default::default() + }) + }; + + for distance in [0.0, threshold / 2.0, threshold] { + let error = PropertyValues::new( + &document, + [( + key, + PropertyValue::Paints(Paints::new([gradient(distance)])), + )], + ) + .unwrap_err(); + assert!( + matches!(&error, PropertyError::InvalidValue { reason, .. } if reason.contains("farther apart")), + "distance={distance}: {error}" + ); + } + + PropertyValues::new( + &document, + [( + key, + PropertyValue::Paints(Paints::new([gradient(threshold * 2.0)])), + )], + ) + .expect("a linear ramp above Skia's degeneracy threshold remains renderable"); +} + +#[test] +fn line_locked_height_and_gradient_stop_cardinality_are_rejected() { + let document = + grida_xml::parse(r#""#) + .unwrap(); + let scene = document.get(document.root).children[0]; + let line = document.get(scene).children[0]; + let height = PropertyValues::new( + &document, + [( + target(&document, line, PropertyKey::Height), + PropertyValue::SizeIntent(SizeIntent::Fixed(1.0)), + )], + ) + .unwrap_err(); + assert!(matches!(height, PropertyError::Inapplicable { .. })); + + let (document, rect, _) = flex_scene(); + for stops in [vec![], vec![stop(0.0)]] { + let gradient = LinearGradientPaint { + stops, + ..Default::default() + }; + let error = PropertyValues::new( + &document, + [( + target(&document, rect, PropertyKey::Fills), + PropertyValue::Paints(Paints::new([Paint::LinearGradient(gradient)])), + )], + ) + .unwrap_err(); + assert!( + matches!(&error, PropertyError::InvalidValue { reason, .. } if reason.contains("at least two stops")), + "{error}" + ); + } +} + +#[test] +fn base_and_empty_effective_views_both_reject_inapplicable_reads() { + let document = + grida_xml::parse(r#""#) + .unwrap(); + let scene = document.get(document.root).children[0]; + let line = document.get(scene).children[0]; + let empty = PropertyValues::default(); + let base = ValueView::base(&document); + let effective = ValueView::new(&document, &empty).unwrap(); + + assert!(std::panic::catch_unwind(|| base.height(line)).is_err()); + assert!(std::panic::catch_unwind(|| effective.height(line)).is_err()); +} + +#[test] +fn unrelated_effective_values_do_not_make_flex_read_inapplicable_geometry() { + let mut builder = DocBuilder::new(); + let frame = builder.add( + 0, + Header::new(SizeIntent::Fixed(200.0), SizeIntent::Fixed(80.0)), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Row, + ..Default::default() + }, + clips_content: false, + }, + ); + let line = builder.add( + frame, + Header::new(SizeIntent::Fixed(20.0), SizeIntent::Auto), + Payload::Shape { + desc: ShapeDesc::Line, + }, + ); + let group = builder.add( + frame, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); + builder.add( + group, + Header::new(SizeIntent::Fixed(12.0), SizeIntent::Fixed(10.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let lens = builder.add( + frame, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { ops: vec![] }, + ); + builder.add( + lens, + Header::new(SizeIntent::Fixed(8.0), SizeIntent::Fixed(6.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let document = builder.build(); + + for node in [line, group, lens] { + let values = PropertyValues::new( + &document, + [( + target(&document, node, PropertyKey::Opacity), + PropertyValue::Number(0.5), + )], + ) + .unwrap(); + let view = ValueView::new(&document, &values).unwrap(); + let resolved = resolve_view(&view, &ResolveOptions::default()); + assert!(resolved.box_opt(node).is_some()); + } +} + +#[test] +fn effective_geometry_tuples_preserve_static_span_line_and_shape_rules() { + let (document, rect, _) = flex_scene(); + let span_with_fixed = PropertyValues::new( + &document, + [( + target(&document, rect, PropertyKey::X), + PropertyValue::AxisBinding(AxisBinding::Span { + start: 0.0, + end: 0.0, + }), + )], + ) + .unwrap_err(); + assert!( + matches!(&span_with_fixed, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("span x")), + "{span_with_fixed}" + ); + + let constrained = grida_xml::parse( + r#""#, + ) + .unwrap(); + let scene = constrained.get(constrained.root).children[0]; + let rect = constrained.get(scene).children[0]; + let span_with_min = PropertyValues::new( + &constrained, + [( + target(&constrained, rect, PropertyKey::MinWidth), + PropertyValue::OptionalNumber(Some(1.0)), + )], + ) + .unwrap_err(); + assert!(matches!( + span_with_min, + PropertyError::InvalidEffectiveState { .. } + )); + + let line_document = + grida_xml::parse(r#""#) + .unwrap(); + let scene = line_document.get(line_document.root).children[0]; + let line = line_document.get(scene).children[0]; + let line_y = PropertyValues::new( + &line_document, + [( + target(&line_document, line, PropertyKey::Y), + PropertyValue::AxisBinding(AxisBinding::Span { + start: 0.0, + end: 0.0, + }), + )], + ) + .unwrap_err(); + assert!( + matches!(&line_y, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("y Span")), + "{line_y}" + ); + let mut bad_line_document = line_document.clone(); + bad_line_document.get_mut(line).header.height = SizeIntent::Fixed(1.0); + let bad_height = PropertyValues::new( + &bad_line_document, + [( + target(&bad_line_document, line, PropertyKey::Width), + PropertyValue::SizeIntent(SizeIntent::Fixed(12.0)), + )], + ) + .unwrap_err(); + assert!( + matches!(&bad_height, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("locked to zero")), + "{bad_height}" + ); + + let mut builder = DocBuilder::new(); + let group = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Group, + ); + let lens = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Lens { ops: vec![] }, + ); + let derived = builder.build(); + for node in [group, lens] { + let error = PropertyValues::new( + &derived, + [( + target(&derived, node, PropertyKey::X), + PropertyValue::AxisBinding(AxisBinding::Span { + start: 0.0, + end: 0.0, + }), + )], + ) + .unwrap_err(); + assert!( + matches!(&error, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("derived origin")), + "{error}" + ); + } + + let shapes = grida_xml::parse( + r#""#, + ) + .unwrap(); + let scene = shapes.get(shapes.root).children[0]; + for &shape in &shapes.get(scene).children { + let error = PropertyValues::new( + &shapes, + [( + target(&shapes, shape, PropertyKey::Width), + PropertyValue::SizeIntent(SizeIntent::Auto), + )], + ) + .unwrap_err(); + assert!( + matches!(&error, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("require")), + "{error}" + ); + } +} + +#[test] +fn effective_corner_and_stroke_pairs_are_validated_as_one_state() { + let (mut smoothing_document, rect, _) = flex_scene(); + smoothing_document.get_mut(rect).corner_smoothing = CornerSmoothing(0.5); + let stroke = per_side_stroke(&smoothing_document.get(rect).payload); + let stroke_error = PropertyValues::new( + &smoothing_document, + [( + target(&smoothing_document, rect, PropertyKey::Strokes), + PropertyValue::Strokes(vec![stroke.clone()]), + )], + ) + .unwrap_err(); + assert!( + matches!(&stroke_error, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("corner-smoothing")), + "{stroke_error}" + ); + + let (mut stroke_document, rect, _) = flex_scene(); + stroke_document.get_mut(rect).strokes = vec![stroke.clone()]; + let smoothing_error = PropertyValues::new( + &stroke_document, + [( + target(&stroke_document, rect, PropertyKey::CornerSmoothing), + PropertyValue::Number(0.5), + )], + ) + .unwrap_err(); + assert!( + matches!(&smoothing_error, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("corner-smoothing")), + "{smoothing_error}" + ); + + let (mut elliptical_document, rect, _) = flex_scene(); + elliptical_document.get_mut(rect).corner_radius = + RectangularCornerRadius::all(Radius { rx: 8.0, ry: 4.0 }); + let elliptical_error = PropertyValues::new( + &elliptical_document, + [( + target(&elliptical_document, rect, PropertyKey::CornerSmoothing), + PropertyValue::Number(0.5), + )], + ) + .unwrap_err(); + assert!( + matches!(&elliptical_error, PropertyError::InvalidEffectiveState { reason, .. } if reason.contains("circular corner radii")), + "{elliptical_error}" + ); + + let (mut radius_document, rect, _) = flex_scene(); + radius_document.get_mut(rect).corner_smoothing = CornerSmoothing(0.5); + let radius_error = PropertyValues::new( + &radius_document, + [( + target(&radius_document, rect, PropertyKey::CornerRadius), + PropertyValue::CornerRadius(RectangularCornerRadius::all(Radius { rx: 8.0, ry: 4.0 })), + )], + ) + .unwrap_err(); + assert!(matches!( + radius_error, + PropertyError::InvalidEffectiveState { .. } + )); + + let coordinated = PropertyValues::new( + &smoothing_document, + [ + ( + target(&smoothing_document, rect, PropertyKey::CornerSmoothing), + PropertyValue::Number(0.0), + ), + ( + target(&smoothing_document, rect, PropertyKey::Strokes), + PropertyValue::Strokes(vec![stroke]), + ), + ], + ) + .unwrap(); + ValueView::new(&smoothing_document, &coordinated).unwrap(); +} + +#[test] +fn value_view_revalidates_aggregate_state_after_authored_changes() { + let (mut document, rect, _) = flex_scene(); + let values = PropertyValues::new( + &document, + [( + target(&document, rect, PropertyKey::Strokes), + PropertyValue::Strokes(vec![per_side_stroke(&document.get(rect).payload)]), + )], + ) + .unwrap(); + document.get_mut(rect).corner_smoothing = CornerSmoothing(0.5); + assert!(matches!( + ValueView::new(&document, &values), + Err(PropertyError::InvalidEffectiveState { .. }) + )); +} + +#[test] +fn effective_image_paints_match_the_proving_renderer_capability_fence() { + let (document, rect, _) = flex_scene(); + let key = target(&document, rect, PropertyKey::Fills); + let valid = ImagePaint::from_rid("asset"); + PropertyValues::new( + &document, + [( + key, + PropertyValue::Paints(Paints::new([Paint::Image(valid.clone())])), + )], + ) + .unwrap(); + + let mut unsupported = Vec::new(); + let mut quarter_turn = valid.clone(); + quarter_turn.quarter_turns = 1; + unsupported.push(quarter_turn); + let mut alignment = valid.clone(); + alignment.alignment = Alignment(-1.0, -1.0); + unsupported.push(alignment); + let mut transform = valid.clone(); + transform.fit = ImagePaintFit::Transform(anchor_lab::math::Affine::IDENTITY); + unsupported.push(transform); + let mut tile = valid.clone(); + tile.fit = ImagePaintFit::Tile(ImageTile { + scale: 1.0, + repeat: ImageRepeat::Repeat, + }); + unsupported.push(tile); + let mut filtered = valid; + filtered.filters.exposure = 1.0; + unsupported.push(filtered); + + for image in unsupported { + let error = PropertyValues::new( + &document, + [( + key, + PropertyValue::Paints(Paints::new([Paint::Image(image)])), + )], + ) + .unwrap_err(); + assert!(matches!(error, PropertyError::InvalidValue { .. })); + } +} + +#[test] +fn absent_values_read_authored_base_while_typed_none_clears_optional_base() { + let (mut document, first, _) = flex_scene(); + document.get_mut(first).header.min_width = Some(12.0); + let key = target(&document, first, PropertyKey::MinWidth); + + let base = ValueView::base(&document); + assert_eq!(base.min_width(first), Some(12.0)); + + let values = + PropertyValues::new(&document, [(key, PropertyValue::OptionalNumber(None))]).unwrap(); + let projected = ValueView::new(&document, &values).unwrap(); + assert_eq!(projected.min_width(first), None); + assert_eq!(document.get(first).header.min_width, Some(12.0)); +} + +#[test] +fn empty_value_view_is_bit_exact_for_resolve_and_pick() { + let source = r##" + + + alpha beta + + + +"##; + let document = grida_xml::parse(source).unwrap(); + let values = PropertyValues::new(&document, []).unwrap(); + let view = ValueView::new(&document, &values).unwrap(); + let options = ResolveOptions::default(); + let base = resolve(&document, &options); + let projected = resolve_view(&view, &options); + assert_eq!(projected, base); + assert_eq!(pick(&projected, 1.0, 1.0), pick(&base, 1.0, 1.0)); +} + +#[test] +fn valid_geometry_transform_and_visibility_values_change_only_the_view() { + let (document, first, second) = flex_scene(); + let authored = document.clone(); + let options = ResolveOptions::default(); + let base = resolve(&document, &options); + + let width_values = PropertyValues::new( + &document, + [( + target(&document, first, PropertyKey::Width), + PropertyValue::SizeIntent(SizeIntent::Fixed(50.0)), + )], + ) + .unwrap(); + let width_view = ValueView::new(&document, &width_values).unwrap(); + let width_resolved = resolve_view(&width_view, &options); + assert_eq!(base.box_of(second).x, 20.0); + assert_eq!(width_resolved.box_of(second).x, 50.0); + + let rotation_values = PropertyValues::new( + &document, + [( + target(&document, first, PropertyKey::Rotation), + PropertyValue::Number(45.0), + )], + ) + .unwrap(); + let rotation_view = ValueView::new(&document, &rotation_values).unwrap(); + let rotation_resolved = resolve_view(&rotation_view, &options); + assert_ne!(rotation_resolved.world_of(first), base.world_of(first)); + assert_ne!(rotation_resolved.aabb_of(first), base.aabb_of(first)); + + let active_values = PropertyValues::new( + &document, + [( + target(&document, first, PropertyKey::Active), + PropertyValue::Boolean(false), + )], + ) + .unwrap(); + let active_view = ValueView::new(&document, &active_values).unwrap(); + let active_resolved = resolve_view(&active_view, &options); + assert!(active_resolved.world_opt(first).is_none()); + assert_eq!(active_resolved.box_of(second).x, 0.0); + + assert_eq!( + document, authored, + "value views never mutate authored state" + ); +} + +#[test] +fn effective_cross_size_controls_container_stretch_without_mutating_authored_state() { + let mut builder = DocBuilder::new(); + let container = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(100.0)), + Payload::Frame { + layout: LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Column, + cross_align: CrossAlign::Stretch, + ..Default::default() + }, + clips_content: false, + }, + ); + let authored_auto = builder.add( + container, + Header::new(SizeIntent::Auto, SizeIntent::Fixed(20.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let authored_fixed = builder.add( + container, + Header::new(SizeIntent::Fixed(30.0), SizeIntent::Fixed(20.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let document = builder.build(); + let authored = document.clone(); + let values = PropertyValues::new( + &document, + [ + ( + target(&document, authored_auto, PropertyKey::Width), + PropertyValue::SizeIntent(SizeIntent::Fixed(40.0)), + ), + ( + target(&document, authored_fixed, PropertyKey::Width), + PropertyValue::SizeIntent(SizeIntent::Auto), + ), + ], + ) + .unwrap(); + let view = ValueView::new(&document, &values).unwrap(); + let resolved = resolve_view(&view, &ResolveOptions::default()); + + assert_eq!( + resolved.box_of(authored_auto).w, + 40.0, + "an effective fixed cross size prevents container stretch" + ); + assert_eq!( + resolved.box_of(authored_fixed).w, + 100.0, + "an effective Auto cross size permits container stretch" + ); + assert_eq!( + document, authored, + "resolution leaves authored state intact" + ); +} + +#[test] +fn rounded_and_smoothed_effective_clips_control_descendant_queries() { + let document = grida_xml::parse( + r#" + + + + + + + +"#, + ) + .unwrap(); + let scene = document.get(document.root).children[0]; + let clip = document.get(scene).children[0]; + let child = document.get(clip).children[0]; + let options = ResolveOptions::default(); + + let base = resolve(&document, &options); + assert_eq!(pick(&base, 11.0, 11.0), Some(child)); + + let rounded_values = PropertyValues::new( + &document, + [( + target(&document, clip, PropertyKey::CornerRadius), + PropertyValue::CornerRadius(RectangularCornerRadius::circular(30.0)), + )], + ) + .unwrap(); + let rounded_view = ValueView::new(&document, &rounded_values).unwrap(); + let rounded = resolve_view(&rounded_view, &options); + assert_eq!( + pick(&rounded, 11.0, 11.0), + Some(clip), + "the rounded corner excludes the child but not the frame's own box" + ); + assert_eq!(pick(&rounded, 50.0, 11.0), Some(child)); + assert_eq!(pick(&rounded, 50.0, 50.0), Some(child)); + + let ordinary_values = PropertyValues::new( + &document, + [( + target(&document, clip, PropertyKey::CornerRadius), + PropertyValue::CornerRadius(RectangularCornerRadius::circular(24.0)), + )], + ) + .unwrap(); + let ordinary_view = ValueView::new(&document, &ordinary_values).unwrap(); + let ordinary = resolve_view(&ordinary_view, &options); + assert_eq!(pick(&ordinary, 28.0, 11.0), Some(child)); + + let smooth_values = PropertyValues::new( + &document, + [ + ( + target(&document, clip, PropertyKey::CornerRadius), + PropertyValue::CornerRadius(RectangularCornerRadius::circular(24.0)), + ), + ( + target(&document, clip, PropertyKey::CornerSmoothing), + PropertyValue::Number(0.8), + ), + ], + ) + .unwrap(); + let smooth_view = ValueView::new(&document, &smooth_values).unwrap(); + let smooth = resolve_view(&smooth_view, &options); + assert_eq!( + pick(&smooth, 28.0, 11.0), + Some(clip), + "effective smoothing uses the same extended corner path as raster clipping" + ); +} + +#[test] +fn impacts_cover_the_declared_downstream_stages() { + assert!(PropertyKey::Width + .spec() + .impact + .contains(PropertyImpact::LAYOUT)); + for key in [ + PropertyKey::Rotation, + PropertyKey::FlipX, + PropertyKey::FlipY, + ] { + assert!( + key.spec() + .impact + .contains(PropertyImpact::MEASURE | PropertyImpact::LAYOUT), + "{key:?} participates in AABB-in-flow measurement and layout" + ); + assert!(key + .spec() + .impact + .contains(PropertyImpact::TRANSFORM | PropertyImpact::BOUNDS | PropertyImpact::PAINT)); + } + assert!(PropertyKey::Strokes + .spec() + .impact + .contains(PropertyImpact::BOUNDS | PropertyImpact::PAINT)); + assert!(PropertyKey::Fills + .spec() + .impact + .contains(PropertyImpact::RESOURCE)); +} diff --git a/model-v2/a/spike-canvas/Cargo.lock b/model-v2/a/spike-canvas/Cargo.lock index 820cf0fed8..c7731339df 100644 --- a/model-v2/a/spike-canvas/Cargo.lock +++ b/model-v2/a/spike-canvas/Cargo.lock @@ -68,8 +68,10 @@ dependencies = [ name = "anchor-lab" version = "0.0.0" dependencies = [ + "kurbo", "quick-xml 0.37.5", "serde", + "svgtypes", "taffy", ] @@ -1879,6 +1881,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "skia-bindings" version = "0.93.1" @@ -1991,6 +1999,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.118" diff --git a/model-v2/a/spike-canvas/src/main.rs b/model-v2/a/spike-canvas/src/main.rs index 00c3833307..061085b0ec 100644 --- a/model-v2/a/spike-canvas/src/main.rs +++ b/model-v2/a/spike-canvas/src/main.rs @@ -12,7 +12,7 @@ mod paint; mod scene; mod shell; -use anchor_engine::drawlist::DrawList; +use anchor_engine::frame::{FrameBuildError, FrameProduct}; use anchor_engine::paint::PaintCtx; use anchor_lab::model::{Document, NodeId}; use anchor_lab::ops::Op; @@ -37,7 +37,10 @@ pub fn resolve_doc(doc: &Document) -> Resolved { /// fonts in the resulting display list. Rendering paths use this boundary; /// [`resolve_doc`] remains the deterministic glyphless helper for recorded /// scripts and benchmarks. -pub fn resolve_and_build_doc(doc: &Document, ctx: &PaintCtx) -> (Resolved, DrawList) { +pub fn resolve_and_build_doc( + doc: &Document, + ctx: &PaintCtx, +) -> Result { anchor_engine::frame::resolve_and_build( doc, &ResolveOptions { @@ -146,8 +149,8 @@ fn record(path: &str, state: &str) { /// Headless render: paint the starter scene (after an optional scripted /// state) into a raster surface and encode PNG — no window, no GL. The SCENE -/// is painted by the engine pipeline (`frame::resolve_and_build` -> -/// `paint::execute`); +/// is painted by the engine pipeline (`frame::resolve_and_build` -> checked +/// `FrameProduct::execute`); /// the HUD is spike chrome on top. These shots are the gate's goldens. fn shot(path: &str, state: &str) { let (mut doc, artboard) = scene::starter(); @@ -163,15 +166,18 @@ fn shot(path: &str, state: &str) { let (w, h) = (1360, 900); let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); let ctx = paint::paint_ctx(); - let (resolved, list) = resolve_and_build_doc(&doc, &ctx); + let product = resolve_and_build_doc(&doc, &ctx).expect("spike scene must pass paint preflight"); + let resolved = product.resolved(); let mut cam = Camera::new(); let ab = resolved.aabb_of(artboard); cam.fit((ab.x, ab.y, ab.w, ab.h), (w as f32, h as f32), 48.0); let canvas = surface.canvas(); canvas.clear(skia_safe::Color::from_argb(255, 0xF7, 0xF8, 0xF9)); - anchor_engine::paint::execute(canvas, &list, &cam.view(), &ctx); - shell::hud::paint_hud(canvas, &doc, &resolved, &cam, selection, None, &ctx); + product + .execute(canvas, &cam.view(), &ctx) + .expect("shot uses the frame's unchanged paint environment"); + shell::hud::paint_hud(canvas, &doc, resolved, &cam, selection, None, &ctx); let image = surface.image_snapshot(); let data = image @@ -212,11 +218,11 @@ fn bench() { // This benchmark intentionally preserves the deterministic, // glyphless lab-stage baseline. Live and shot rendering use the // shaped `resolve_and_build_doc` path above. - let list = anchor_engine::drawlist::build_glyphless(doc, &resolved); + let list = anchor_engine::drawlist::build_glyphless_unchecked(doc, &resolved); let t2 = Instant::now(); let canvas = surface.canvas(); canvas.clear(skia_safe::Color::WHITE); - anchor_engine::paint::execute(canvas, &list, &view, &ctx); + anchor_engine::paint::execute_unchecked(canvas, &list, &view, &ctx); let t3 = Instant::now(); let ms = |a: Instant, b: Instant| (b - a).as_secs_f64() * 1000.0; resolve_ms = resolve_ms.min(ms(t0, t1)); diff --git a/model-v2/a/spike-canvas/src/shell/app.rs b/model-v2/a/spike-canvas/src/shell/app.rs index 1f50095078..77041f3a0a 100644 --- a/model-v2/a/spike-canvas/src/shell/app.rs +++ b/model-v2/a/spike-canvas/src/shell/app.rs @@ -5,6 +5,7 @@ use std::num::NonZeroU32; +use anchor_engine::frame::FrameProduct; use anchor_engine::journal::Journal; use anchor_engine::query::Query; use anchor_lab::model::{Document, NodeId}; @@ -69,10 +70,10 @@ pub struct App { want_framelog: bool, frame_log: Option>, frame_clock: std::time::Instant, - /// Previous frame's resolved tier and the count of nodes that changed - /// against it (ENG-2.2 damage) — shown in the panel; not yet used to - /// scope repaint (OS-2a). - last_resolved: Option, + /// Previous complete frame product and declared paint environment. + /// ENG-2.2 damage is shown in the panel but not yet used to scope repaint + /// (OS-2a). + last_frame: Option, pub last_damage: usize, // panels (egui overlay on the shared GL context) @@ -144,7 +145,7 @@ pub fn run(init: WindowInit) { want_framelog: std::env::var("ANCHOR_FRAMELOG").is_ok(), frame_log: None, frame_clock: std::time::Instant::now(), - last_resolved: None, + last_frame: None, last_damage: 0, egui_ctx, egui_winit, @@ -169,7 +170,10 @@ impl App { /// The returned drawlist is intentionally discarded when a pointer or /// editor query needs only the resolved tier. fn resolve_live(&self) -> Resolved { - resolve_and_build_doc(&self.doc, &self.ctx).0 + let (resolved, _, _) = resolve_and_build_doc(&self.doc, &self.ctx) + .expect("spike scene must pass paint preflight") + .into_parts(); + resolved } fn fit_artboard(&mut self) { @@ -273,7 +277,7 @@ impl App { } let (wx, wy) = self.camera.screen_to_world(self.cursor); - match Query::new(&self.doc, &resolved).hit_point(wx, wy) { + match Query::new(&resolved).hit_point(wx, wy) { Some(hit) if hit != self.doc.root => { self.selection = Some(hit); self.fsm = Fsm::Pressed { @@ -473,7 +477,7 @@ impl App { // Idle / Pressed: hover tracking only. let resolved = self.resolve_live(); let (wx, wy) = self.camera.screen_to_world(self.cursor); - self.hover = Query::new(&self.doc, &resolved) + self.hover = Query::new(&resolved) .hit_point(wx, wy) .filter(|h| *h != self.doc.root); } @@ -664,21 +668,27 @@ impl App { // stage sum does not. If gap >> draw, the problem is not rendering. let gap = t0.duration_since(self.frame_clock).as_secs_f32() * 1000.0; self.frame_clock = t0; - let (resolved, list) = resolve_and_build_doc(&self.doc, &self.ctx); - self.last_damage = match &self.last_resolved { - Some(prev) => anchor_engine::damage::diff(prev, &resolved).changed.len(), + let product = resolve_and_build_doc(&self.doc, &self.ctx) + .expect("spike scene must pass paint preflight"); + self.last_damage = match &self.last_frame { + Some(previous) => anchor_engine::damage::diff_frame(previous, &product) + .changed + .len(), None => 0, }; + let resolved = product.resolved(); let t_resolve_build = ms(t0); let t1 = std::time::Instant::now(); let canvas = self.gpu.surface.canvas(); canvas.clear(skia_safe::Color::from_argb(255, 0xF7, 0xF8, 0xF9)); - anchor_engine::paint::execute(canvas, &list, &self.camera.view(), &self.ctx); + product + .execute(canvas, &self.camera.view(), &self.ctx) + .expect("live frame uses the unchanged host paint environment"); hud::paint_hud_dpr( canvas, &self.doc, - &resolved, + resolved, &self.camera, self.selection, self.hover, @@ -755,7 +765,7 @@ impl App { let _ = w.flush(); } - self.last_resolved = Some(resolved); + self.last_frame = Some(product); } // ── panels (egui) ─────────────────────────────────────────────── diff --git a/model-v2/engine/rig/fixtures/durable-addressing.grida.xml b/model-v2/engine/rig/fixtures/durable-addressing.grida.xml new file mode 100644 index 0000000000..3fc4d0a35a --- /dev/null +++ b/model-v2/engine/rig/fixtures/durable-addressing.grida.xml @@ -0,0 +1,35 @@ + + + + Ada River + + + + + + Follow + + + Message + + + diff --git a/model-v2/engine/src/bin/gate.rs b/model-v2/engine/src/bin/gate.rs index 864e671f92..a06924d296 100644 --- a/model-v2/engine/src/bin/gate.rs +++ b/model-v2/engine/src/bin/gate.rs @@ -143,7 +143,7 @@ fn gate_replays() -> bool { /// Every render optimization ships a row here proving `optimized(input) == /// reference(input)` — a fast-but-wrong cache aborts the build before anyone /// reads a speedup. Today it holds ZERO optimization rows (each lands with its -/// win); it instead pins the L2 reference oracle itself — `raster_to_bytes` +/// win); it instead pins the L2 reference oracle itself — checked frame raster /// must be deterministic (ENG-0.3), so every future pixel row can trust it. fn gate_diff() -> bool { println!("\n[diff] oracle-law differential (ENG-0.2)"); @@ -169,9 +169,14 @@ fn gate_diff() -> bool { } }; let (doc, _) = replay::play(&rep).expect("corpus oracle must be supported"); - let (_, list) = frame::resolve_and_build(&doc, &opts, &ctx); - let a = paint::raster_to_bytes(&list, &view, w, h, &ctx); - let b = paint::raster_to_bytes(&list, &view, w, h, &ctx); + let product = frame::resolve_and_build(&doc, &opts, &ctx) + .expect("gate corpus frame must pass paint preflight"); + let a = product + .raster_to_bytes(&view, w, h, &ctx) + .expect("gate context is unchanged"); + let b = product + .raster_to_bytes(&view, w, h, &ctx) + .expect("gate context is unchanged"); let same = a == b; println!( " {name:10} reference {}", @@ -192,11 +197,16 @@ fn gate_diff() -> bool { ..view }; let mut cache = SceneCache::new(w, h); - let _ = composited_to_bytes(&mut cache, &doc, &opts, &view, &ctx, false, w, h); // cold - let blit = composited_to_bytes(&mut cache, &doc, &opts, &panned, &ctx, false, w, h); + let _ = composited_to_bytes(&mut cache, &doc, &opts, &view, &ctx, false, w, h) + .expect("gate cache-cold frame must pass paint preflight"); + let blit = composited_to_bytes(&mut cache, &doc, &opts, &panned, &ctx, false, w, h) + .expect("gate cached frame must pass paint preflight"); let fresh = { - let (_, list) = frame::resolve_and_build(&doc, &opts, &ctx); - paint::raster_to_bytes(&list, &panned, w, h, &ctx) + let product = frame::resolve_and_build(&doc, &opts, &ctx) + .expect("gate fresh frame must pass paint preflight"); + product + .raster_to_bytes(&panned, w, h, &ctx) + .expect("gate context is unchanged") }; let cache_ok = blit == fresh; println!( @@ -316,7 +326,7 @@ fn bench_doc(doc: &Document, opts: &ResolveOptions) -> (f64, f64) { let t1 = Instant::now(); // The replay benchmark deliberately measures the deterministic lab // oracle. Shaped rendering enters through `frame::resolve_and_build`. - let _ = drawlist::build_glyphless(doc, &resolved); + let _ = drawlist::build_glyphless_unchecked(doc, &resolved); let t2 = Instant::now(); r_min = r_min.min((t1 - t0).as_secs_f64() * 1e6); b_min = b_min.min((t2 - t1).as_secs_f64() * 1e6); diff --git a/model-v2/engine/src/bin/grida_xml_render.rs b/model-v2/engine/src/bin/grida_xml_render.rs index 2f898ace26..b1a879ecea 100644 --- a/model-v2/engine/src/bin/grida_xml_render.rs +++ b/model-v2/engine/src/bin/grida_xml_render.rs @@ -301,14 +301,15 @@ fn run() -> Result<(), String> { .ok_or_else(|| "no platform-default typeface is available".to_string())?; let mut paint_ctx = PaintCtx::new(Some(font)); load_image_resources(&program, &mut paint_ctx)?; - let (resolved, _, _) = frame::render( + let (product, _) = frame::render( surface.canvas(), doc, &options, &Affine::IDENTITY, &paint_ctx, - ); - ensure_resolved_without_errors(&program, &resolved)?; + ) + .map_err(|error| format!("frame construction failed: {error}"))?; + ensure_resolved_without_errors(&program, product.resolved())?; let image = surface.image_snapshot(); let png = image @@ -609,7 +610,8 @@ mod tests { }, &Affine::IDENTITY, &ctx, - ); + ) + .expect("valid multi-source image frame"); let pixels = read_pixels(&mut surface, 50, 20); let region = |x: usize| { (0..20) diff --git a/model-v2/engine/src/bin/probe.rs b/model-v2/engine/src/bin/probe.rs index b93cef9a19..9128165000 100644 --- a/model-v2/engine/src/bin/probe.rs +++ b/model-v2/engine/src/bin/probe.rs @@ -1,6 +1,6 @@ //! probe — the autonomous render-perf harness (deterministic CPU raster). //! -//! Drives the real `frame::resolve_and_build -> paint::execute` seam across +//! Drives the real `frame::resolve_and_build -> FrameProduct::execute` seam across //! three axes — VIEW (pan/zoom, no doc mutation), MUTATION (one node/frame), //! ANIMATION (N nodes/frame) — at several scene sizes, reporting per-STAGE //! distributions (min/p50/p95/p99/max/mean) with fps at mean AND p99. 120fps = @@ -241,7 +241,8 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { let mut surface = skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).unwrap(); // Reference resolve/drawlist for the VIEW-redundancy audit (A4). - let (ref_resolved, ref_list) = resolve_and_build(&doc, &opts(), ctx); + let reference = + resolve_and_build(&doc, &opts(), ctx).expect("benchmark scene must pass paint preflight"); // Rotation is a paint-only transform under DEC-0 (grounded in the // classifier, not the scenario name). if sc.name == "mutate_rotate" { @@ -260,7 +261,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { } let target = ids[0]; - let mut prev = ref_resolved.clone(); + let mut previous = reference.clone(); let mut view = base; let mut pan = 0.0f32; let mut pan_dir = 1.0f32; @@ -297,7 +298,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { let dx = if frame % 40 < 20 { 3.0 } else { -3.0 }; let _ = ops::apply( &mut doc, - &prev, + previous.resolved(), &Op::MoveBy { id: target, dx, @@ -307,12 +308,16 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { } "mutate_rotate" => { let deg = (frame % 30) as f32; // 0..30 sweep, paint-only - let _ = ops::apply(&mut doc, &prev, &Op::SetRotation { id: target, deg }); + let _ = ops::apply( + &mut doc, + previous.resolved(), + &Op::SetRotation { id: target, deg }, + ); } "mutate_color" => { - // NOTE: fill is not an Op (no set_fill in the lab). This - // deliberately exercises the missing paint-damage channel: - // damage::diff sees NO geometry change though the pixels do. + // Fill is not an Op (no set_fill in the lab). This direct + // mutation deliberately exercises complete frame damage: + // geometry stays fixed while drawlist and pixels change. let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; doc.get_mut(target).fills = Paints::solid(format!("#{hex}").into()); } @@ -323,7 +328,8 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { if sc.name.starts_with("anim_transform") { let deg = (frame % 30) as f32; for &id in &ids[..k] { - let _ = ops::apply(&mut doc, &prev, &Op::SetRotation { id, deg }); + let _ = + ops::apply(&mut doc, previous.resolved(), &Op::SetRotation { id, deg }); } } else { let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; @@ -338,14 +344,15 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { let canvas = surface.canvas(); canvas.clear(skia_safe::Color::WHITE); let t = Instant::now(); - let (resolved, list, stats) = render(canvas, &doc, &opts(), &view, ctx); + let (product, stats) = render(canvas, &doc, &opts(), &view, ctx) + .expect("benchmark scene must pass paint preflight"); let full = t.elapsed().as_nanos(); // black_box the pure outputs so LLVM can't hoist resolve/build. - black_box(list.items.len()); - black_box(resolved.resolved_count()); + black_box(product.drawlist().items.len()); + black_box(product.resolved().resolved_count()); - let dmg = damage::diff(&prev, &resolved); + let dmg = damage::diff_frame(&previous, &product); if frame >= WARMUP { r_ns.push(stats.resolve_ns); @@ -357,7 +364,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { if sc.axis == Axis::View { // A4: on a VIEW frame the doc is unchanged → resolve+build // are provably redundant. This is the whole premise. - if damage::diff(&ref_resolved, &resolved).is_empty() && list == ref_list { + if damage::diff_frame(&reference, &product).is_empty() { redundant_view_frames += 1; } // A2: the camera must change frame-to-frame (no no-op frame @@ -368,7 +375,7 @@ fn run(sc: &Scenario, n: usize, frames: usize, ctx: &PaintCtx) -> Result { } } - prev = resolved; + previous = product; } // ── trust guards (panic, not fake numbers) ── @@ -443,7 +450,9 @@ fn run_cached(name: &'static str, n: usize, frames: usize, ctx: &PaintCtx) -> (D let canvas = surface.canvas(); canvas.clear(skia_safe::Color::WHITE); let t = Instant::now(); - let did = cache.frame(canvas, &doc, &opts(), &view, ctx, false); + let did = cache + .frame(canvas, &doc, &opts(), &view, ctx, false) + .expect("benchmark scene must pass paint preflight"); let elapsed = t.elapsed().as_nanos(); black_box(did); if frame >= WARMUP { diff --git a/model-v2/engine/src/bin/probe_gpu.rs b/model-v2/engine/src/bin/probe_gpu.rs index b23d68f262..ef5540b185 100644 --- a/model-v2/engine/src/bin/probe_gpu.rs +++ b/model-v2/engine/src/bin/probe_gpu.rs @@ -328,7 +328,9 @@ mod imp { { let canvas = gpu.surface.canvas(); canvas.clear(skia_safe::Color::WHITE); - cache.frame(canvas, &doc, &opts(), &view, &ctx, false); + cache + .frame(canvas, &doc, &opts(), &view, &ctx, false) + .expect("GPU probe scene must pass paint preflight"); } gpu.gr_context.flush_and_submit(); let elapsed = t.elapsed().as_nanos(); diff --git a/model-v2/engine/src/cache.rs b/model-v2/engine/src/cache.rs index b0dcf21fdf..e8cac6185d 100644 --- a/model-v2/engine/src/cache.rs +++ b/model-v2/engine/src/cache.rs @@ -4,11 +4,12 @@ //! GPU via `Canvas::new_surface`) covering the viewport plus a margin, then //! re-composites it under camera PANS with a single blit — turning the //! O(nodes) `execute` wall into an O(1) image draw. It re-rasters only on a -//! document, resolve-option, or resource-environment change; a ZOOM change (a -//! bitmap can't be crisply rescaled — that is the re-raster boundary, ENG-2 -//! growth); or a pan beyond the cached margin. The cached drawlist is reused -//! across clean camera re-rasters, so a camera-only frame never re-resolves or -//! re-builds either (the retained-drawlist win folded in). +//! runtime-document incarnation, effective-value, resolve-option, or +//! paint-environment change; a ZOOM change (a bitmap can't be crisply rescaled +//! — that is the re-raster boundary, ENG-2 growth); or a pan beyond the cached +//! margin. The cached drawlist is reused across clean camera re-rasters, so a +//! camera-only frame never re-resolves or re-builds either (the +//! retained-drawlist win folded in). //! //! Correctness (gate_diff L2): for an INTEGER-pixel pan the composite is //! byte-identical to a fresh render — a shape translated by a whole pixel @@ -17,18 +18,59 @@ //! gate proves the integer case, which is the contract. use anchor_lab::math::Affine; -use anchor_lab::model::Document; +use anchor_lab::model::{Document, NodeKey}; +use anchor_lab::properties::{PropertyError, PropertyValues, ValueView}; use anchor_lab::resolve::{ResolveOptions, RotationInFlow}; use skia_safe::{Canvas, Color, FilterMode, Image, ImageInfo, MipmapMode, SamplingOptions}; use crate::drawlist::DrawList; -use crate::frame::resolve_and_build; -use crate::paint::{execute, PaintCtx}; +use crate::frame::{resolve_and_build_view, FrameBuildError, FrameExecutionError}; +use crate::paint::{execute_unchecked, PaintCtx, PaintEnvironmentKey}; /// Extra content rastered around the viewport, so small pans blit without a /// re-raster. Larger margin = fewer re-raster hitches, more offscreen memory. const MARGIN: f32 = 256.0; +/// Failure before a value-aware cached frame can reach the destination +/// canvas. Property validation precedes resolution; frame construction then +/// preflights the exact drawlist and resolved paint boxes. +#[derive(Debug, Clone, PartialEq)] +pub enum SceneCacheError { + Property(PropertyError), + FrameBuild(FrameBuildError), + FrameExecution(FrameExecutionError), +} + +impl std::fmt::Display for SceneCacheError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SceneCacheError::Property(error) => error.fmt(f), + SceneCacheError::FrameBuild(error) => error.fmt(f), + SceneCacheError::FrameExecution(error) => error.fmt(f), + } + } +} + +impl std::error::Error for SceneCacheError {} + +impl From for SceneCacheError { + fn from(error: PropertyError) -> Self { + SceneCacheError::Property(error) + } +} + +impl From for SceneCacheError { + fn from(error: FrameBuildError) -> Self { + SceneCacheError::FrameBuild(error) + } +} + +impl From for SceneCacheError { + fn from(error: FrameExecutionError) -> Self { + SceneCacheError::FrameExecution(error) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ResolveOptionsKey { viewport_width: u32, @@ -59,10 +101,17 @@ pub struct SceneCache { /// Resource environment under which the drawlist was resolved and rastered. /// The drawlist retains exact text fonts, but a different or revised host /// context requests a new semantic resolution rather than stale reuse. - ctx_id: Option<(u64, u64)>, + environment_key: Option, /// Layout options are part of resolved-scene identity even when the camera /// and document are unchanged. opts_key: Option, + /// Arena-scoped scene identity. Exact empty values are shared by all + /// static documents, so values alone cannot detect document replacement. + scene_key: Option, + /// Exact immutable effective values used to build `list`. Comparing the + /// data itself keeps correctness independent from a caller-supplied dirty + /// hint; the empty set is the static path's canonical cache key. + values_key: PropertyValues, } impl SceneCache { @@ -73,8 +122,10 @@ impl SceneCache { ref_view: Affine::IDENTITY, vw, vh, - ctx_id: None, + environment_key: None, opts_key: None, + scene_key: None, + values_key: PropertyValues::default(), } } @@ -90,7 +141,51 @@ impl SceneCache { view: &Affine, ctx: &PaintCtx, doc_dirty: bool, - ) -> bool { + ) -> Result { + let values = PropertyValues::default(); + self.frame_view( + canvas, + &ValueView::base(doc), + &values, + opts, + view, + ctx, + doc_dirty, + ) + } + + /// Composite one frame with immutable effective property values. Invalid + /// or stale targets fail before cache comparison or raster work. A changed + /// value set rebuilds the retained drawlist even when `doc_dirty` is + /// false. + pub fn frame_with_values( + &mut self, + canvas: &Canvas, + doc: &Document, + values: &PropertyValues, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, + doc_dirty: bool, + ) -> Result { + let value_view = ValueView::new(doc, values)?; + self.frame_view(canvas, &value_view, values, opts, view, ctx, doc_dirty) + } + + fn frame_view( + &mut self, + canvas: &Canvas, + values: &ValueView<'_>, + values_key: &PropertyValues, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, + doc_dirty: bool, + ) -> Result { + let document = values.document(); + let scene_key = document + .key_of(document.root) + .expect("a render document has one live implicit root"); let dx = view.e - self.ref_view.e; let dy = view.f - self.ref_view.f; let same_zoom = view.a == self.ref_view.a @@ -99,8 +194,10 @@ impl SceneCache { && view.d == self.ref_view.d; let rebuild_list = doc_dirty || self.list.is_none() - || self.ctx_id != Some(ctx.identity()) - || self.opts_key != Some(opts.into()); + || self.environment_key != Some(ctx.environment_key()) + || self.opts_key != Some(opts.into()) + || self.scene_key != Some(scene_key) + || self.values_key != *values_key; let reraster = self.image.is_none() || rebuild_list || !same_zoom @@ -108,7 +205,7 @@ impl SceneCache { || dy.abs() > MARGIN; if reraster { - self.raster(canvas, doc, opts, view, ctx, rebuild_list); + self.raster(canvas, values, values_key, opts, view, ctx, rebuild_list)?; } // Blit the cached image at the (now possibly zero) integer pan offset. @@ -118,7 +215,7 @@ impl SceneCache { // one src pixel (byte-exact); it never silently blurs at non-integer. let sampling = SamplingOptions::new(FilterMode::Nearest, MipmapMode::None); canvas.draw_image_with_sampling_options(img, (-MARGIN + dx, -MARGIN + dy), sampling, None); - reraster + Ok(reraster) } /// (Re)render the scene into a fresh backend-matched offscreen image sized @@ -127,17 +224,34 @@ impl SceneCache { fn raster( &mut self, canvas: &Canvas, - doc: &Document, + values: &ValueView<'_>, + values_key: &PropertyValues, opts: &ResolveOptions, view: &Affine, ctx: &PaintCtx, rebuild_list: bool, - ) { - if rebuild_list { - let (_, list) = resolve_and_build(doc, opts, ctx); - self.list = Some(list); - } - let list = self.list.as_ref().unwrap(); + ) -> Result<(), SceneCacheError> { + let rebuilt = if rebuild_list { + let product = resolve_and_build_view(values, opts, ctx)?; + let (_, list, environment) = product.into_parts(); + Some((list, environment)) + } else { + None + }; + let environment_key = rebuilt + .as_ref() + .map(|(_, environment)| *environment) + .unwrap_or_else(|| ctx.environment_key()); + let list = rebuilt + .as_ref() + .map(|(list, _)| list) + .or(self.list.as_ref()) + .expect("a clean cache re-raster retains its drawlist"); + + let mut shifted = *view; + shifted.e += MARGIN; + shifted.f += MARGIN; + crate::paint::preflight_images(list, &shifted, ctx).map_err(FrameExecutionError::from)?; let m = MARGIN as i32; let info = ImageInfo::new_n32_premul((self.vw + 2 * m, self.vh + 2 * m), None); @@ -146,21 +260,36 @@ impl SceneCache { .expect("backend-matched offscreen surface"); let oc = off.canvas(); oc.clear(Color::WHITE); - let mut shifted = *view; - shifted.e += MARGIN; - shifted.f += MARGIN; - execute(oc, list, &shifted, ctx); + assert_eq!( + environment_key, + ctx.environment_key(), + "retained drawlist paint environment changed before cache replay" + ); + execute_unchecked(oc, list, &shifted, ctx); + + let image = off.image_snapshot(); - self.image = Some(off.image_snapshot()); + // Commit every cache field only after build, preflight, and offscreen + // replay have all succeeded. A fallible rebuild therefore leaves the + // prior retained frame usable and the destination canvas untouched. + if let Some((list, _)) = rebuilt { + self.list = Some(list); + } + self.image = Some(image); self.ref_view = *view; - self.ctx_id = Some(ctx.identity()); + self.environment_key = Some(environment_key); self.opts_key = Some(opts.into()); + let document = values.document(); + self.scene_key = document.key_of(document.root); + self.values_key = values_key.clone(); + Ok(()) } } /// Render one composited frame to a fresh raster surface and return its bytes — /// the optimized side of the gate_diff L2 row. Pairs with -/// [`crate::paint::raster_to_bytes`] (the reference). A fresh cache is passed so +/// [`crate::paint::raster_to_bytes_unchecked`] (the low-level reference). A +/// fresh cache is passed so /// the first frame is a cache-cold re-raster; call twice with panned views to /// exercise the blit path. pub fn composited_to_bytes( @@ -172,10 +301,29 @@ pub fn composited_to_bytes( doc_dirty: bool, w: i32, h: i32, -) -> Vec { +) -> Result, SceneCacheError> { + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + cache.frame(canvas, doc, opts, view, ctx, doc_dirty)?; + Ok(crate::paint::read_pixels(&mut surface, w, h)) +} + +/// Value-aware counterpart to [`composited_to_bytes`]. +pub fn composited_to_bytes_with_values( + cache: &mut SceneCache, + doc: &Document, + values: &PropertyValues, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, + doc_dirty: bool, + w: i32, + h: i32, +) -> Result, SceneCacheError> { let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); let canvas = surface.canvas(); canvas.clear(Color::WHITE); - cache.frame(canvas, doc, opts, view, ctx, doc_dirty); - crate::paint::read_pixels(&mut surface, w, h) + cache.frame_with_values(canvas, doc, values, opts, view, ctx, doc_dirty)?; + Ok(crate::paint::read_pixels(&mut surface, w, h)) } diff --git a/model-v2/engine/src/damage.rs b/model-v2/engine/src/damage.rs index ad1af7fcd9..c2fb389165 100644 --- a/model-v2/engine/src/damage.rs +++ b/model-v2/engine/src/damage.rs @@ -1,17 +1,20 @@ -//! ENG-2.2 · damage as data. `diff(prev, next) -> Damage` (step 11) -//! compares two resolved tiers column-by-column with exact f32 equality -//! — justified because the pipeline is order-deterministic (ENG-0.3), so -//! "unchanged" is bit-identity, not an epsilon guess. Damage flows -//! forward only (resolve diff -> drawlist diff -> screen rects); no stage -//! invents or widens it. Day 1 it is asserted and shown (HUD line), not -//! yet consumed for partial repaint (that is OS-2a). +//! ENG-2.2 · damage as data. [`diff_frame`] compares the actual resolved and +//! drawlist products with exact equality, so geometry, paint-only values, +//! opacity scopes, text/path artifacts, and painter ordering all flow into one +//! covering result. [`diff`] remains the geometry-only compatibility primitive. +//! Damage is asserted and shown, not yet consumed for partial repaint (OS-2a). use anchor_lab::math::RectF; use anchor_lab::model::NodeId; use anchor_lab::resolve::Resolved; +use std::collections::{BTreeMap, BTreeSet}; -/// What changed between two resolves: the touched nodes and the world-space -/// rect that bounds their before+after ink (covers appear/disappear). +use crate::drawlist::{DrawList, Item, ItemKind}; +use crate::frame::FrameProduct; + +/// What changed between two frame products: the touched nodes and the +/// world-space rect that bounds their before+after ink (covers +/// appear/disappear). #[derive(Debug, Clone, Default, PartialEq)] pub struct Damage { pub changed: Vec, @@ -32,22 +35,127 @@ impl Damage { /// is asserted and shown (HUD), not yet consumed for partial repaint (OS-2a). pub fn diff(prev: &Resolved, next: &Resolved) -> Damage { let n = prev.slot_count().max(next.slot_count()); - let mut changed = Vec::new(); - let mut union_world: Option = None; + let mut changed = BTreeSet::new(); for id in 0..n as NodeId { if slot_changed(prev, next, id) { - changed.push(id); - // Cover the node's ink in BOTH states (appear/disappear/move). - if let Some(r) = prev.aabb_opt(id) { - union_world = Some(union_rect(union_world, r)); - } - if let Some(r) = next.aabb_opt(id) { - union_world = Some(union_rect(union_world, r)); + changed.insert(id); + } + } + finish_damage(prev, next, changed) +} + +/// Diff complete frame products. Resolved columns cover geometry, text, and +/// paths; per-node drawlist groups cover paints, opacity scopes, clips, +/// strokes, and painter order. The product-owned paint environment additionally +/// covers resource readiness and replacing bytes behind one logical id. +pub fn diff_frame(prev: &FrameProduct, next: &FrameProduct) -> Damage { + let mut changed = frame_changed_nodes( + prev.resolved(), + prev.drawlist(), + next.resolved(), + next.drawlist(), + ); + if prev.environment() != next.environment() { + changed.extend(prev.drawlist().items.iter().map(|item| item.node)); + changed.extend(next.drawlist().items.iter().map(|item| item.node)); + } + finish_damage(prev.resolved(), next.resolved(), changed) +} + +fn frame_changed_nodes( + prev_resolved: &Resolved, + prev_list: &DrawList, + next_resolved: &Resolved, + next_list: &DrawList, +) -> BTreeSet { + let n = prev_resolved.slot_count().max(next_resolved.slot_count()); + let mut changed = BTreeSet::new(); + for id in 0..n as NodeId { + if slot_changed(prev_resolved, next_resolved, id) { + changed.insert(id); + } + } + + let prev_groups = group_items(prev_list); + let next_groups = group_items(next_list); + let mut appearance_changed = BTreeSet::new(); + for id in prev_groups.keys().chain(next_groups.keys()) { + if prev_groups.get(id) != next_groups.get(id) { + appearance_changed.insert(*id); + } + } + changed.extend(appearance_changed.iter().copied()); + + // Remove nodes whose own item group already changed, then compare the + // remaining `(node, ordinal-within-node)` permutation. Inserting an + // opacity scope for a parent therefore marks the parent without falsely + // marking every shifted descendant, while a genuine painter-order change + // still marks every item whose relative position moved. + let prev_positions = item_positions(prev_list, &appearance_changed); + let next_positions = item_positions(next_list, &appearance_changed); + for token in prev_positions.keys().chain(next_positions.keys()) { + if prev_positions.get(token) != next_positions.get(token) { + changed.insert(token.0); + } + } + + // The exact font registry is list-owned rather than repeated on each text + // item. A registry change therefore damages the text nodes that consume + // it even when their backend-independent item data is identical. + if !prev_list.same_text_fonts(next_list) { + for item in prev_list.items.iter().chain(&next_list.items) { + if matches!( + &item.kind, + ItemKind::TextFill { .. } | ItemKind::TextStroke { .. } + ) { + changed.insert(item.node); } } } + + changed +} + +fn group_items(list: &DrawList) -> BTreeMap> { + let mut groups = BTreeMap::>::new(); + for item in &list.items { + groups.entry(item.node).or_default().push(item); + } + groups +} + +fn item_positions( + list: &DrawList, + excluded: &BTreeSet, +) -> BTreeMap<(NodeId, usize), usize> { + let mut ordinals = BTreeMap::::new(); + let mut positions = BTreeMap::new(); + let mut position = 0; + for item in &list.items { + if excluded.contains(&item.node) { + continue; + } + let ordinal = ordinals.entry(item.node).or_default(); + positions.insert((item.node, *ordinal), position); + *ordinal += 1; + position += 1; + } + positions +} + +fn finish_damage(prev: &Resolved, next: &Resolved, changed: BTreeSet) -> Damage { + let mut union_world = None; + for &id in &changed { + // Cover the node's ink in BOTH states (appear/disappear/move). + if let Some(r) = prev.aabb_opt(id) { + union_world = Some(union_rect(union_world, r)); + } + if let Some(r) = next.aabb_opt(id) { + union_world = Some(union_rect(union_world, r)); + } + } Damage { - changed, + changed: changed.into_iter().collect(), union_world, } } diff --git a/model-v2/engine/src/drawlist.rs b/model-v2/engine/src/drawlist.rs index 226db471e2..b9a5c56e7c 100644 --- a/model-v2/engine/src/drawlist.rs +++ b/model-v2/engine/src/drawlist.rs @@ -1,6 +1,6 @@ //! ENG-2.1 · the display list — a pure, diffable projection of resolved //! geometry and authored paint intent. The engine frame boundary supplies the -//! exact fonts accompanying shaped text. [`build_glyphless`] exists only for +//! exact fonts accompanying shaped text. [`build_glyphless_unchecked`] exists only for //! deterministic lab and structural probes whose resolver deliberately emits //! no glyphs. Neither path performs I/O or raster work. The camera is not baked //! in, so one drawlist paints at any zoom. @@ -10,6 +10,7 @@ use anchor_lab::model::{ CornerSmoothing, Document, NodeId, Paints, Payload, RectangularCornerRadius, ShapeDesc, Stroke, }; use anchor_lab::path::ResolvedPathArtifact; +use anchor_lab::properties::ValueView; use anchor_lab::resolve::Resolved; use anchor_lab::text_layout::TextLayout; use std::sync::Arc; @@ -153,6 +154,10 @@ impl DrawList { .as_deref() .expect("glyph-bearing drawlist has no text font registry") } + + pub(crate) fn same_text_fonts(&self, other: &Self) -> bool { + self.text_fonts == other.text_fonts + } } /// Materialize only paints that can contribute pixels. The authored stack is @@ -206,10 +211,17 @@ fn materialize_text_paints(payload: &Payload, node_fills: &Paints) -> TextPaints /// Node opacity scopes fill, descendants, and strokes. A frame's clip scopes /// descendants only. Geometry is local-space, positioned by `world` — copied /// verbatim, never recomputed. The camera is applied later by the executor. -pub fn build_glyphless(doc: &Document, resolved: &Resolved) -> DrawList { +pub fn build_glyphless_unchecked(doc: &Document, resolved: &Resolved) -> DrawList { build_inner(doc, resolved, None) } +/// Project the exact authored-plus-effective-value view that produced +/// `resolved`. This is the value-aware structural probe counterpart to +/// [`crate::frame::resolve_and_build_view`]. +pub fn build_glyphless_view_unchecked(view: &ValueView<'_>, resolved: &Resolved) -> DrawList { + build_inner(view, resolved, None) +} + /// Project a resolved tier produced by the engine text oracle, retaining the /// exact registry that minted every [`anchor_lab::text_layout::TextFontKey`]. pub(crate) fn build_with_text_fonts( @@ -220,13 +232,115 @@ pub(crate) fn build_with_text_fonts( build_inner(doc, resolved, Some(text_fonts)) } -fn build_inner( - doc: &Document, +/// Effective-value counterpart to [`build_with_text_fonts`]. Both paths use +/// one monomorphized projection below; the authored path reads the document +/// directly instead of paying a dynamic registry lookup at every paint item. +pub(crate) fn build_with_text_fonts_view( + view: &ValueView<'_>, + resolved: &Resolved, + text_fonts: Arc, +) -> DrawList { + build_inner(view, resolved, Some(text_fonts)) +} + +/// The drawlist's complete authored/effective read surface. Keeping this +/// private makes the optimization non-semantic: there is one projection +/// algorithm and no second public paint contract. +trait DrawValues { + fn document(&self) -> &Document; + fn opacity(&self, id: NodeId) -> f32; + fn clips_content(&self, id: NodeId) -> bool; + fn corner_radius(&self, id: NodeId) -> RectangularCornerRadius; + fn corner_smoothing(&self, id: NodeId) -> CornerSmoothing; + fn fills(&self, id: NodeId) -> &Paints; + fn strokes(&self, id: NodeId) -> &[Stroke]; +} + +impl DrawValues for Document { + #[inline] + fn document(&self) -> &Document { + self + } + + #[inline] + fn opacity(&self, id: NodeId) -> f32 { + self.get(id).header.opacity + } + + #[inline] + fn clips_content(&self, id: NodeId) -> bool { + match self.get(id).payload { + Payload::Frame { clips_content, .. } => clips_content, + _ => unreachable!("drawlist requests clips-content only for frames"), + } + } + + #[inline] + fn corner_radius(&self, id: NodeId) -> RectangularCornerRadius { + self.get(id).corner_radius + } + + #[inline] + fn corner_smoothing(&self, id: NodeId) -> CornerSmoothing { + self.get(id).corner_smoothing + } + + #[inline] + fn fills(&self, id: NodeId) -> &Paints { + &self.get(id).fills + } + + #[inline] + fn strokes(&self, id: NodeId) -> &[Stroke] { + &self.get(id).strokes + } +} + +impl DrawValues for ValueView<'_> { + #[inline] + fn document(&self) -> &Document { + ValueView::document(self) + } + + #[inline] + fn opacity(&self, id: NodeId) -> f32 { + ValueView::opacity(self, id) + } + + #[inline] + fn clips_content(&self, id: NodeId) -> bool { + ValueView::clips_content(self, id) + } + + #[inline] + fn corner_radius(&self, id: NodeId) -> RectangularCornerRadius { + ValueView::corner_radius(self, id) + } + + #[inline] + fn corner_smoothing(&self, id: NodeId) -> CornerSmoothing { + ValueView::corner_smoothing(self, id) + } + + #[inline] + fn fills(&self, id: NodeId) -> &Paints { + ValueView::fills(self, id) + } + + #[inline] + fn strokes(&self, id: NodeId) -> &[Stroke] { + ValueView::strokes(self, id) + } +} + +fn build_inner( + values: &V, resolved: &Resolved, text_fonts: Option>, ) -> DrawList { + let doc = values.document(); let mut items = Vec::new(); - emit(doc, resolved, doc.root, &mut items); + emit(values, resolved, doc.root, &mut items); let has_glyphs = items.iter().any(|item| match &item.kind { ItemKind::TextFill { layout, .. } | ItemKind::TextStroke { layout, .. } => { !layout.glyph_runs.is_empty() @@ -243,10 +357,16 @@ fn build_inner( } } -fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) { +fn emit( + values: &V, + resolved: &Resolved, + id: NodeId, + items: &mut Vec, +) { let Some(world) = resolved.world_opt(id) else { return; // hidden subtree — pruned, children not visited }; + let doc = values.document(); let node = doc.get(id); let b = resolved.box_of(id); let text_layout = node @@ -256,18 +376,12 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) let text_paints = node .payload .as_text() - .map(|_| materialize_text_paints(&node.payload, &node.fills)); + .map(|_| materialize_text_paints(&node.payload, values.fills(id))); - let opacity_scope = node.header.opacity != 1.0; + let opacity = values.opacity(id); + let opacity_scope = opacity != 1.0; if opacity_scope { - push( - items, - id, - world, - ItemKind::BeginOpacity { - opacity: node.header.opacity, - }, - ); + push(items, id, world, ItemKind::BeginOpacity { opacity }); } // Root is the backdrop; derived kinds have no ink. Both still recurse and @@ -275,8 +389,10 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) if id != doc.root && !node.payload.box_is_derived() { match &node.payload { Payload::Frame { .. } => { - let paints = visible_paints(&node.fills); + let paints = visible_paints(values.fills(id)); if !paints.is_empty() { + let corner_radius = values.corner_radius(id); + let corner_smoothing = values.corner_smoothing(id); push( items, id, @@ -284,22 +400,28 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) ItemKind::RectFill { w: b.w, h: b.h, - corner_radius: node.corner_radius, - corner_smoothing: node.corner_smoothing, + corner_radius, + corner_smoothing, paints, }, ); } } + // Lines have no fill channel, and `Fills` is intentionally + // inapplicable to them in the closed registry. Do not perform a + // speculative fill read before selecting the shape variant. + Payload::Shape { + desc: ShapeDesc::Line, + } => {} Payload::Shape { desc } => { - let paints = visible_paints(&node.fills); + let paints = visible_paints(values.fills(id)); if !paints.is_empty() { let kind = match desc { ShapeDesc::Rect => Some(ItemKind::RectFill { w: b.w, h: b.h, - corner_radius: node.corner_radius, - corner_smoothing: node.corner_smoothing, + corner_radius: values.corner_radius(id), + corner_smoothing: values.corner_smoothing(id), paints, }), ShapeDesc::Ellipse => Some(ItemKind::OvalFill { @@ -317,8 +439,7 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) paints, }) } - // Lines have no fill channel and no implicit ink. - ShapeDesc::Line => None, + ShapeDesc::Line => unreachable!("line matched before fill lookup"), }; if let Some(kind) = kind { push(items, id, world, kind); @@ -351,14 +472,10 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) } } - let clip_scope = matches!( - node.payload, - Payload::Frame { - clips_content: true, - .. - } - ); + let clip_scope = matches!(node.payload, Payload::Frame { .. }) && values.clips_content(id); if clip_scope { + let corner_radius = values.corner_radius(id); + let corner_smoothing = values.corner_smoothing(id); push( items, id, @@ -366,14 +483,14 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) ItemKind::BeginClipRect { w: b.w, h: b.h, - corner_radius: node.corner_radius, - corner_smoothing: node.corner_smoothing, + corner_radius, + corner_smoothing, }, ); } for &c in &node.children { - emit(doc, resolved, c, items); + emit(values, resolved, c, items); } if clip_scope { @@ -381,10 +498,17 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) } if id != doc.root && !node.payload.box_is_derived() { - for stroke in node - .strokes + let corner_smoothing = match &node.payload { + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect, + } => values.corner_smoothing(id), + _ => CornerSmoothing::default(), + }; + for stroke in values + .strokes(id) .iter() - .filter_map(|stroke| visible_stroke(stroke, &node.payload, node.corner_smoothing)) + .filter_map(|stroke| visible_stroke(stroke, &node.payload, corner_smoothing)) { if matches!( &node.payload, @@ -402,8 +526,8 @@ fn emit(doc: &Document, resolved: &Resolved, id: NodeId, items: &mut Vec) } => ItemKind::RectStroke { w: b.w, h: b.h, - corner_radius: node.corner_radius, - corner_smoothing: node.corner_smoothing, + corner_radius: values.corner_radius(id), + corner_smoothing, stroke, }, Payload::Shape { diff --git a/model-v2/engine/src/frame.rs b/model-v2/engine/src/frame.rs index cc0b8f2761..400d70b28e 100644 --- a/model-v2/engine/src/frame.rs +++ b/model-v2/engine/src/frame.rs @@ -1,19 +1,210 @@ //! ENG-2.4 socket · the one frame entry point. `render(...)` (step 6) -//! runs `resolve -> build -> execute` and returns the resolved tier, the -//! drawlist, and timings — the host clears the canvas and paints its own -//! chrome around this, never the other way round (the compositor owns -//! pacing; the host adapts). Kept a single seam so the fragmented -//! tick/redraw rot the legacy `FrameLoop` unified never regrows. +//! runs `resolve -> build -> execute` and returns one immutable +//! [`FrameProduct`] plus timings — the host clears the canvas and paints its +//! own chrome around this, never the other way round (the compositor owns +//! pacing; the host adapts). Kept a single seam so the fragmented tick/redraw +//! rot the legacy `FrameLoop` unified never regrows. use anchor_lab::math::Affine; use anchor_lab::model::Document; -use anchor_lab::resolve::{resolve_with_text_layout, ResolveOptions, Resolved}; +use anchor_lab::properties::ValueView; +use anchor_lab::resolve::{ + resolve_view_with_text_layout, resolve_with_text_layout, ResolveOptions, Resolved, +}; use std::time::Instant; -use crate::drawlist::{build_with_text_fonts, DrawList}; -use crate::paint::{execute, PaintCtx}; +use crate::drawlist::{build_with_text_fonts, build_with_text_fonts_view, DrawList}; +use crate::paint::{raster_to_bytes_unchecked, PaintCtx, PaintEnvironmentKey}; +use crate::query::Query; use crate::text_layout::SkiaTextLayoutOracle; +/// A retained frame was asked to raster under a host resource environment +/// other than the one used to resolve and build it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PaintEnvironmentMismatch { + pub expected: PaintEnvironmentKey, + pub actual: PaintEnvironmentKey, +} + +impl std::fmt::Display for PaintEnvironmentMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "frame paint environment mismatch: expected {:?}, found {:?}", + self.expected, self.actual + ) + } +} + +impl std::error::Error for PaintEnvironmentMismatch {} + +/// Frame construction failed after resolution and exact drawlist projection, +/// before the complete product was minted or any canvas command was issued. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameBuildError { + Gradient(crate::paint::GradientPreflightError), +} + +impl std::fmt::Display for FrameBuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameBuildError::Gradient(error) => error.fmt(f), + } + } +} + +impl std::error::Error for FrameBuildError {} + +impl From for FrameBuildError { + fn from(error: crate::paint::GradientPreflightError) -> Self { + FrameBuildError::Gradient(error) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameExecutionError { + Environment(PaintEnvironmentMismatch), + Image(crate::paint::ImagePreflightError), +} + +impl std::fmt::Display for FrameExecutionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameExecutionError::Environment(error) => error.fmt(f), + FrameExecutionError::Image(error) => error.fmt(f), + } + } +} + +impl std::error::Error for FrameExecutionError {} + +impl From for FrameExecutionError { + fn from(error: PaintEnvironmentMismatch) -> Self { + FrameExecutionError::Environment(error) + } +} + +impl From for FrameExecutionError { + fn from(error: crate::paint::ImagePreflightError) -> Self { + FrameExecutionError::Image(error) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + Build(FrameBuildError), + Execution(FrameExecutionError), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::Build(error) => error.fmt(f), + FrameError::Execution(error) => error.fmt(f), + } + } +} + +impl std::error::Error for FrameError {} + +impl From for FrameError { + fn from(error: FrameBuildError) -> Self { + FrameError::Build(error) + } +} + +impl From for FrameError { + fn from(error: FrameExecutionError) -> Self { + FrameError::Execution(error) + } +} + +/// One immutable semantic frame result. +/// +/// The paint-environment snapshot belongs beside the resolved tier and +/// drawlist: decoded image bytes can change pixels without changing either +/// structural product. Keeping all three together makes complete damage +/// comparison impossible to call with a key captured from the wrong frame. +#[derive(Debug, Clone)] +pub struct FrameProduct { + resolved: Resolved, + drawlist: DrawList, + environment: PaintEnvironmentKey, +} + +impl FrameProduct { + pub fn resolved(&self) -> &Resolved { + &self.resolved + } + + pub fn drawlist(&self) -> &DrawList { + &self.drawlist + } + + pub fn environment(&self) -> PaintEnvironmentKey { + self.environment + } + + /// Spatial reads consume the traversal and effective clip state captured + /// in this product's resolved tier. No document or value view can be paired + /// with the wrong frame. + pub fn query(&self) -> Query<'_> { + Query::new(&self.resolved) + } + + fn check_environment(&self, ctx: &PaintCtx) -> Result<(), PaintEnvironmentMismatch> { + let actual = ctx.environment_key(); + if actual == self.environment { + Ok(()) + } else { + Err(PaintEnvironmentMismatch { + expected: self.environment, + actual, + }) + } + } + + /// Replay this product only under the exact paint environment captured + /// while it was built. Environment mismatch or a view-dependent image + /// sampling capability failure occurs before any draw command reaches the + /// canvas. + pub fn execute( + &self, + canvas: &skia_safe::Canvas, + view: &Affine, + ctx: &PaintCtx, + ) -> Result<(), FrameExecutionError> { + self.check_environment(ctx)?; + crate::paint::preflight_images(&self.drawlist, view, ctx)?; + crate::paint::execute_unchecked(canvas, &self.drawlist, view, ctx); + Ok(()) + } + + /// Checked raster-byte reference for a complete product. Execution + /// preflight completes before the temporary surface is allocated or + /// cleared. + pub fn raster_to_bytes( + &self, + view: &Affine, + w: i32, + h: i32, + ctx: &PaintCtx, + ) -> Result, FrameExecutionError> { + self.check_environment(ctx)?; + crate::paint::preflight_images(&self.drawlist, view, ctx)?; + Ok(raster_to_bytes_unchecked(&self.drawlist, view, w, h, ctx)) + } + + /// Explicitly dismantle a complete product when a host needs to retain + /// its independently owned parts. This forfeits checked execution: the + /// caller must keep the returned environment key coupled to the drawlist. + /// There is deliberately no public inverse constructor; only the frame + /// seam can mint a complete product. + pub fn into_parts(self) -> (Resolved, DrawList, PaintEnvironmentKey) { + (self.resolved, self.drawlist, self.environment) + } +} + /// Per-frame timings for the three pipeline seams (nanoseconds). Populated /// by the same spans [`crate::trace`] reads when the `trace` feature is on; /// always cheap enough to compute unconditionally here. @@ -29,42 +220,82 @@ pub struct FrameStats { /// /// This is the public non-rasterizing stage boundary. Callers that need stage /// retained lists or custom surfaces use it instead of combining the glyphless -/// compatibility resolver with [`crate::drawlist::build_glyphless`]. +/// compatibility resolver with [`crate::drawlist::build_glyphless_unchecked`]. pub fn resolve_and_build( doc: &Document, opts: &ResolveOptions, ctx: &PaintCtx, -) -> (Resolved, DrawList) { - let (resolved, list, _) = resolve_and_build_profiled(doc, opts, ctx); - (resolved, list) +) -> Result { + resolve_and_build_profiled(doc, opts, ctx).map(|(product, _)| product) +} + +/// Resolve and build from one validated authored-plus-effective-value view. +/// The same immutable view feeds both stages, so paint intent cannot drift +/// from the geometry that was resolved for it. +pub fn resolve_and_build_view( + view: &ValueView<'_>, + opts: &ResolveOptions, + ctx: &PaintCtx, +) -> Result { + resolve_and_build_view_profiled(view, opts, ctx).map(|(product, _)| product) } fn resolve_and_build_profiled( doc: &Document, opts: &ResolveOptions, ctx: &PaintCtx, -) -> (Resolved, DrawList, FrameStats) { +) -> Result<(FrameProduct, FrameStats), FrameBuildError> { let t0 = Instant::now(); let text_layout = SkiaTextLayoutOracle::new(ctx); let resolved = resolve_with_text_layout(doc, opts, &text_layout); let t1 = Instant::now(); let list = build_with_text_fonts(doc, &resolved, text_layout.font_registry()); + crate::paint::preflight_gradients(&list)?; + let t2 = Instant::now(); + Ok(( + FrameProduct { + resolved, + drawlist: list, + environment: ctx.environment_key(), + }, + FrameStats { + resolve_ns: (t1 - t0).as_nanos(), + build_ns: (t2 - t1).as_nanos(), + execute_ns: 0, + }, + )) +} + +fn resolve_and_build_view_profiled( + view: &ValueView<'_>, + opts: &ResolveOptions, + ctx: &PaintCtx, +) -> Result<(FrameProduct, FrameStats), FrameBuildError> { + let t0 = Instant::now(); + let text_layout = SkiaTextLayoutOracle::new(ctx); + let resolved = resolve_view_with_text_layout(view, opts, &text_layout); + let t1 = Instant::now(); + let list = build_with_text_fonts_view(view, &resolved, text_layout.font_registry()); + crate::paint::preflight_gradients(&list)?; let t2 = Instant::now(); - ( - resolved, - list, + Ok(( + FrameProduct { + resolved, + drawlist: list, + environment: ctx.environment_key(), + }, FrameStats { resolve_ns: (t1 - t0).as_nanos(), build_ns: (t2 - t1).as_nanos(), execute_ns: 0, }, - ) + )) } /// The one frame entry: `resolve -> build -> execute`, immediate, no caches /// (the spike's proven thesis). The host clears the canvas and paints its own -/// chrome around this; it never reaches into the stages. Returns the resolved -/// tier and drawlist (the host reuses them for HUD/pick/damage) plus timings. +/// chrome around this; it never reaches into the stages. Returns the complete +/// immutable product (the host reuses it for HUD/pick/damage) plus timings. /// The only skia this module names is the `Canvas` it hands to the executor — /// all raster work stays in [`crate::paint`] (S-1). pub fn render( @@ -73,10 +304,27 @@ pub fn render( opts: &ResolveOptions, view: &Affine, ctx: &PaintCtx, -) -> (Resolved, DrawList, FrameStats) { - let (resolved, list, mut stats) = resolve_and_build_profiled(doc, opts, ctx); +) -> Result<(FrameProduct, FrameStats), FrameError> { + let (product, mut stats) = resolve_and_build_profiled(doc, opts, ctx)?; + let t0 = Instant::now(); + product.execute(canvas, view, ctx)?; + stats.execute_ns = t0.elapsed().as_nanos(); + Ok((product, stats)) +} + +/// Render one frame from one validated authored-plus-effective-value view. +/// This is still the full reference pipeline: values do not select an +/// incremental or compositor-only semantic path. +pub fn render_view( + canvas: &skia_safe::Canvas, + values: &ValueView<'_>, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, +) -> Result<(FrameProduct, FrameStats), FrameError> { + let (product, mut stats) = resolve_and_build_view_profiled(values, opts, ctx)?; let t0 = Instant::now(); - execute(canvas, &list, view, ctx); + product.execute(canvas, view, ctx)?; stats.execute_ns = t0.elapsed().as_nanos(); - (resolved, list, stats) + Ok((product, stats)) } diff --git a/model-v2/engine/src/ident.rs b/model-v2/engine/src/ident.rs index bc8ac35e11..2e1f06b81c 100644 --- a/model-v2/engine/src/ident.rs +++ b/model-v2/engine/src/ident.rs @@ -1,28 +1,16 @@ -//! ENG-2.3 / ENG-1.4 · the cache-key type. Cache identity is -//! `(slot, generation)`, never a bare [`NodeId`] — the arena reuses -//! nothing today, but the moment any cache (raster, layout, glyph atlas) -//! exists, a future reused slot must not be able to alias a prior node's -//! cached artifacts. No cache consumes [`Key`] yet: the type IS the -//! socket, and `key_of` (step 3, once `Document::gen_of` lands) is the -//! only sanctioned way to mint one. +//! ENG-2.3 / ENG-1.4 · the cache-key identity. +//! +//! The model owns the one runtime node-key domain: arena incarnation, slot, +//! and generation. The engine re-exports that type instead of defining a +//! second identity that could drift from document liveness rules. use anchor_lab::model::{Document, NodeId}; -/// A generation-stamped node identity — the mandated key for every future -/// cache tier. Equality includes the generation, so a recycled slot is a -/// different key. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Key { - pub id: NodeId, - pub gen: u32, -} +/// The single arena-scoped, generation-stamped runtime node identity. +pub use anchor_lab::model::NodeKey as Key; -/// Mint the cache key for a node — the ONLY sanctioned way. Reads the -/// live generation from the arena so a future reused slot yields a -/// distinct key from the node that previously occupied it. -pub fn key_of(doc: &Document, id: NodeId) -> Key { - Key { - id, - gen: doc.gen_of(id), - } +/// Mint a key only for a live node. Dead, missing, and tombstoned slots have +/// no identity. +pub fn key_of(doc: &Document, id: NodeId) -> Option { + doc.key_of(id) } diff --git a/model-v2/engine/src/lib.rs b/model-v2/engine/src/lib.rs index dd6357d5d1..bb475780b0 100644 --- a/model-v2/engine/src/lib.rs +++ b/model-v2/engine/src/lib.rs @@ -2,7 +2,8 @@ //! model ([`anchor_lab`], consumed as a library — the same relationship //! `crates/grida` will have with the model crate after the migration). //! -//! This crate is the pipeline: `document -> resolve -> drawlist -> paint` +//! This crate is the pipeline: +//! `(document + immutable effective values) -> resolve -> drawlist -> paint` //! (the browser's staged-and-pure discipline) plus the read tier //! (`query`), time-as-data (`journal`/`replay`), and the sockets every //! future optimization plugs into (`damage`, `ident`, `oracle`). The diff --git a/model-v2/engine/src/paint.rs b/model-v2/engine/src/paint.rs index 3139c22e6f..ff0957a70b 100644 --- a/model-v2/engine/src/paint.rs +++ b/model-v2/engine/src/paint.rs @@ -1,10 +1,13 @@ //! ENG-2.1 · the paint executor — the only module that touches Skia's raster //! API. [`crate::text_layout`] uses Skia Paragraph strictly as the shaping -//! oracle. `execute(canvas, drawlist, view, ctx)` (step 6) replays a +//! oracle. `execute_unchecked(canvas, drawlist, view, ctx)` replays a //! [`DrawList`](crate::drawlist::DrawList) onto a skia `Canvas`, //! composing `view.then(&item.world)` per item in the exact mathematical //! form the current spike painter uses — pixel identity is a property of -//! doing the same float ops in the same order, not a tolerance. +//! doing the same float ops in the same order, not a tolerance. Complete +//! [`FrameProduct`](crate::frame::FrameProduct) rendering enters through its +//! checked execution method; the raw function here is for glyphless structural +//! probes and internal retained-list replay only. use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -17,6 +20,8 @@ use anchor_lab::model::{ StrokeJoin, StrokeWidth, SweepGradientPaint, TileMode, }; use anchor_lab::path::{FillRule, PathCommand, ResolvedPathArtifact}; +use anchor_lab::renderability::{self, RenderabilityError}; +use anchor_lab::rounded_box::smooth_corner_params; use skia_safe::canvas::{SaveLayerFlags, SaveLayerRec}; use skia_safe::gradient_shader::{Gradient, GradientColors, Interpolation}; use skia_safe::{ @@ -28,6 +33,157 @@ use skia_safe::{ use crate::drawlist::{DrawList, ItemKind}; +/// The gradient family whose local matrix could not be represented by the +/// raster backend for one resolved paint box. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GradientKind { + Linear, + Radial, + Sweep, + Diamond, +} + +impl std::fmt::Display for GradientKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + GradientKind::Linear => "linear", + GradientKind::Radial => "radial", + GradientKind::Sweep => "sweep", + GradientKind::Diamond => "diamond", + }) + } +} + +/// Semantic use of a paint stack in the exact draw item that failed backend +/// capability validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaintUseContext { + Fill, + Stroke, + TextRun { source_run: Option }, +} + +impl std::fmt::Display for PaintUseContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PaintUseContext::Fill => f.write_str("fill"), + PaintUseContext::Stroke => f.write_str("stroke"), + PaintUseContext::TextRun { + source_run: Some(run), + } => write!(f, "text source run {run}"), + PaintUseContext::TextRun { source_run: None } => f.write_str("uniform text run"), + } + } +} + +/// One exact drawlist gradient has no invertible local matrix in the pinned +/// backend. The paint index is explicitly post-visibility-filtering; authored +/// inactive or zero-opacity entries are absent from the drawlist. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GradientPreflightReason { + InvalidPaint(RenderabilityError), + BackendMatrixNotInvertible, + BackendShaderConstructionFailed, +} + +/// One exact drawlist gradient failed capability validation. The paint index +/// is explicitly post-visibility-filtering; authored inactive or zero-opacity +/// entries are absent from the drawlist. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GradientPreflightError { + pub node: anchor_lab::model::NodeId, + pub gradient: GradientKind, + pub context: PaintUseContext, + pub draw_item: usize, + pub visible_paint_index: usize, + pub reason: GradientPreflightReason, +} + +impl std::fmt::Display for GradientPreflightError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} gradient on node {} {} visible paint {} in draw item {}: ", + self.gradient, self.node, self.context, self.visible_paint_index, self.draw_item + )?; + match &self.reason { + GradientPreflightReason::InvalidPaint(error) => error.fmt(f), + GradientPreflightReason::BackendMatrixNotInvertible => { + f.write_str("no invertible backend matrix exists for its resolved paint box") + } + GradientPreflightReason::BackendShaderConstructionFailed => { + f.write_str("the backend could not construct the gradient shader") + } + } + } +} + +impl std::error::Error for GradientPreflightError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImagePreflightReason { + MissingResource, + UnsupportedModelState, + TotalMatrixNotInvertible, + ShaderConstructionFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImagePreflightError { + pub node: anchor_lab::model::NodeId, + pub context: PaintUseContext, + pub rid: String, + pub draw_item: usize, + pub visible_paint_index: usize, + pub reason: ImagePreflightReason, +} + +impl std::fmt::Display for ImagePreflightError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "image `{}` on node {} {} visible paint {} in draw item {}: ", + self.rid, self.node, self.context, self.visible_paint_index, self.draw_item + )?; + f.write_str(match self.reason { + ImagePreflightReason::MissingResource => "resource is not loaded in this paint context", + ImagePreflightReason::UnsupportedModelState => { + "image paint state is unsupported by the proving renderer" + } + ImagePreflightReason::TotalMatrixNotInvertible => { + "view, world, and image-fit matrices do not compose to an invertible backend matrix" + } + ImagePreflightReason::ShaderConstructionFailed => { + "the backend could not construct the image shader" + } + }) + } +} + +impl std::error::Error for ImagePreflightError {} + +static NEXT_PAINT_CONTEXT_ID: AtomicU64 = AtomicU64::new(1); + +/// Opaque identity of one complete host paint environment. +/// +/// The context incarnation distinguishes independent hosts; the checked +/// revision changes whenever fonts or decoded images change, including an +/// overwrite under the same logical resource id. Damage and caches compare +/// this value without depending on [`PaintCtx`] itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PaintEnvironmentKey { + context: u64, + revision: u64, +} + +fn fresh_paint_context_id() -> u64 { + NEXT_PAINT_CONTEXT_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| { + next.checked_add(1) + }) + .expect("paint context identity space exhausted") +} + /// Host-supplied resources: the typeface offered to text resolution and decoded /// images used at paint time. Exact shaped fonts live with the drawlist; images /// stay keyed by the model's logical RID so authored-source resolution remains @@ -35,29 +191,33 @@ use crate::drawlist::{DrawList, ItemKind}; /// origin-aware runtime RID before this boundary. pub struct PaintCtx { id: u64, - resource_revision: u64, + revision: u64, font: Option, images: BTreeMap, } impl PaintCtx { pub fn new(font: Option) -> Self { - static NEXT_ID: AtomicU64 = AtomicU64::new(1); Self { - id: NEXT_ID.fetch_add(1, Ordering::Relaxed), - resource_revision: 0, + id: fresh_paint_context_id(), + revision: 0, font, images: BTreeMap::new(), } } - pub(crate) fn identity(&self) -> (u64, u64) { - (self.id, self.resource_revision) + /// Snapshot the current opaque environment identity for cache and damage + /// comparison. It carries no resource contents and performs no I/O. + pub fn environment_key(&self) -> PaintEnvironmentKey { + PaintEnvironmentKey { + context: self.id, + revision: self.revision, + } } - fn bump_resource_revision(&mut self) { - self.resource_revision = self - .resource_revision + fn bump_revision(&mut self) { + self.revision = self + .revision .checked_add(1) .expect("paint context revision exhausted"); } @@ -71,15 +231,20 @@ impl PaintCtx { /// Replace the host typeface and invalidate every cache keyed by this /// context. Existing drawlists keep their exact resolved fonts. pub fn set_font(&mut self, font: Option) { + // Exhaustion must fail before the environment changes; otherwise the + // same key could name two different resource states. + self.bump_revision(); self.font = font; - self.bump_resource_revision(); } /// Register an already-decoded image under the exact model resource id. pub fn insert_image(&mut self, rid: impl Into, image: Image) { let image = image.with_default_mipmaps().unwrap_or(image); - self.images.insert(rid.into(), image); - self.bump_resource_revision(); + let rid = rid.into(); + // BTreeMap insertion has no recoverable failure after this point, so + // reserve the new environment identity before mutating the map. + self.bump_revision(); + self.images.insert(rid, image); } /// Eagerly decode encoded PNG/JPEG/WebP bytes and register them under `rid`. @@ -115,6 +280,7 @@ mod paint_ctx_tests { use anchor_lab::model::{ Color as ModelColor, GradientStop, LinearGradientPaint, Paint as ModelPaint, }; + use std::panic::{catch_unwind, AssertUnwindSafe}; #[test] fn encoded_resources_are_eager_raster_images() { @@ -128,6 +294,31 @@ mod paint_ctx_tests { ); } + #[test] + fn revision_exhaustion_cannot_mutate_the_environment() { + const IMAGE: &[u8] = include_bytes!("../../../fixtures/images/border-diamonds.png"); + const FONT: &[u8] = + include_bytes!("../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); + + let mut images = PaintCtx::new(None); + images.revision = u64::MAX; + let image = skia_safe::Image::from_encoded(skia_safe::Data::new_copy(IMAGE)).unwrap(); + let image_key = images.environment_key(); + assert!(catch_unwind(AssertUnwindSafe(|| images.insert_image("new", image))).is_err()); + assert_eq!(images.environment_key(), image_key); + assert!(!images.contains_image("new")); + + let mut fonts = PaintCtx::new(None); + fonts.revision = u64::MAX; + let typeface = skia_safe::FontMgr::new() + .new_from_data(FONT, None) + .expect("bundled Inter typeface"); + let font_key = fonts.environment_key(); + assert!(catch_unwind(AssertUnwindSafe(|| fonts.set_font(Some(typeface)))).is_err()); + assert_eq!(fonts.environment_key(), font_key); + assert!(fonts.font().is_none()); + } + #[test] fn shader_paint_opacity_remains_float_precision() { let opacity = 0.123_456_7; @@ -255,6 +446,323 @@ fn paint_box_matrix(paint_box: PaintBox, transform: &Affine) -> Matrix { matrix } +fn gradient_transform(model: &ModelPaint) -> Option<(GradientKind, &Affine)> { + match model { + ModelPaint::LinearGradient(gradient) => Some((GradientKind::Linear, &gradient.transform)), + ModelPaint::RadialGradient(gradient) => Some((GradientKind::Radial, &gradient.transform)), + ModelPaint::SweepGradient(gradient) => Some((GradientKind::Sweep, &gradient.transform)), + ModelPaint::DiamondGradient(gradient) => Some((GradientKind::Diamond, &gradient.transform)), + ModelPaint::Solid(_) | ModelPaint::Image(_) => None, + } +} + +fn preflight_paints( + node: anchor_lab::model::NodeId, + draw_item: usize, + context: PaintUseContext, + paints: &Paints, + paint_box: PaintBox, +) -> Result<(), GradientPreflightError> { + for (visible_paint_index, model) in paints.iter().enumerate() { + let Some((gradient, transform)) = gradient_transform(model) else { + continue; + }; + if let Err(error) = renderability::validate_paint(model) { + return Err(GradientPreflightError { + node, + gradient, + context, + draw_item, + visible_paint_index, + reason: GradientPreflightReason::InvalidPaint(error), + }); + } + if paint_box_matrix(paint_box, transform).invert().is_none() { + return Err(GradientPreflightError { + node, + gradient, + context, + draw_item, + visible_paint_index, + reason: GradientPreflightReason::BackendMatrixNotInvertible, + }); + } + let shader_exists = match model { + ModelPaint::LinearGradient(model) => linear_gradient_shader(model, paint_box).is_some(), + ModelPaint::RadialGradient(model) => radial_gradient_shader(model, paint_box).is_some(), + ModelPaint::SweepGradient(model) => sweep_gradient_shader(model, paint_box).is_some(), + ModelPaint::DiamondGradient(model) => { + diamond_gradient_shader(model, paint_box).is_some() + } + ModelPaint::Solid(_) | ModelPaint::Image(_) => unreachable!(), + }; + if !shader_exists { + return Err(GradientPreflightError { + node, + gradient, + context, + draw_item, + visible_paint_index, + reason: GradientPreflightReason::BackendShaderConstructionFailed, + }); + } + } + Ok(()) +} + +/// Prove that every gradient which can be evaluated by this exact drawlist has +/// an invertible backend-local matrix for its resolved paint box. This is +/// deliberately later than authored paint validation: multiplying a finite, +/// mathematically invertible transform by a concrete box can rescue or defeat +/// binary32 backend representability. +pub(crate) fn preflight_gradients(list: &DrawList) -> Result<(), GradientPreflightError> { + for (draw_item, item) in list.items.iter().enumerate() { + match &item.kind { + ItemKind::RectFill { w, h, paints, .. } + | ItemKind::OvalFill { w, h, paints } + | ItemKind::PathFill { w, h, paints, .. } => preflight_paints( + item.node, + draw_item, + PaintUseContext::Fill, + paints, + PaintBox::from_size(*w, *h), + )?, + ItemKind::TextFill { + layout, + paints, + paint_w, + paint_h, + } => { + let paint_box = PaintBox::from_size(*paint_w, *paint_h); + for run in &layout.glyph_runs { + let Some(run_paints) = paints.for_source_run(run.source_run) else { + continue; + }; + preflight_paints( + item.node, + draw_item, + PaintUseContext::TextRun { + source_run: run.source_run, + }, + run_paints, + paint_box, + )?; + } + } + ItemKind::RectStroke { w, h, stroke, .. } + | ItemKind::OvalStroke { w, h, stroke } + | ItemKind::PathStroke { w, h, stroke, .. } => preflight_paints( + item.node, + draw_item, + PaintUseContext::Stroke, + &stroke.paints, + PaintBox::from_size(*w, *h), + )?, + ItemKind::LineStroke { + paint_w, + paint_h, + stroke, + .. + } => preflight_paints( + item.node, + draw_item, + PaintUseContext::Stroke, + &stroke.paints, + PaintBox::from_size(*paint_w, *paint_h), + )?, + ItemKind::TextStroke { + layout, + paint_w, + paint_h, + stroke, + .. + } => { + if !layout.glyph_runs.is_empty() { + preflight_paints( + item.node, + draw_item, + PaintUseContext::Stroke, + &stroke.paints, + PaintBox::from_size(*paint_w, *paint_h), + )?; + } + } + ItemKind::BeginOpacity { .. } + | ItemKind::EndOpacity + | ItemKind::BeginClipRect { .. } + | ItemKind::EndClip => {} + } + } + Ok(()) +} + +fn image_rid(model: &ImagePaint) -> &str { + match &model.image { + ResourceRef::Rid(rid) | ResourceRef::Hash(rid) => rid, + } +} + +fn image_model_supported(model: &ImagePaint) -> bool { + model.quarter_turns == 0 + && model.alignment == Alignment::CENTER + && model.filters == ImageFilters::default() + && matches!(model.fit, ImagePaintFit::Fit(_)) +} + +fn preflight_image_paints( + item: &crate::drawlist::Item, + draw_item: usize, + context: PaintUseContext, + paints: &Paints, + paint_box: PaintBox, + view: &Affine, + ctx: &PaintCtx, +) -> Result<(), ImagePreflightError> { + for (visible_paint_index, paint) in paints.iter().enumerate() { + let ModelPaint::Image(model) = paint else { + continue; + }; + let rid = image_rid(model); + let fail = |reason| ImagePreflightError { + node: item.node, + context, + rid: rid.to_owned(), + draw_item, + visible_paint_index, + reason, + }; + if !image_model_supported(model) { + return Err(fail(ImagePreflightReason::UnsupportedModelState)); + } + let image = ctx + .image(rid) + .ok_or_else(|| fail(ImagePreflightReason::MissingResource))?; + let local = image_fit_matrix( + image, + paint_box, + match model.fit { + ImagePaintFit::Fit(fit) => fit, + ImagePaintFit::Transform(_) | ImagePaintFit::Tile(_) => unreachable!(), + }, + ); + let geometry = view.then(&item.world); + let geometry_is_finite = [ + geometry.a, geometry.b, geometry.c, geometry.d, geometry.e, geometry.f, + ] + .iter() + .all(|value| value.is_finite()); + let determinant = f64::from(geometry.a) * f64::from(geometry.d) + - f64::from(geometry.b) * f64::from(geometry.c); + let ctm = skia_matrix(&geometry); + if !geometry_is_finite + || (determinant != 0.0 && Matrix::concat(&ctm, &local).invert().is_none()) + { + return Err(fail(ImagePreflightReason::TotalMatrixNotInvertible)); + } + if image_shader(model, paint_box, ctx).is_none() { + return Err(fail(ImagePreflightReason::ShaderConstructionFailed)); + } + } + Ok(()) +} + +/// Checked-execution image capability fence. Unlike gradient-local preflight, +/// image sampling depends on the final CTM, so this runs for the requested +/// view immediately before replay. +pub(crate) fn preflight_images( + list: &DrawList, + view: &Affine, + ctx: &PaintCtx, +) -> Result<(), ImagePreflightError> { + for (draw_item, item) in list.items.iter().enumerate() { + match &item.kind { + ItemKind::RectFill { w, h, paints, .. } + | ItemKind::OvalFill { w, h, paints } + | ItemKind::PathFill { w, h, paints, .. } => preflight_image_paints( + item, + draw_item, + PaintUseContext::Fill, + paints, + PaintBox::from_size(*w, *h), + view, + ctx, + )?, + ItemKind::TextFill { + layout, + paints, + paint_w, + paint_h, + } => { + let paint_box = PaintBox::from_size(*paint_w, *paint_h); + for run in &layout.glyph_runs { + if let Some(run_paints) = paints.for_source_run(run.source_run) { + preflight_image_paints( + item, + draw_item, + PaintUseContext::TextRun { + source_run: run.source_run, + }, + run_paints, + paint_box, + view, + ctx, + )?; + } + } + } + ItemKind::RectStroke { w, h, stroke, .. } + | ItemKind::OvalStroke { w, h, stroke } + | ItemKind::PathStroke { w, h, stroke, .. } => preflight_image_paints( + item, + draw_item, + PaintUseContext::Stroke, + &stroke.paints, + PaintBox::from_size(*w, *h), + view, + ctx, + )?, + ItemKind::LineStroke { + paint_w, + paint_h, + stroke, + .. + } => preflight_image_paints( + item, + draw_item, + PaintUseContext::Stroke, + &stroke.paints, + PaintBox::from_size(*paint_w, *paint_h), + view, + ctx, + )?, + ItemKind::TextStroke { + layout, + paint_w, + paint_h, + stroke, + .. + } => { + if !layout.glyph_runs.is_empty() { + preflight_image_paints( + item, + draw_item, + PaintUseContext::Stroke, + &stroke.paints, + PaintBox::from_size(*paint_w, *paint_h), + view, + ctx, + )?; + } + } + ItemKind::BeginOpacity { .. } + | ItemKind::EndOpacity + | ItemKind::BeginClipRect { .. } + | ItemKind::EndClip => {} + } + } + Ok(()) +} + /// Convert the model's centered-normalized gradient point to UV for Skia. /// The f64 intermediate avoids overflowing or needlessly rounding finite f32 /// model values before the result returns to Skia's f32 coordinate space. @@ -364,16 +872,28 @@ fn sk_paint(model: &ModelPaint, paint_box: PaintBox, ctx: &PaintCtx) -> Option

{ - paint.set_shader(linear_gradient_shader(model, paint_box)?); + paint.set_shader( + linear_gradient_shader(model, paint_box) + .expect("preflighted linear gradient shader construction failed"), + ); } ModelPaint::RadialGradient(model) => { - paint.set_shader(radial_gradient_shader(model, paint_box)?); + paint.set_shader( + radial_gradient_shader(model, paint_box) + .expect("preflighted radial gradient shader construction failed"), + ); } ModelPaint::SweepGradient(model) => { - paint.set_shader(sweep_gradient_shader(model, paint_box)?); + paint.set_shader( + sweep_gradient_shader(model, paint_box) + .expect("preflighted sweep gradient shader construction failed"), + ); } ModelPaint::DiamondGradient(model) => { - paint.set_shader(diamond_gradient_shader(model, paint_box)?); + paint.set_shader( + diamond_gradient_shader(model, paint_box) + .expect("preflighted diamond gradient shader construction failed"), + ); } ModelPaint::Image(model) => { paint.set_shader(image_shader(model, paint_box, ctx)?); @@ -513,51 +1033,6 @@ fn ordinary_rrect_path(w: f32, h: f32, radius: &RectangularCornerRadius) -> Path ordinary_rrect_path_at(Rect::from_wh(w, h), radius) } -#[derive(Debug, Clone, Copy)] -struct SmoothCornerParams { - radius: f32, - extent: f32, - a: f32, - b: f32, - c: f32, - d: f32, - bezier_angle: f32, -} - -fn smooth_corner_params(radius: f32, smoothing: f32, shortest_side: f32) -> SmoothCornerParams { - let smoothing = smoothing.clamp(0.0, 1.0); - let radius = radius.min(shortest_side / 2.0).max(0.0); - let extent = ((1.0 + smoothing) * radius).min(shortest_side / 2.0); - let (circle_angle, bezier_angle) = if radius > shortest_side / 4.0 { - let change = (radius - shortest_side / 4.0) / (shortest_side / 4.0); - ( - 90.0 * (1.0 - smoothing * (1.0 - change)), - 45.0 * smoothing * (1.0 - change), - ) - } else { - (90.0 * (1.0 - smoothing), 45.0 * smoothing) - }; - - let bezier_radians = bezier_angle.to_radians(); - let circle_radians = circle_angle.to_radians(); - let tangent = bezier_radians.tan(); - let longest = radius * (bezier_radians / 2.0).tan(); - let arc_chord = (circle_radians / 2.0).sin() * radius * 2.0_f32.sqrt(); - let c = longest * bezier_radians.cos(); - let d = c * tangent; - let b = ((extent - arc_chord) - (1.0 + tangent) * c) / 3.0; - - SmoothCornerParams { - radius, - extent, - a: 2.0 * b, - b, - c, - d, - bezier_angle, - } -} - /// Mirror the production engine's orthogonal smooth-corner construction. /// /// That construction is circular-only today, so the XML boundary rejects @@ -1032,12 +1507,13 @@ fn text_path( builder.snapshot() } -/// Replay a [`DrawList`] onto a skia canvas under `view`. Drawing items compose -/// `view.then(&item.world)` and set that matrix absolutely. Balanced scope -/// commands persist across drawing items: opacity opens a backdrop-preserving -/// group layer, while a content clip saves the node-local box outline until -/// `EndClip`. -pub fn execute(canvas: &Canvas, list: &DrawList, view: &Affine, ctx: &PaintCtx) { +/// Replay a raw [`DrawList`] without a frame-environment check. +/// +/// This low-level entry exists for glyphless structural probes and internal +/// retained-list replay. A host rendering a complete semantic frame must call +/// [`crate::frame::FrameProduct::execute`], which refuses a context whose +/// incarnation or resource revision differs from the one captured at build. +pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, ctx: &PaintCtx) { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Scope { Opacity, @@ -1271,6 +1747,9 @@ pub fn execute(canvas: &Canvas, list: &DrawList, view: &Affine, ctx: &PaintCtx) paint_h, stroke, } => { + if layout.glyph_runs.is_empty() { + continue; + } with_local_transform(canvas, view, &item.world, || { let paint_box = PaintBox::from_size(*paint_w, *paint_h); if stroke.align == StrokeAlign::Center { @@ -1302,16 +1781,25 @@ pub fn execute(canvas: &Canvas, list: &DrawList, view: &Affine, ctx: &PaintCtx) canvas.restore_to_count(initial_save_count); } -/// Render a drawlist to a fresh raster surface and return its premultiplied -/// pixel bytes — the reference for differential pixel tests (`gate_diff` L2). +/// Render a raw drawlist to a fresh raster surface without a frame-environment +/// check and return its premultiplied pixel bytes. This is the low-level +/// reference for glyphless differential probes; complete products use +/// [`crate::frame::FrameProduct::raster_to_bytes`]. +/// /// Bytes, NOT PNG: the encoder is not the system under test, and byte /// equality is exact (ENG-0.3), not a tolerance. `font: None` in the gate /// removes font-availability nondeterminism. -pub fn raster_to_bytes(list: &DrawList, view: &Affine, w: i32, h: i32, ctx: &PaintCtx) -> Vec { +pub fn raster_to_bytes_unchecked( + list: &DrawList, + view: &Affine, + w: i32, + h: i32, + ctx: &PaintCtx, +) -> Vec { let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); let canvas = surface.canvas(); canvas.clear(Color::WHITE); - execute(canvas, list, view, ctx); + execute_unchecked(canvas, list, view, ctx); read_pixels(&mut surface, w, h) } diff --git a/model-v2/engine/src/query.rs b/model-v2/engine/src/query.rs index 19bf859de2..74d77f2f8a 100644 --- a/model-v2/engine/src/query.rs +++ b/model-v2/engine/src/query.rs @@ -9,7 +9,7 @@ //! approximate candidates but never changes what gets selected. use anchor_lab::math::RectF; -use anchor_lab::model::{Document, NodeId}; +use anchor_lab::model::NodeId; use anchor_lab::resolve::Resolved; /// The spatial read tier — the one door for point-hit, marquee, and cull. @@ -19,19 +19,19 @@ use anchor_lab::resolve::Resolved; /// narrowphase [`anchor_lab::pick`], never here — an index may over- /// approximate candidates but never changes what is selected. pub struct Query<'a> { - pub doc: &'a Document, pub resolved: &'a Resolved, } impl<'a> Query<'a> { - pub fn new(doc: &'a Document, resolved: &'a Resolved) -> Self { - Query { doc, resolved } + /// Build the spatial read tier from its complete immutable input. + pub fn new(resolved: &'a Resolved) -> Self { + Query { resolved } } /// Topmost node under a world point — delegates the narrowphase, so what /// gets selected is defined in one place (`pick`), not re-derived here. pub fn hit_point(&self, x: f32, y: f32) -> Option { - anchor_lab::pick::pick(self.doc, self.resolved, x, y) + anchor_lab::pick::pick(self.resolved, x, y) } /// Nodes whose world AABB overlaps `rect` — marquee candidates. Over- diff --git a/model-v2/engine/src/replay.rs b/model-v2/engine/src/replay.rs index f3b94f3110..8094073286 100644 --- a/model-v2/engine/src/replay.rs +++ b/model-v2/engine/src/replay.rs @@ -178,9 +178,10 @@ pub fn play(replay: &Replay) -> Result<(Document, Vec), String> { Ok((doc, results)) } -/// Bit-for-bit equality of two resolved tiers across all six columns — the -/// determinism oracle. `==` would pass -0.0/0.0 and fail NaN==NaN; bits are -/// exact, which is what "same computation" means. +/// Bit-for-bit equality of two resolved tiers across the hot columns, content +/// artifacts, and spatial traversal/clip snapshot — the determinism oracle. +/// `==` would pass -0.0/0.0 and fail NaN==NaN; bits are exact, which is what +/// "same computation" means. pub fn resolved_bits_eq(a: &Resolved, b: &Resolved) -> bool { if a.slot_count() != b.slot_count() { return false; @@ -205,7 +206,7 @@ pub fn resolved_bits_eq(a: &Resolved, b: &Resolved) -> bool { return false; } } - true + a.query_snapshot_bits_eq(b) } fn scalar_eq(a: f32, b: f32) -> bool { diff --git a/model-v2/engine/tests/cache.rs b/model-v2/engine/tests/cache.rs index e5553a14d0..d8e4925540 100644 --- a/model-v2/engine/tests/cache.rs +++ b/model-v2/engine/tests/cache.rs @@ -4,11 +4,14 @@ //! bitmap-can't-rescale boundary), and doc-dirty re-raster. use anchor_engine::cache::{composited_to_bytes, SceneCache}; -use anchor_engine::drawlist::build_glyphless; +use anchor_engine::damage::diff_frame; +use anchor_engine::drawlist::build_glyphless_unchecked; use anchor_engine::frame; -use anchor_engine::paint::{raster_to_bytes, PaintCtx}; +use anchor_engine::paint::{raster_to_bytes_unchecked, PaintCtx}; +use anchor_engine::replay::resolved_bits_eq; use anchor_lab::math::Affine; use anchor_lab::model::*; +use anchor_lab::properties::{PropertyKey, PropertyTarget, PropertyValue, PropertyValues}; use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; use skia_safe::{surfaces, Color as SkColor, FontMgr}; @@ -76,16 +79,32 @@ fn text_scene() -> Document { doc } +fn image_scene() -> (Document, NodeId) { + let mut builder = DocBuilder::new(); + let mut header = Header::new(SizeIntent::Fixed(120.0), SizeIntent::Fixed(80.0)); + header.x = AxisBinding::start(40.0); + header.y = AxisBinding::start(40.0); + let node = builder.add( + 0, + header, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(node).fills = Paints::new([Paint::Image(ImagePaint::from_rid("asset"))]); + (builder.build(), node) +} + fn fresh_bytes(doc: &Document, view: &Affine) -> Vec { let r = resolve(doc, &opts()); - let list = build_glyphless(doc, &r); - raster_to_bytes(&list, view, W, H, &ctx()) + let list = build_glyphless_unchecked(doc, &r); + raster_to_bytes_unchecked(&list, view, W, H, &ctx()) } fn fresh_frame_bytes(doc: &Document, view: &Affine, ctx: &PaintCtx) -> Vec { let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); surface.canvas().clear(SkColor::WHITE); - frame::render(surface.canvas(), doc, &opts(), view, ctx); + frame::render(surface.canvas(), doc, &opts(), view, ctx).expect("valid fresh frame"); anchor_engine::paint::read_pixels(&mut surface, W, H) } @@ -98,7 +117,27 @@ fn cached_frame_bytes( ) -> (Vec, bool) { let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); surface.canvas().clear(SkColor::WHITE); - let rerastered = cache.frame(surface.canvas(), doc, &opts(), view, ctx, doc_dirty); + let rerastered = cache + .frame(surface.canvas(), doc, &opts(), view, ctx, doc_dirty) + .expect("valid cached frame"); + ( + anchor_engine::paint::read_pixels(&mut surface, W, H), + rerastered, + ) +} + +fn cached_value_frame_bytes( + cache: &mut SceneCache, + doc: &Document, + values: &PropertyValues, + view: &Affine, + ctx: &PaintCtx, +) -> (Vec, bool) { + let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); + surface.canvas().clear(SkColor::WHITE); + let rerastered = cache + .frame_with_values(surface.canvas(), doc, values, &opts(), view, ctx, false) + .unwrap(); ( anchor_engine::paint::read_pixels(&mut surface, W, H), rerastered, @@ -112,7 +151,8 @@ fn cache_cold_matches_fresh() { let mut cache = SceneCache::new(W, H); let context = ctx(); // First frame is a cache-cold re-raster at `view`. - let got = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, false, W, H); + let got = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, false, W, H) + .expect("valid cached frame"); assert_eq!(got, fresh_bytes(&doc, &view), "cache-cold must equal fresh"); } @@ -154,9 +194,11 @@ fn zoom_forces_reraster_and_matches_fresh() { }; let mut cache = SceneCache::new(W, H); let context = ctx(); - let _ = composited_to_bytes(&mut cache, &doc, &opts(), &ref_view, &context, false, W, H); + let _ = composited_to_bytes(&mut cache, &doc, &opts(), &ref_view, &context, false, W, H) + .expect("valid cached frame"); // Different zoom → a bitmap blit would be wrong; the cache must re-raster. - let got = composited_to_bytes(&mut cache, &doc, &opts(), &zoomed, &context, false, W, H); + let got = composited_to_bytes(&mut cache, &doc, &opts(), &zoomed, &context, false, W, H) + .expect("valid cached frame"); assert_eq!( got, fresh_bytes(&doc, &zoomed), @@ -170,7 +212,8 @@ fn doc_dirty_forces_reraster_and_matches_fresh() { let view = Affine::translate(200.0, 150.0); let mut cache = SceneCache::new(W, H); let context = ctx(); - let _ = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, false, W, H); + let _ = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, false, W, H) + .expect("valid cached frame"); // Mutate: move the first shape. With doc_dirty the cache rebuilds + re-rasters. let r = resolve(&doc, &opts()); @@ -184,7 +227,8 @@ fn doc_dirty_forces_reraster_and_matches_fresh() { ) .unwrap(); - let got = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, true, W, H); + let got = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, true, W, H) + .expect("valid dirty cached frame"); assert_eq!( got, fresh_bytes(&doc, &view), @@ -199,7 +243,8 @@ fn cache_builds_text_with_the_same_shaping_oracle_as_a_fresh_frame() { let view = Affine::IDENTITY; let mut cache = SceneCache::new(W, H); - let cached = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, false, W, H); + let cached = composited_to_bytes(&mut cache, &doc, &opts(), &view, &context, false, W, H) + .expect("valid cached text frame"); let fresh = fresh_frame_bytes(&doc, &view, &context); assert_eq!(cached, fresh); assert!(cached.chunks_exact(4).any(|pixel| pixel[0] < 100)); @@ -240,40 +285,275 @@ fn changing_resolve_options_invalidates_the_cached_resolved_scene() { let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); let base = opts(); - assert!(cache.frame(surface.canvas(), &doc, &base, &view, &context, false)); - assert!(!cache.frame(surface.canvas(), &doc, &base, &view, &context, false)); + assert!(cache + .frame(surface.canvas(), &doc, &base, &view, &context, false) + .unwrap()); + assert!(!cache + .frame(surface.canvas(), &doc, &base, &view, &context, false) + .unwrap()); let viewport_changed = ResolveOptions { viewport: (base.viewport.0 + 100.0, base.viewport.1), ..base }; - assert!(cache.frame( - surface.canvas(), - &doc, - &viewport_changed, - &view, - &context, - false, - )); - assert!(!cache.frame( - surface.canvas(), - &doc, - &viewport_changed, - &view, - &context, - false, - )); + assert!(cache + .frame( + surface.canvas(), + &doc, + &viewport_changed, + &view, + &context, + false, + ) + .unwrap()); + assert!(!cache + .frame( + surface.canvas(), + &doc, + &viewport_changed, + &view, + &context, + false, + ) + .unwrap()); let rotation_changed = ResolveOptions { rotation_in_flow: RotationInFlow::AabbParticipates, ..viewport_changed }; - assert!(cache.frame( - surface.canvas(), + assert!(cache + .frame( + surface.canvas(), + &doc, + &rotation_changed, + &view, + &context, + false, + ) + .unwrap()); +} + +#[test] +fn changed_effective_values_invalidate_without_document_dirtiness() { + let doc = scene(); + let node = doc.root + 1; + let target = PropertyTarget::new(doc.key_of(node).unwrap(), PropertyKey::Fills); + let blue = PropertyValues::new( &doc, - &rotation_changed, - &view, - &context, - false, - )); + [( + target, + PropertyValue::Paints(Paints::solid("#2563EB".into())), + )], + ) + .unwrap(); + let red = PropertyValues::new( + &doc, + [( + target, + PropertyValue::Paints(Paints::solid("#DC2626".into())), + )], + ) + .unwrap(); + let context = ctx(); + let view = Affine::IDENTITY; + let mut cache = SceneCache::new(W, H); + + let (blue_bytes, cold) = cached_value_frame_bytes(&mut cache, &doc, &blue, &view, &context); + let (_, reused) = cached_value_frame_bytes(&mut cache, &doc, &blue, &view, &context); + let (red_bytes, changed) = cached_value_frame_bytes(&mut cache, &doc, &red, &view, &context); + + assert!(cold); + assert!( + !reused, + "the same immutable values reuse the retained scene" + ); + assert!( + changed, + "effective values invalidate without a document dirty hint" + ); + assert_ne!(blue_bytes, red_bytes); +} + +#[test] +fn a_fresh_document_arena_invalidates_even_with_empty_values() { + let doc = scene(); + let clone = doc.clone(); + let context = ctx(); + let view = Affine::IDENTITY; + let mut cache = SceneCache::new(W, H); + let mut surface = surfaces::raster_n32_premul((W, H)).unwrap(); + + assert!(cache + .frame(surface.canvas(), &doc, &opts(), &view, &context, false) + .unwrap()); + assert!(!cache + .frame(surface.canvas(), &doc, &opts(), &view, &context, false) + .unwrap()); + assert!(cache + .frame(surface.canvas(), &clone, &opts(), &view, &context, false,) + .unwrap()); + assert!(!cache + .frame(surface.canvas(), &clone, &opts(), &view, &context, false,) + .unwrap()); +} + +#[test] +fn same_rid_replacement_is_environment_damage_and_invalidates_the_cache() { + const CHECKER: &[u8] = include_bytes!("../../../fixtures/images/checker.png"); + const STRIPES: &[u8] = include_bytes!("../../../fixtures/images/stripes.png"); + + let (document, node) = image_scene(); + let view = Affine::IDENTITY; + let mut context = ctx(); + context.insert_encoded("asset", CHECKER).unwrap(); + let before_environment = context.environment_key(); + let before = frame::resolve_and_build(&document, &opts(), &context).expect("valid frame"); + assert_eq!(before.environment(), before_environment); + let mut cache = SceneCache::new(W, H); + let (before_pixels, cold) = cached_frame_bytes(&mut cache, &document, &view, &context, false); + assert!(cold); + + context.insert_encoded("asset", STRIPES).unwrap(); + let after_environment = context.environment_key(); + let after = frame::resolve_and_build(&document, &opts(), &context).expect("valid frame"); + assert_eq!(after.environment(), after_environment); + assert!(resolved_bits_eq(before.resolved(), after.resolved())); + assert_eq!( + before.drawlist(), + after.drawlist(), + "the logical RID did not change" + ); + + let environment_damage = diff_frame(&before, &after); + assert_eq!(environment_damage.changed, vec![node]); + assert!(environment_damage.union_world.is_some()); + + let (after_pixels, rerastered) = + cached_frame_bytes(&mut cache, &document, &view, &context, false); + assert!( + rerastered, + "resource revision invalidates without doc dirtiness" + ); + assert_ne!(before_pixels, after_pixels); +} + +#[test] +fn failed_rebuild_preserves_destination_and_previous_cache_entry() { + let valid = scene(); + let view = Affine::IDENTITY; + let context = ctx(); + let mut cache = SceneCache::new(W, H); + + let mut first_surface = surfaces::raster_n32_premul((W, H)).unwrap(); + first_surface.canvas().clear(SkColor::WHITE); + assert!(cache + .frame( + first_surface.canvas(), + &valid, + &opts(), + &view, + &context, + false, + ) + .unwrap()); + let first = anchor_engine::paint::read_pixels(&mut first_surface, W, H); + + let mut invalid = valid.clone(); + let node = invalid.root + 1; + invalid.get_mut(node).fills = Paints::new([Paint::LinearGradient(LinearGradientPaint { + transform: Affine { + a: 1e-20, + b: 1e-20, + c: 0.0, + d: 1e-20, + e: 0.0, + f: 0.0, + }, + stops: vec![ + GradientStop { + offset: 0.0, + color: Color::BLACK, + }, + GradientStop { + offset: 1.0, + color: Color(0xFFFF_FFFF), + }, + ], + ..Default::default() + })]); + + let mut failed_surface = surfaces::raster_n32_premul((W, H)).unwrap(); + failed_surface.canvas().clear(SkColor::MAGENTA); + let before_failure = anchor_engine::paint::read_pixels(&mut failed_surface, W, H); + cache + .frame( + failed_surface.canvas(), + &invalid, + &opts(), + &view, + &context, + true, + ) + .expect_err("invalid replacement frame must fail before cache commit"); + let after_failure = anchor_engine::paint::read_pixels(&mut failed_surface, W, H); + assert_eq!(after_failure, before_failure, "destination canvas changed"); + + let mut reused_surface = surfaces::raster_n32_premul((W, H)).unwrap(); + reused_surface.canvas().clear(SkColor::WHITE); + assert!(!cache + .frame( + reused_surface.canvas(), + &valid, + &opts(), + &view, + &context, + false, + ) + .unwrap()); + assert_eq!( + anchor_engine::paint::read_pixels(&mut reused_surface, W, H), + first, + "failed rebuild replaced the prior retained frame" + ); + + let mut missing_image = valid.clone(); + let node = missing_image.root + 1; + missing_image.get_mut(node).fills = + Paints::new([Paint::Image(ImagePaint::from_rid("missing"))]); + let mut image_failure_surface = surfaces::raster_n32_premul((W, H)).unwrap(); + image_failure_surface.canvas().clear(SkColor::CYAN); + let before_image_failure = anchor_engine::paint::read_pixels(&mut image_failure_surface, W, H); + cache + .frame( + image_failure_surface.canvas(), + &missing_image, + &opts(), + &view, + &context, + true, + ) + .expect_err("missing image must fail checked cache execution"); + assert_eq!( + anchor_engine::paint::read_pixels(&mut image_failure_surface, W, H), + before_image_failure, + "image execution failure changed the destination" + ); + + let mut final_surface = surfaces::raster_n32_premul((W, H)).unwrap(); + final_surface.canvas().clear(SkColor::WHITE); + assert!(!cache + .frame( + final_surface.canvas(), + &valid, + &opts(), + &view, + &context, + false, + ) + .unwrap()); + assert_eq!( + anchor_engine::paint::read_pixels(&mut final_surface, W, H), + first, + "image execution failure replaced the prior retained frame" + ); } diff --git a/model-v2/engine/tests/damage.rs b/model-v2/engine/tests/damage.rs index 33d5a6340e..8e0a5ce0c4 100644 --- a/model-v2/engine/tests/damage.rs +++ b/model-v2/engine/tests/damage.rs @@ -1,10 +1,13 @@ -//! ENG-2.2 · damage is a data diff of two resolved tiers. Identical tiers -//! produce no damage; a single independent move damages exactly that node -//! (the locality the incremental resolver will exploit — OS-1a). +//! ENG-2.2 · legacy `diff` covers resolved-tier geometry while `diff_frame` +//! compares complete immutable frame products. Identical frames produce no +//! damage; local geometry, artifacts, paint, environment, and painter-order +//! changes remain attributed to covering nodes. use std::sync::Arc; -use anchor_engine::damage::diff; +use anchor_engine::damage::{diff, diff_frame}; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; use anchor_lab::math::RectF; use anchor_lab::model::*; use anchor_lab::ops::{apply, Op}; @@ -199,3 +202,20 @@ fn material_text_artifact_changes_damage_even_when_geometry_is_identical() { assert!(damage.union_world.is_some()); } } + +#[test] +fn painter_order_changes_damage_every_reordered_item() { + let (mut document, first, second) = scene(); + document.get_mut(first).fills = Paints::solid("#DC2626".into()); + document.get_mut(second).fills = Paints::solid("#2563EB".into()); + let context = PaintCtx::new(None); + let before = frame::resolve_and_build(&document, &opts(), &context).expect("valid frame"); + assert_eq!(before.drawlist().items.len(), 2); + document.get_mut(document.root).children.swap(0, 1); + let after = frame::resolve_and_build(&document, &opts(), &context).expect("valid frame"); + + let damage = diff_frame(&before, &after); + assert_eq!(damage.changed, vec![first, second]); + let bounds = damage.union_world.expect("reordered ink has coverage"); + assert!(bounds.w > 0.0 && bounds.h > 0.0); +} diff --git a/model-v2/engine/tests/drawlist.rs b/model-v2/engine/tests/drawlist.rs index 8af97f1554..de3a04a4c6 100644 --- a/model-v2/engine/tests/drawlist.rs +++ b/model-v2/engine/tests/drawlist.rs @@ -2,7 +2,7 @@ //! tests guard ordering, balanced scopes, pruning, authored paint transfer, //! and verbatim resolver transforms. -use anchor_engine::drawlist::{build_glyphless, ItemKind}; +use anchor_engine::drawlist::{build_glyphless_unchecked, ItemKind}; use anchor_lab::model::*; use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; @@ -121,7 +121,7 @@ fn scene() -> (Document, NodeId, NodeId) { fn traversal_order_and_pruning() { let (doc, _f, _r) = scene(); let resolved = resolve(&doc, &opts()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); let tags: Vec<&str> = list.items.iter().map(|it| tag(&it.kind)).collect(); // frame(fill,stroke), rect, ellipse, line stroke, group-child rect, text; @@ -144,7 +144,7 @@ fn traversal_order_and_pruning() { fn paint_stacks_materialize_without_kind_fallbacks() { let (doc, _f, _r) = scene(); let resolved = resolve(&doc, &opts()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); // Frame fill = the node's #ff0000, opaque. let ItemKind::RectFill { ref paints, .. } = list.items[0].kind else { @@ -169,7 +169,7 @@ fn paint_stacks_materialize_without_kind_fallbacks() { fn world_is_copied_verbatim_not_recomputed() { let (doc, f, _r) = scene(); let resolved = resolve(&doc, &opts()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); // The frame's item carries the resolver's world transform unchanged. assert_eq!(list.items[0].world, resolved.world_of(f)); } @@ -179,8 +179,8 @@ fn build_is_deterministic() { let (doc, _f, _r) = scene(); let resolved = resolve(&doc, &opts()); assert_eq!( - build_glyphless(&doc, &resolved), - build_glyphless(&doc, &resolved) + build_glyphless_unchecked(&doc, &resolved), + build_glyphless_unchecked(&doc, &resolved) ); } @@ -210,7 +210,7 @@ fn fill_children_and_strokes_are_wrapped_by_balanced_scopes() { doc.get_mut(child).fills = Paints::solid("#00FF00".into()); let resolved = resolve(&doc, &opts()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); assert_eq!( list.items .iter() @@ -251,7 +251,7 @@ fn repeated_strokes_keep_geometry_and_paint_order() { doc.get_mut(rect).strokes = vec![lower.clone(), upper.clone()]; let resolved = resolve(&doc, &opts()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); let strokes = list .items .iter() @@ -286,7 +286,7 @@ fn frame_and_line_have_no_implicit_ink() { // Even a programmatic compatibility fill does not become line ink. doc.get_mut(line).fills = Paints::solid("#FF0000".into()); let resolved = resolve(&doc, &opts()); - assert!(build_glyphless(&doc, &resolved).items.is_empty()); + assert!(build_glyphless_unchecked(&doc, &resolved).items.is_empty()); } #[test] @@ -313,7 +313,7 @@ fn ineffective_strokes_emit_no_item_and_visible_paints_keep_order() { doc.get_mut(rect).strokes = vec![zero_width, empty, visible]; let resolved = resolve(&doc, &opts()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); let [item] = list.items.as_slice() else { panic!("exactly one effective stroke should materialize"); }; @@ -349,7 +349,7 @@ fn unsupported_rectangular_stroke_states_emit_no_draw_item() { let doc = builder.build(); let resolved = resolve(&doc, &opts()); assert!( - build_glyphless(&doc, &resolved).items.is_empty(), + build_glyphless_unchecked(&doc, &resolved).items.is_empty(), "{desc:?} must not emit lossy rectangular stroke geometry" ); } diff --git a/model-v2/engine/tests/frame.rs b/model-v2/engine/tests/frame.rs new file mode 100644 index 0000000000..5c31ffab2b --- /dev/null +++ b/model-v2/engine/tests/frame.rs @@ -0,0 +1,471 @@ +//! Complete-frame execution keeps the drawlist and paint environment coupled. + +use anchor_engine::{ + frame::{self, FrameBuildError, FrameError, FrameExecutionError}, + paint::{GradientPreflightReason, ImagePreflightReason, PaintCtx, PaintUseContext}, +}; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::model::*; +use anchor_lab::properties::{ + PropertyKey, PropertyTarget, PropertyValue, PropertyValues, ValueView, +}; +use anchor_lab::resolve::ResolveOptions; +use skia_safe::{surfaces, Color, FontMgr}; + +fn scene() -> Document { + let mut builder = DocBuilder::new(); + let rect = builder.add( + 0, + Header::new(SizeIntent::Fixed(32.0), SizeIntent::Fixed(24.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(rect).fills = Paints::solid("#2563EB".into()); + builder.build() +} + +fn gradient(scale: f32) -> Paint { + Paint::LinearGradient(LinearGradientPaint { + transform: Affine { + a: scale, + b: scale, + c: 0.0, + d: scale, + e: 0.0, + f: 0.0, + }, + stops: vec![ + GradientStop { + offset: 0.0, + color: anchor_lab::model::Color::BLACK, + }, + GradientStop { + offset: 1.0, + color: anchor_lab::model::Color(0xFFFF_FFFF), + }, + ], + ..Default::default() + }) +} + +fn gradient_failure( + result: Result, +) -> anchor_engine::paint::GradientPreflightError { + let FrameBuildError::Gradient(error) = result.expect_err("frame must fail gradient preflight"); + error +} + +#[test] +fn complete_product_rejects_another_paint_context_before_drawing() { + let document = scene(); + let expected_context = PaintCtx::new(None); + let other_context = PaintCtx::new(None); + let product = frame::resolve_and_build( + &document, + &ResolveOptions { + viewport: (64.0, 48.0), + ..Default::default() + }, + &expected_context, + ) + .expect("valid frame"); + let mut surface = surfaces::raster_n32_premul((64, 48)).unwrap(); + surface.canvas().clear(Color::WHITE); + + let error = product + .execute(surface.canvas(), &Affine::IDENTITY, &other_context) + .unwrap_err(); + let FrameExecutionError::Environment(error) = error else { + panic!("expected environment mismatch"); + }; + assert_eq!(error.expected, product.environment()); + assert_eq!(error.actual, other_context.environment_key()); +} + +#[test] +fn complete_product_rejects_a_resource_revision_after_build() { + const IMAGE: &[u8] = include_bytes!("../../../fixtures/images/checker.png"); + + let document = scene(); + let mut context = PaintCtx::new(None); + let product = frame::resolve_and_build( + &document, + &ResolveOptions { + viewport: (64.0, 48.0), + ..Default::default() + }, + &context, + ) + .expect("valid frame"); + let captured = product.environment(); + context.insert_encoded("asset", IMAGE).unwrap(); + + let error = product + .raster_to_bytes(&Affine::IDENTITY, 64, 48, &context) + .unwrap_err(); + let FrameExecutionError::Environment(error) = error else { + panic!("expected environment mismatch"); + }; + assert_eq!(error.expected, captured); + assert_eq!(error.actual, context.environment_key()); +} + +#[test] +fn complete_product_executes_under_its_captured_environment() { + let document = scene(); + let context = PaintCtx::new(None); + let product = frame::resolve_and_build( + &document, + &ResolveOptions { + viewport: (64.0, 48.0), + ..Default::default() + }, + &context, + ) + .expect("valid frame"); + + let pixels = product + .raster_to_bytes(&Affine::IDENTITY, 64, 48, &context) + .unwrap(); + assert!( + pixels + .chunks_exact(4) + .any(|pixel| pixel != [255, 255, 255, 255]), + "the unchanged environment replays the frame" + ); +} + +#[test] +fn parsed_subnormal_gradient_is_box_dependent_at_frame_build() { + let ordinary = grida_xml::parse( + r##""##, + ) + .expect("a finite transform with an exact nonzero f64 determinant parses"); + let error = gradient_failure(frame::resolve_and_build( + &ordinary, + &ResolveOptions::default(), + &PaintCtx::new(None), + )); + assert_eq!(error.context, PaintUseContext::Fill); + assert_eq!(error.visible_paint_index, 0); + assert_eq!( + error.reason, + GradientPreflightReason::BackendMatrixNotInvertible + ); + + let rescued = grida_xml::parse( + r##""##, + ) + .expect("the same mathematically invertible transform parses"); + frame::resolve_and_build(&rescued, &ResolveOptions::default(), &PaintCtx::new(None)) + .expect("the resolved paint box rescues backend matrix conditioning"); +} + +#[test] +fn effective_overflowing_determinant_is_accepted_then_box_preflighted() { + let document_with_size = |size: f32| { + let mut builder = DocBuilder::new(); + let rect = builder.add( + 0, + Header::new(SizeIntent::Fixed(size), SizeIntent::Fixed(size)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + (builder.build(), rect) + }; + let values_for = |document: &Document, rect: NodeId| { + PropertyValues::new( + document, + [( + PropertyTarget::new(document.key_of(rect).unwrap(), PropertyKey::Fills), + PropertyValue::Paints(Paints::new([gradient(1e38)])), + )], + ) + .expect("effective values retain a finite transform whose f32 determinant overflows") + }; + + let (rescued, rescued_rect) = document_with_size(1e-30); + let rescued_values = values_for(&rescued, rescued_rect); + let rescued_view = ValueView::new(&rescued, &rescued_values).unwrap(); + frame::resolve_and_build_view( + &rescued_view, + &ResolveOptions::default(), + &PaintCtx::new(None), + ) + .expect("a tiny resolved paint box rescues the large transform"); + + let (ordinary, ordinary_rect) = document_with_size(100.0); + let ordinary_values = values_for(&ordinary, ordinary_rect); + let ordinary_view = ValueView::new(&ordinary, &ordinary_values).unwrap(); + let error = gradient_failure(frame::resolve_and_build_view( + &ordinary_view, + &ResolveOptions::default(), + &PaintCtx::new(None), + )); + assert_eq!(error.context, PaintUseContext::Fill); + assert_eq!( + error.reason, + GradientPreflightReason::BackendMatrixNotInvertible + ); +} + +#[test] +fn frame_build_error_identifies_stroke_and_text_run_contexts() { + let mut builder = DocBuilder::new(); + let rect = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(80.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut stroke = Stroke::default_for(&Payload::Shape { + desc: ShapeDesc::Rect, + }) + .unwrap(); + stroke.width = StrokeWidth::Uniform(4.0); + stroke.paints = Paints::new([gradient(1e-20)]); + builder.node_mut(rect).strokes.push(stroke); + let stroke_doc = builder.build(); + let stroke_error = gradient_failure(frame::resolve_and_build( + &stroke_doc, + &ResolveOptions::default(), + &PaintCtx::new(None), + )); + assert_eq!(stroke_error.node, rect); + assert_eq!(stroke_error.context, PaintUseContext::Stroke); + + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Auto), + Payload::AttributedText { + attributed_string: AttributedString::from_runs( + "Hi", + vec![StyledTextRun { + start: 0, + end: 2, + style: TextStyleRec::default(), + fills: Some(Paints::new([gradient(1e-20)])), + }], + ) + .unwrap(), + default_style: TextStyleRec::default(), + }, + ); + let text_doc = builder.build(); + const INTER: &[u8] = + include_bytes!("../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); + let typeface = FontMgr::new() + .new_from_data(INTER, None) + .expect("bundled Inter typeface"); + let text_error = gradient_failure(frame::resolve_and_build( + &text_doc, + &ResolveOptions::default(), + &PaintCtx::new(Some(typeface)), + )); + assert_eq!(text_error.node, text); + assert_eq!( + text_error.context, + PaintUseContext::TextRun { + source_run: Some(0) + } + ); +} + +#[test] +fn render_rejects_before_mutating_the_destination_canvas() { + let document = grida_xml::parse( + r##""##, + ) + .unwrap(); + let mut surface = surfaces::raster_n32_premul((32, 32)).unwrap(); + surface.canvas().clear(Color::MAGENTA); + let before = anchor_engine::paint::read_pixels(&mut surface, 32, 32); + let save_count = surface.canvas().save_count(); + + let error = frame::render( + surface.canvas(), + &document, + &ResolveOptions::default(), + &Affine::IDENTITY, + &PaintCtx::new(None), + ) + .expect_err("unrepresentable gradient must fail frame construction"); + assert!(matches!( + error, + FrameError::Build(FrameBuildError::Gradient(_)) + )); + assert_eq!(surface.canvas().save_count(), save_count); + let after = anchor_engine::paint::read_pixels(&mut surface, 32, 32); + assert_eq!(before, after); +} + +#[test] +fn ordinary_gradient_frame_still_rasters() { + let mut builder = DocBuilder::new(); + let rect = builder.add( + 0, + Header::new(SizeIntent::Fixed(32.0), SizeIntent::Fixed(24.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(rect).fills = Paints::new([gradient(1.0)]); + let document = builder.build(); + let context = PaintCtx::new(None); + let product = frame::resolve_and_build( + &document, + &ResolveOptions { + viewport: (64.0, 48.0), + ..Default::default() + }, + &context, + ) + .expect("ordinary gradient passes frame preflight"); + let pixels = product + .raster_to_bytes(&Affine::IDENTITY, 64, 48, &context) + .unwrap(); + assert!(pixels + .chunks_exact(4) + .any(|pixel| pixel != [255, 255, 255, 255])); +} + +#[test] +fn programmatic_invalid_gradient_is_a_build_error_not_a_painter_panic() { + let mut builder = DocBuilder::new(); + let rect = builder.add( + 0, + Header::new(SizeIntent::Fixed(32.0), SizeIntent::Fixed(24.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(rect).fills = + Paints::new([Paint::LinearGradient(LinearGradientPaint::default())]); + let document = builder.build(); + let error = gradient_failure(frame::resolve_and_build( + &document, + &ResolveOptions::default(), + &PaintCtx::new(None), + )); + assert_eq!(error.node, rect); + assert_eq!(error.context, PaintUseContext::Fill); + assert!(matches!( + error.reason, + GradientPreflightReason::InvalidPaint(_) + )); + assert!(error.to_string().contains("requires at least two stops")); +} + +fn image_document(size: f32) -> Document { + let mut builder = DocBuilder::new(); + let rect = builder.add( + 0, + Header::new(SizeIntent::Fixed(size), SizeIntent::Fixed(size)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut image = ImagePaint::from_rid("asset"); + image.fit = ImagePaintFit::Fit(BoxFit::Fill); + builder.node_mut(rect).fills = Paints::new([Paint::Image(image)]); + builder.build() +} + +#[test] +fn missing_image_resource_fails_checked_execution_before_canvas_mutation() { + let document = image_document(32.0); + let context = PaintCtx::new(None); + let product = frame::resolve_and_build(&document, &ResolveOptions::default(), &context) + .expect("resource readiness is an execution input"); + let mut surface = surfaces::raster_n32_premul((32, 32)).unwrap(); + surface.canvas().clear(Color::MAGENTA); + let before = anchor_engine::paint::read_pixels(&mut surface, 32, 32); + + let FrameExecutionError::Image(error) = product + .execute(surface.canvas(), &Affine::IDENTITY, &context) + .expect_err("missing image must fail checked execution") + else { + panic!("expected image preflight failure"); + }; + assert_eq!(error.context, PaintUseContext::Fill); + assert_eq!(error.rid, "asset"); + assert_eq!(error.reason, ImagePreflightReason::MissingResource); + assert_eq!( + anchor_engine::paint::read_pixels(&mut surface, 32, 32), + before + ); +} + +#[test] +fn image_total_matrix_failure_is_view_dependent_and_rescuable() { + const CHECKER: &[u8] = include_bytes!("../../../fixtures/images/checker.png"); + let document = image_document(1e-40); + let mut context = PaintCtx::new(None); + context.insert_encoded("asset", CHECKER).unwrap(); + let product = frame::resolve_and_build(&document, &ResolveOptions::default(), &context) + .expect("tiny image geometry remains valid frame data"); + let mut surface = surfaces::raster_n32_premul((32, 32)).unwrap(); + surface.canvas().clear(Color::MAGENTA); + + let FrameExecutionError::Image(error) = product + .execute(surface.canvas(), &Affine::IDENTITY, &context) + .expect_err("identity view leaves the image total matrix unrepresentable") + else { + panic!("expected image matrix failure"); + }; + assert_eq!(error.reason, ImagePreflightReason::TotalMatrixNotInvertible); + + product + .execute(surface.canvas(), &Affine::scale(1e30, 1e30), &context) + .expect("the final view rescues total-matrix representability"); +} + +#[test] +fn collapsed_image_geometry_remains_a_valid_blank_execution() { + const CHECKER: &[u8] = include_bytes!("../../../fixtures/images/checker.png"); + let document = image_document(32.0); + let mut context = PaintCtx::new(None); + context.insert_encoded("asset", CHECKER).unwrap(); + let product = frame::resolve_and_build(&document, &ResolveOptions::default(), &context) + .expect("ordinary image frame"); + let mut surface = surfaces::raster_n32_premul((32, 32)).unwrap(); + surface.canvas().clear(Color::MAGENTA); + let before = anchor_engine::paint::read_pixels(&mut surface, 32, 32); + product + .execute(surface.canvas(), &Affine::scale(0.0, 0.0), &context) + .expect("a singular geometry CTM is collapsed coverage, not an image failure"); + assert_eq!( + anchor_engine::paint::read_pixels(&mut surface, 32, 32), + before, + "collapsed image geometry must emit no pixels" + ); +} + +#[test] +fn empty_text_stroke_does_not_evaluate_its_paints() { + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Auto), + Payload::Text { + content: String::new(), + font_size: 16.0, + }, + ); + let mut stroke = Stroke::default_for(&Payload::Text { + content: String::new(), + font_size: 16.0, + }) + .unwrap(); + stroke.width = StrokeWidth::Uniform(2.0); + stroke.paints = Paints::new([gradient(1e-20)]); + builder.node_mut(text).strokes.push(stroke); + let document = builder.build(); + frame::resolve_and_build(&document, &ResolveOptions::default(), &PaintCtx::new(None)) + .expect("empty text has no glyph geometry on which to evaluate stroke paint"); +} diff --git a/model-v2/engine/tests/grida_xml.rs b/model-v2/engine/tests/grida_xml.rs index 233aaa9dfd..3ef4750ec4 100644 --- a/model-v2/engine/tests/grida_xml.rs +++ b/model-v2/engine/tests/grida_xml.rs @@ -4,7 +4,7 @@ mod support; -use anchor_engine::drawlist::{build_glyphless, Item, ItemKind}; +use anchor_engine::drawlist::{build_glyphless_unchecked, Item, ItemKind}; use anchor_engine::paint::PaintCtx; use anchor_lab::grida_xml; use anchor_lab::math::Affine; @@ -42,7 +42,7 @@ fn draft0_nested_rects_materialize_in_the_drawlist() { let resolved = resolve(&doc, &options()); // This structural fixture has no text; the glyphless lab projection is the // explicit pure-stage subject. The following test covers the frame path. - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); // The structural envelope and implicit document root emit no ink. // The authored container and nested rectangles emit only their authored diff --git a/model-v2/engine/tests/grida_xml_examples.rs b/model-v2/engine/tests/grida_xml_examples.rs index 52ab999d12..ca157561db 100644 --- a/model-v2/engine/tests/grida_xml_examples.rs +++ b/model-v2/engine/tests/grida_xml_examples.rs @@ -186,17 +186,20 @@ fn checked_in_examples_resolve_resources_and_render() { viewport: (width as f32, height as f32), ..Default::default() }; - let (resolved, drawlist, _) = - frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, &ctx); + let (product, _) = frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, &ctx) + .expect("valid checked-in example frame"); assert!( - !resolved.reports.iter().any(|report| matches!( + !product.resolved().reports.iter().any(|report| matches!( report, Report::IgnoredByRule { .. } | Report::ErrorByRule { .. } )), "{name}: unresolved intent: {:?}", - resolved.reports + product.resolved().reports + ); + assert!( + !product.drawlist().items.is_empty(), + "{name}: empty drawlist" ); - assert!(!drawlist.items.is_empty(), "{name}: empty drawlist"); assert_eq!( surface.canvas().save_count(), 1, diff --git a/model-v2/engine/tests/grida_xml_slots.rs b/model-v2/engine/tests/grida_xml_slots.rs index 0d13d59252..695cfadb9f 100644 --- a/model-v2/engine/tests/grida_xml_slots.rs +++ b/model-v2/engine/tests/grida_xml_slots.rs @@ -7,7 +7,7 @@ #[allow(dead_code)] mod support; -use anchor_engine::drawlist::build_glyphless; +use anchor_engine::drawlist::build_glyphless_unchecked; use anchor_engine::frame; use anchor_engine::paint::PaintCtx; use anchor_lab::grida_xml_source::{self, SourceProvider, SourceSnapshot}; @@ -189,7 +189,7 @@ fn version3_slots_materialize_before_the_component_blind_frame() { // The display list is already slot-blind. Its ordinary painter order is // definition header, caller roots in assignment order, then definition // footer, followed by the empty instance's header and footer. - let list = build_glyphless(&output.document, &resolved); + let list = build_glyphless_unchecked(&output.document, &resolved); assert_eq!( list.items.iter().map(|item| item.node).collect::>(), [ @@ -205,14 +205,15 @@ fn version3_slots_materialize_before_the_component_blind_frame() { let paint_ctx = PaintCtx::new(None); let mut surface = surfaces::raster_n32_premul((WIDTH, HEIGHT)).expect("raster surface"); surface.canvas().clear(Color::BLACK); - let (_, frame_list, _) = frame::render( + let (product, _) = frame::render( surface.canvas(), &output.document, &options(), &Affine::IDENTITY, &paint_ctx, - ); - assert_eq!(frame_list, list); + ) + .expect("valid slot-program frame"); + assert_eq!(product.drawlist(), &list); assert_eq!(surface.canvas().save_count(), 1, "canvas state leaked"); let image = support::RgbaImage::from_image(&surface.image_snapshot()); diff --git a/model-v2/engine/tests/grida_xml_social_feed.rs b/model-v2/engine/tests/grida_xml_social_feed.rs index 8c40a56d14..89f80f325f 100644 --- a/model-v2/engine/tests/grida_xml_social_feed.rs +++ b/model-v2/engine/tests/grida_xml_social_feed.rs @@ -154,16 +154,17 @@ fn desktop_social_feed_keeps_shared_shells_caller_media_and_loaded_resources() { .expect("checked-in social media asset decodes"); assert!(paint_ctx.contains_image(&resource.runtime_rid)); } - let (_, drawlist, _) = frame::render( + let (product, _) = frame::render( surface.canvas(), &output.document, &options(), &Affine::IDENTITY, &paint_ctx, - ); - assert!(!drawlist.items.is_empty()); + ) + .expect("valid social-feed frame"); + assert!(!product.drawlist().items.is_empty()); let mut drawn_image_rids = BTreeSet::new(); - for item in &drawlist.items { + for item in &product.drawlist().items { let ItemKind::RectFill { paints, .. } = &item.kind else { continue; }; diff --git a/model-v2/engine/tests/grida_xml_source.rs b/model-v2/engine/tests/grida_xml_source.rs index 934603fd73..0a4763c8ca 100644 --- a/model-v2/engine/tests/grida_xml_source.rs +++ b/model-v2/engine/tests/grida_xml_source.rs @@ -7,19 +7,25 @@ #[allow(dead_code)] mod support; -use anchor_engine::drawlist::{build_glyphless, Item, ItemKind}; +use anchor_engine::drawlist::{build_glyphless_unchecked, Item, ItemKind}; use anchor_engine::frame; use anchor_engine::paint::PaintCtx; -use anchor_lab::grida_xml_source::{self, SourceProvider, SourceSnapshot, ValueSelection}; +use anchor_lab::grida_xml_source::{ + self, AuthoredMemberId, SourceProvider, SourceSnapshot, ValueSelection, +}; use anchor_lab::math::Affine; use anchor_lab::model::{ Color as ModelColor, CornerSmoothing, Paints, Payload, RectangularCornerRadius, }; +use anchor_lab::properties::{ + PropertyKey, PropertyTarget, PropertyValue, PropertyValues, ValueView, +}; use anchor_lab::resolve::{resolve, ResolveOptions}; use skia_safe::{surfaces, Color}; const ENTRY_SOURCE: &str = include_str!("../rig/fixtures/component-program/entry.grida.xml"); const COMPONENT_SOURCE: &str = include_str!("../rig/fixtures/component-program/swatch.grida.xml"); +const DURABLE_SOURCE: &str = include_str!("../rig/fixtures/durable-addressing.grida.xml"); const ENTRY_ID: &str = "fixture:component-program/entry"; const COMPONENT_ID: &str = "fixture:component-program/swatch"; const SOURCE_BASE: &str = "fixture:/component-program/"; @@ -146,7 +152,7 @@ fn version2_component_program_materializes_before_the_component_blind_frame() { let resolved = resolve(&output.document, &options()); assert_eq!(resolved.xywh(instances[0]), (8.0, 8.0, 40.0, 24.0)); assert_eq!(resolved.xywh(instances[1]), (40.0, 8.0, 40.0, 24.0)); - let list = build_glyphless(&output.document, &resolved); + let list = build_glyphless_unchecked(&output.document, &resolved); assert_eq!(list.items.len(), 3); assert_eq!(list.items[0].node, scene); assert_rect_fill( @@ -167,14 +173,15 @@ fn version2_component_program_materializes_before_the_component_blind_frame() { let paint_ctx = PaintCtx::new(None); let mut surface = surfaces::raster_n32_premul((WIDTH, HEIGHT)).expect("raster surface"); surface.canvas().clear(Color::BLACK); - let (_, frame_list, _) = frame::render( + let (product, _) = frame::render( surface.canvas(), &output.document, &options(), &Affine::IDENTITY, &paint_ctx, - ); - assert_eq!(frame_list, list); + ) + .expect("valid source-program frame"); + assert_eq!(product.drawlist(), &list); assert_eq!(surface.canvas().save_count(), 1, "canvas state leaked"); let image = support::RgbaImage::from_image(&surface.image_snapshot()); @@ -184,3 +191,79 @@ fn version2_component_program_materializes_before_the_component_blind_frame() { assert_eq!(image.at(72, 20), [0, 0, 255, 255]); assert_eq!(image.at(44, 36), [255, 255, 255, 255]); } + +#[test] +fn durable_fixture_compiles_one_authored_occurrence_to_an_effective_engine_target() { + let mut sources = FixtureSources::default(); + let output = grida_xml_source::materialize( + SourceSnapshot::new("fixture:durable-addressing", "fixture:/", DURABLE_SOURCE), + &mut sources, + ) + .expect("Version 4 durable-addressing fixture materializes"); + assert_eq!( + sources.requests, 0, + "the fixture is one self-contained unit" + ); + assert_eq!( + output.addresses().len(), + output.document.len() - 1, + "every ordinary node except the implicit root has one address" + ); + for id in 0..output.document.capacity() as u32 { + let Some(key) = output.document.key_of(id) else { + continue; + }; + if id == output.document.root { + assert!(output.address_for_node(key).is_err()); + } else { + let address = output.address_for_node(key).unwrap(); + assert_eq!(output.node_for_address(address).unwrap(), key); + } + } + + let (address, avatar) = output + .addresses() + .find(|(address, _)| { + address.member.id == AuthoredMemberId::Id("avatar".into()) + && address + .use_path + .iter() + .any(|occurrence| occurrence.id == "first-card") + }) + .map(|(address, node)| (address.clone(), node)) + .expect("first profile-card avatar occurrence"); + assert_eq!(output.node_for_address(&address).unwrap(), avatar); + + // Source addressing ends here. The engine receives only the ordinary + // arena-scoped node key plus one closed semantic property key. + let target = PropertyTarget::new(avatar, PropertyKey::Fills); + let values = PropertyValues::new( + &output.document, + [( + target, + PropertyValue::Paints(Paints::solid("#DC2626".into())), + )], + ) + .unwrap(); + let view = ValueView::new(&output.document, &values).unwrap(); + let product = frame::resolve_and_build_view( + &view, + &ResolveOptions { + viewport: (352.0, 224.0), + ..Default::default() + }, + &PaintCtx::new(None), + ) + .expect("valid effective-value frame"); + let avatar_fill = product + .drawlist() + .items + .iter() + .find(|item| item.node == avatar.id()) + .expect("addressed avatar emits ink"); + assert!(matches!( + &avatar_fill.kind, + ItemKind::OvalFill { paints, .. } + if *paints == Paints::solid("#DC2626".into()) + )); +} diff --git a/model-v2/engine/tests/ident.rs b/model-v2/engine/tests/ident.rs index a21f56f3ab..c51d7be3e4 100644 --- a/model-v2/engine/tests/ident.rs +++ b/model-v2/engine/tests/ident.rs @@ -1,11 +1,12 @@ -//! ENG-2.3 · key_of mints a generation-stamped cache key from the arena. -//! A tombstoned slot's key advances, so no future occupant can alias it. +//! ENG-2.3 · the engine reuses the model's arena-scoped generational key. +//! Dead slots mint nothing; deletion and document replacement invalidate old +//! runtime identities. -use anchor_engine::ident::{key_of, Key}; +use anchor_engine::ident::key_of; use anchor_lab::model::*; #[test] -fn key_of_reads_generation() { +fn key_of_mints_only_live_model_keys() { let mut b = DocBuilder::new(); let c = b.add( 0, @@ -16,8 +17,34 @@ fn key_of_reads_generation() { ); let mut doc = b.build(); - assert_eq!(key_of(&doc, c), Key { id: c, gen: 0 }); + let key = key_of(&doc, c).expect("live key"); + assert_eq!(key.id(), c); + assert_eq!(key.generation(), 0); + assert!(doc.contains_key(key)); doc.remove_subtree(c); - assert_eq!(key_of(&doc, c), Key { id: c, gen: 1 }); + assert_eq!(key_of(&doc, c), None); + assert!(!doc.contains_key(key)); +} + +#[test] +fn engine_keys_cannot_cross_document_incarnations() { + let build = || { + let mut builder = DocBuilder::new(); + let node = builder.add( + 0, + Header::new(SizeIntent::Fixed(10.0), SizeIntent::Fixed(10.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + (builder.build(), node) + }; + let (document, node) = build(); + let key = key_of(&document, node).unwrap(); + let (other, other_node) = build(); + + assert_eq!(node, other_node, "the slot alone deliberately aliases"); + assert!(!other.contains_key(key)); + assert_ne!(key.arena(), key_of(&other, other_node).unwrap().arena()); } diff --git a/model-v2/engine/tests/paints.rs b/model-v2/engine/tests/paints.rs index 4d85ab4dd4..192133c707 100644 --- a/model-v2/engine/tests/paints.rs +++ b/model-v2/engine/tests/paints.rs @@ -136,6 +136,32 @@ fn gradient_transform_is_composed_in_unit_space_before_box_scale() { } } +#[test] +fn linear_gradient_just_above_backend_degeneracy_threshold_remains_a_ramp() { + // Skia degenerates lengths <= 2^-15 before applying the local matrix. + // This 2^-14 ramp remains valid, then a 2^14 unit-space transform expands + // it across the full paint box so stable interior pixels can probe it. + let source = r##" + + + + + + + +"##; + let (image, _) = render(source, 100, 20, &PaintCtx::new(None)); + let left = image.at(5, 10); + let middle = image.at(50, 10); + let right = image.at(95, 10); + assert!(left[0] > 220 && left[2] < 35, "left={left:?}"); + assert!( + (100..=155).contains(&middle[0]) && (100..=155).contains(&middle[2]), + "middle={middle:?}" + ); + assert!(right[2] > 220 && right[0] < 35, "right={right:?}"); +} + #[test] fn linear_gradient_tile_modes_have_distinct_outside_domain_behavior() { let source = r##" diff --git a/model-v2/engine/tests/paths.rs b/model-v2/engine/tests/paths.rs index 17be86fa10..cf2c168346 100644 --- a/model-v2/engine/tests/paths.rs +++ b/model-v2/engine/tests/paths.rs @@ -29,7 +29,7 @@ fn options() -> ResolveOptions { fn one_path_artifact_drives_fill_and_repeated_strokes() { let document = grida_xml::parse(SOURCE).expect("unit path fixture parses"); let resolved = resolve(&document, &options()); - let list = anchor_engine::drawlist::build_glyphless(&document, &resolved); + let list = anchor_engine::drawlist::build_glyphless_unchecked(&document, &resolved); assert_eq!(list.items.len(), 4, "container, fill, then two strokes"); let ItemKind::PathFill { @@ -106,8 +106,8 @@ fn equivalent_path_spelling_is_not_visual_damage() { let result = damage::diff(&before_resolved, &after_resolved); assert!(result.is_empty()); assert_eq!( - anchor_engine::drawlist::build_glyphless(&before, &before_resolved), - anchor_engine::drawlist::build_glyphless(&after, &after_resolved), + anchor_engine::drawlist::build_glyphless_unchecked(&before, &before_resolved), + anchor_engine::drawlist::build_glyphless_unchecked(&after, &after_resolved), "equivalent source spelling must not invalidate the visual display list" ); } @@ -123,7 +123,7 @@ fn path_children_paint_between_the_fill_and_parent_strokes() { ..Default::default() }, ); - let list = anchor_engine::drawlist::build_glyphless(&document, &resolved); + let list = anchor_engine::drawlist::build_glyphless_unchecked(&document, &resolved); assert!(matches!(list.items[0].kind, ItemKind::PathFill { .. })); assert!(matches!(list.items[1].kind, ItemKind::RectFill { .. })); diff --git a/model-v2/engine/tests/query.rs b/model-v2/engine/tests/query.rs index cfd4812064..0b2bcd2699 100644 --- a/model-v2/engine/tests/query.rs +++ b/model-v2/engine/tests/query.rs @@ -2,10 +2,13 @@ //! is selected: `hit_point` equals `anchor_lab::pick::pick` at every point of //! a sweep (the walk is the oracle), and `nodes_in_rect` finds AABB overlaps. -use anchor_engine::query::Query; +use anchor_engine::{frame, paint::PaintCtx, query::Query}; use anchor_lab::math::RectF; use anchor_lab::model::*; use anchor_lab::pick::pick; +use anchor_lab::properties::{ + PropertyKey, PropertyTarget, PropertyValue, PropertyValues, ValueView, +}; use anchor_lab::resolve::{resolve, ResolveOptions, RotationInFlow}; fn opts() -> ResolveOptions { @@ -58,7 +61,7 @@ fn scene() -> (Document, NodeId, NodeId, NodeId) { fn hit_point_matches_pick_over_grid() { let (doc, ..) = scene(); let r = resolve(&doc, &opts()); - let q = Query::new(&doc, &r); + let q = Query::new(&r); let mut gx = 0.0; while gx <= 500.0 { @@ -66,7 +69,7 @@ fn hit_point_matches_pick_over_grid() { while gy <= 500.0 { assert_eq!( q.hit_point(gx, gy), - pick(&doc, &r, gx, gy), + pick(&r, gx, gy), "hit_point must equal pick at ({gx},{gy})" ); gy += 15.0; @@ -79,7 +82,7 @@ fn hit_point_matches_pick_over_grid() { fn nodes_in_rect_selects_overlaps() { let (doc, s1, ..) = scene(); let r = resolve(&doc, &opts()); - let q = Query::new(&doc, &r); + let q = Query::new(&r); let near = q.nodes_in_rect(RectF { x: 45.0, @@ -97,3 +100,60 @@ fn nodes_in_rect_selects_overlaps() { }); assert!(!far.contains(&s1), "rect far from s1 should not include s1"); } + +#[test] +fn frame_query_owns_effective_clip_and_traversal_state() { + let mut builder = DocBuilder::new(); + let mut clip_header = Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(80.0)); + clip_header.x = AxisBinding::start(10.0); + clip_header.y = AxisBinding::start(10.0); + let clip = builder.add( + 0, + clip_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: true, + }, + ); + let child = builder.add( + clip, + Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(80.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let mut document = builder.build(); + let values = PropertyValues::new( + &document, + [( + PropertyTarget::new(document.key_of(clip).unwrap(), PropertyKey::CornerRadius), + PropertyValue::CornerRadius(RectangularCornerRadius::circular(30.0)), + )], + ) + .unwrap(); + let view = ValueView::new(&document, &values).unwrap(); + let context = PaintCtx::new(None); + let product = frame::resolve_and_build_view(&view, &opts(), &context) + .expect("valid effective-value frame"); + + // Mutate every authored fact the old query seam reread. The retained frame + // remains self-contained: no API accepts this newer document or another + // ValueView alongside its resolved columns. + document.get_mut(clip).corner_radius = RectangularCornerRadius::default(); + if let Payload::Frame { clips_content, .. } = &mut document.get_mut(clip).payload { + *clips_content = false; + } + document.get_mut(clip).children.clear(); + + let query = product.query(); + assert_eq!( + query.hit_point(11.0, 11.0), + Some(clip), + "the captured rounded clip still excludes its child" + ); + assert_eq!( + query.hit_point(50.0, 50.0), + Some(child), + "the captured traversal still reaches the child" + ); +} diff --git a/model-v2/engine/tests/support/mod.rs b/model-v2/engine/tests/support/mod.rs index 3ce9c5be49..8e1ca7c3e9 100644 --- a/model-v2/engine/tests/support/mod.rs +++ b/model-v2/engine/tests/support/mod.rs @@ -80,12 +80,14 @@ pub fn render_xml_on( viewport: (width as f32, height as f32), ..Default::default() }; - let (_, list, _) = frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, ctx); + let (product, _) = frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, ctx) + .expect("valid fixture frame"); assert_eq!( surface.canvas().save_count(), 1, "display-list scopes leaked canvas state" ); let image = RgbaImage::from_image(&surface.image_snapshot()); - (image, list) + let (_, drawlist, _) = product.into_parts(); + (image, drawlist) } diff --git a/model-v2/engine/tests/text.rs b/model-v2/engine/tests/text.rs index 9562588750..b23154e33c 100644 --- a/model-v2/engine/tests/text.rs +++ b/model-v2/engine/tests/text.rs @@ -4,7 +4,7 @@ mod support; -use anchor_engine::drawlist::{build_glyphless, DrawList, ItemKind}; +use anchor_engine::drawlist::{build_glyphless_unchecked, DrawList, ItemKind}; use anchor_engine::frame; use anchor_engine::paint::PaintCtx; use anchor_lab::grida_xml; @@ -52,13 +52,15 @@ fn render_document_full( viewport: (width as f32, height as f32), ..Default::default() }; - let (resolved, list, _) = frame::render( + let (product, _) = frame::render( surface.canvas(), document, &options, &Affine::IDENTITY, &paint_ctx(), - ); + ) + .expect("valid text frame"); + let (resolved, list, _) = product.into_parts(); ( RgbaImage::from_image(&surface.image_snapshot()), resolved, @@ -102,7 +104,7 @@ fn drawlist_materializes_shared_wrapping_and_explicit_empty_lines() { let source = "aa bb cc\nx\n"; let doc = grida_xml::parse(source).unwrap(); let resolved = resolve(&doc, &ResolveOptions::default()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); let lines = list .items .iter() @@ -129,7 +131,7 @@ fn fill_and_repeated_strokes_share_one_text_line_topology() { let source = r##"aa bb cc"##; let doc = grida_xml::parse(source).unwrap(); let resolved = resolve(&doc, &ResolveOptions::default()); - let list = build_glyphless(&doc, &resolved); + let list = build_glyphless_unchecked(&doc, &resolved); let topologies = list .items .iter() @@ -220,7 +222,7 @@ fn attributed_drawlist_preserves_run_metrics_style_and_fill_fallback() { 80.0, ); let resolved = resolve(&document, &ResolveOptions::default()); - let list = build_glyphless(&document, &resolved); + let list = build_glyphless_unchecked(&document, &resolved); let (layout, paints, paint_w, paint_h) = list .items .iter() @@ -293,7 +295,7 @@ fn attributed_wrap_and_auto_height_share_run_aware_line_metrics() { ); let resolved = resolve(&document, &ResolveOptions::default()); assert_eq!(resolved.box_of(text_id).h, 36.0); - let list = build_glyphless(&document, &resolved); + let list = build_glyphless_unchecked(&document, &resolved); let lines = list .items .iter() diff --git a/model-v2/engine/tests/text_layout.rs b/model-v2/engine/tests/text_layout.rs index 2ac22853d7..a2f9798031 100644 --- a/model-v2/engine/tests/text_layout.rs +++ b/model-v2/engine/tests/text_layout.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use anchor_engine::drawlist::{DrawList, ItemKind}; use anchor_engine::frame; use anchor_engine::oracle::TEXT_SKPARAGRAPH; -use anchor_engine::paint::{raster_to_bytes, PaintCtx}; +use anchor_engine::paint::{raster_to_bytes_unchecked, PaintCtx}; use anchor_lab::math::Affine; use anchor_lab::model::{ AttributedString, Color, DocBuilder, Document, Header, Paints, Payload, SizeIntent, Stroke, @@ -58,18 +58,20 @@ fn render_with_context(document: &Document, context: &PaintCtx) -> (Resolved, Dr viewport: (640.0, 480.0), ..Default::default() }; - let (resolved, list, _) = frame::render( + let (product, _) = frame::render( surface.canvas(), document, &options, &Affine::IDENTITY, context, - ); + ) + .expect("valid text-layout frame"); assert_eq!( surface.canvas().save_count(), 1, "glyph replay must leave the canvas scope balanced" ); + let (resolved, list, _) = product.into_parts(); (resolved, list) } @@ -372,9 +374,9 @@ fn shaped_drawlist_retains_exact_fonts_after_its_shaping_context_is_gone() { let (_, list) = render(&document); let fontless_context = PaintCtx::new(None); - let first = raster_to_bytes(&list, &Affine::IDENTITY, 640, 480, &fontless_context); + let first = raster_to_bytes_unchecked(&list, &Affine::IDENTITY, 640, 480, &fontless_context); let unrelated_context = PaintCtx::new(Some(inter_typeface())); - let second = raster_to_bytes(&list, &Affine::IDENTITY, 640, 480, &unrelated_context); + let second = raster_to_bytes_unchecked(&list, &Affine::IDENTITY, 640, 480, &unrelated_context); assert_eq!(first, second, "paint contexts cannot reinterpret font keys"); assert!(first.chunks_exact(4).any(|pixel| pixel[0] < 100)); @@ -389,13 +391,16 @@ fn public_non_rasterizing_stage_returns_replayable_shaped_text() { ..Default::default() }; - let (resolved, list) = frame::resolve_and_build(&document, &options, &shaping_context); + let product = frame::resolve_and_build(&document, &options, &shaping_context) + .expect("valid text-layout frame"); + let (resolved, list, _) = product.into_parts(); let layout = resolved.text_layout_of(text_id); assert_eq!(layout.oracle, TEXT_SKPARAGRAPH); assert!(!layout.glyph_runs.is_empty()); let fontless_replay_context = PaintCtx::new(None); - let pixels = raster_to_bytes(&list, &Affine::IDENTITY, 640, 480, &fontless_replay_context); + let pixels = + raster_to_bytes_unchecked(&list, &Affine::IDENTITY, 640, 480, &fontless_replay_context); assert!( pixels.chunks_exact(4).any(|pixel| pixel[0] < 100), "the returned drawlist must own every font needed for later replay" diff --git a/model-v2/engine/tests/values.rs b/model-v2/engine/tests/values.rs new file mode 100644 index 0000000000..ee1204eab3 --- /dev/null +++ b/model-v2/engine/tests/values.rs @@ -0,0 +1,542 @@ +//! Pre-animation effective-value contract: one immutable typed view feeds +//! resolve, drawlist, query, damage, and raster. There is deliberately no +//! clock, timeline, track, keyframe, or playback state in these tests. + +use anchor_engine::damage::{diff_frame, Damage}; +use anchor_engine::drawlist::{ + build_glyphless_unchecked, build_glyphless_view_unchecked, ItemKind, +}; +use anchor_engine::frame; +use anchor_engine::paint::{read_pixels, PaintCtx}; +use anchor_engine::query::Query; +use anchor_engine::replay::resolved_bits_eq; +use anchor_lab::math::Affine; +use anchor_lab::model::*; +use anchor_lab::properties::{ + PropertyKey, PropertyTarget, PropertyValue, PropertyValues, ValueView, +}; +use anchor_lab::resolve::{resolve, resolve_view, ResolveOptions}; +use skia_safe::{surfaces, Color as SkColor}; + +const W: i32 = 180; +const H: i32 = 120; + +fn options() -> ResolveOptions { + ResolveOptions { + viewport: (W as f32, H as f32), + ..Default::default() + } +} + +fn target(document: &Document, node: NodeId, property: PropertyKey) -> PropertyTarget { + PropertyTarget::new(document.key_of(node).expect("live node"), property) +} + +fn value_set( + document: &Document, + entries: impl IntoIterator, +) -> PropertyValues { + PropertyValues::new( + document, + entries + .into_iter() + .map(|(node, key, value)| (target(document, node, key), value)), + ) + .expect("valid effective values") +} + +fn simple_scene() -> (Document, NodeId) { + let mut builder = DocBuilder::new(); + let mut header = Header::new(SizeIntent::Fixed(48.0), SizeIntent::Fixed(36.0)); + header.x = AxisBinding::start(24.0); + header.y = AxisBinding::start(28.0); + let rect = builder.add( + 0, + header, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(rect).fills = Paints::solid("#2563EB".into()); + (builder.build(), rect) +} + +fn render_view(document: &Document, values: &PropertyValues) -> Vec { + let view = ValueView::new(document, values).unwrap(); + let context = PaintCtx::new(None); + let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); + surface.canvas().clear(SkColor::WHITE); + frame::render_view( + surface.canvas(), + &view, + &options(), + &Affine::IDENTITY, + &context, + ) + .expect("valid effective-value frame"); + read_pixels(&mut surface, W, H) +} + +fn assert_changed_node(damage: &Damage, node: NodeId) { + assert_eq!(damage.changed, vec![node]); + assert!(damage.union_world.is_some(), "visual damage needs coverage"); +} + +#[test] +fn empty_values_are_the_exact_static_pipeline() { + let (document, rect) = simple_scene(); + let values = PropertyValues::default(); + let value_view = ValueView::new(&document, &values).unwrap(); + let context = PaintCtx::new(None); + + let static_resolved = resolve(&document, &options()); + let viewed_resolved = resolve_view(&value_view, &options()); + assert!(resolved_bits_eq(&static_resolved, &viewed_resolved)); + + let static_list = build_glyphless_unchecked(&document, &static_resolved); + let viewed_list = build_glyphless_view_unchecked(&value_view, &viewed_resolved); + assert_eq!(static_list, viewed_list); + + let static_query = Query::new(&static_resolved); + let viewed_query = Query::new(&viewed_resolved); + for point in [(0.0, 0.0), (30.0, 35.0), (70.0, 64.0), (100.0, 100.0)] { + assert_eq!( + static_query.hit_point(point.0, point.1), + viewed_query.hit_point(point.0, point.1) + ); + } + assert_eq!(viewed_query.hit_point(30.0, 35.0), Some(rect)); + + let static_product = + frame::resolve_and_build(&document, &options(), &context).expect("valid static frame"); + let viewed_product = frame::resolve_and_build_view(&value_view, &options(), &context) + .expect("valid effective-value frame"); + assert!(resolved_bits_eq( + static_product.resolved(), + viewed_product.resolved() + )); + assert_eq!(static_product.drawlist(), viewed_product.drawlist()); + assert!(diff_frame(&static_product, &viewed_product).is_empty()); + + // The two public raster entries must not merely look close: their bytes + // are the same static computation. + let expected = render_view(&document, &values); + let mut surface = surfaces::raster_n32_premul((W, H)).unwrap(); + surface.canvas().clear(SkColor::WHITE); + frame::render( + surface.canvas(), + &document, + &options(), + &Affine::IDENTITY, + &PaintCtx::new(None), + ) + .expect("valid static frame"); + assert_eq!(read_pixels(&mut surface, W, H), expected); +} + +#[test] +fn layout_rotation_and_query_observe_one_effective_view() { + let mut builder = DocBuilder::new(); + let mut frame_header = Header::new(SizeIntent::Fixed(120.0), SizeIntent::Fixed(50.0)); + frame_header.x = AxisBinding::start(10.0); + frame_header.y = AxisBinding::start(20.0); + let parent = builder.add( + 0, + frame_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let first = builder.add( + parent, + Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(20.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let second = builder.add( + parent, + Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(20.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(first).fills = Paints::solid("#111827".into()); + builder.node_mut(second).fills = Paints::solid("#7C3AED".into()); + let document = builder.build(); + + let layout = LayoutBehavior { + mode: LayoutMode::Flex, + direction: Direction::Row, + gap_main: 12.0, + ..Default::default() + }; + let values = value_set( + &document, + [ + (parent, PropertyKey::Layout, PropertyValue::Layout(layout)), + (second, PropertyKey::Rotation, PropertyValue::Number(30.0)), + ], + ); + let view = ValueView::new(&document, &values).unwrap(); + let resolved = resolve_view(&view, &options()); + + assert_eq!(resolved.xywh(first), (0.0, 0.0, 20.0, 20.0)); + assert_eq!(resolved.xywh(second), (32.0, 0.0, 20.0, 20.0)); + assert_ne!(resolved.world_of(second), Affine::translate(42.0, 20.0)); + + let query = Query::new(&resolved); + assert_eq!(query.hit_point(20.0, 30.0), Some(first)); + assert_eq!(query.hit_point(52.0, 30.0), Some(second)); + + let list = build_glyphless_view_unchecked(&view, &resolved); + let second_item = list.items.iter().find(|item| item.node == second).unwrap(); + assert_eq!(second_item.world, resolved.world_of(second)); +} + +#[test] +fn effective_smooth_clip_query_matches_raster_coverage() { + let mut builder = DocBuilder::new(); + let mut clip_header = Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(80.0)); + clip_header.x = AxisBinding::start(20.0); + clip_header.y = AxisBinding::start(20.0); + let clip = builder.add( + 0, + clip_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: true, + }, + ); + let child = builder.add( + clip, + Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(80.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(child).fills = Paints::solid("#22C55E".into()); + let document = builder.build(); + let values = value_set( + &document, + [ + ( + clip, + PropertyKey::CornerRadius, + PropertyValue::CornerRadius(RectangularCornerRadius::circular(28.0)), + ), + ( + clip, + PropertyKey::CornerSmoothing, + PropertyValue::Number(0.7), + ), + ], + ); + let view = ValueView::new(&document, &values).unwrap(); + let context = PaintCtx::new(None); + let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); + surface.canvas().clear(SkColor::WHITE); + let (product, _) = frame::render_view( + surface.canvas(), + &view, + &options(), + &Affine::IDENTITY, + &context, + ) + .expect("valid effective-value frame"); + let pixels = read_pixels(&mut surface, W, H); + let query = product.query(); + + let mut inside = 0; + let mut outside = 0; + for local_y in (2..78).step_by(4) { + for local_x in (2..78).step_by(4) { + let x = 20 + local_x; + let y = 20 + local_y; + let offset = ((y * W + x) * 4) as usize; + let pixel = &pixels[offset..offset + 4]; + let hit = query.hit_point(x as f32 + 0.5, y as f32 + 0.5); + if pixel == [34, 197, 94, 255] { + inside += 1; + assert_eq!(hit, Some(child), "visible child pixel at ({x}, {y})"); + } else if pixel == [255, 255, 255, 255] { + outside += 1; + assert_ne!(hit, Some(child), "clipped child pixel at ({x}, {y})"); + } + } + } + assert!(inside > 250, "probe must cover the clip interior"); + assert!(outside > 10, "probe must cover clipped smooth corners"); +} + +#[test] +fn effective_ordinary_elliptical_extreme_radii_query_matches_skia_raster() { + let mut builder = DocBuilder::new(); + let mut clip_header = Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(60.0)); + clip_header.x = AxisBinding::start(20.0); + clip_header.y = AxisBinding::start(20.0); + let clip = builder.add( + 0, + clip_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: true, + }, + ); + let child = builder.add( + clip, + Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(60.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(child).fills = Paints::solid("#22C55E".into()); + let document = builder.build(); + let extreme_ellipse = Radius { + rx: f32::MAX, + ry: f32::MAX / 2.0, + }; + let values = value_set( + &document, + [( + clip, + PropertyKey::CornerRadius, + PropertyValue::CornerRadius(RectangularCornerRadius::all(extreme_ellipse)), + )], + ); + let view = ValueView::new(&document, &values).unwrap(); + let context = PaintCtx::new(None); + let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); + surface.canvas().clear(SkColor::WHITE); + let (product, _) = frame::render_view( + surface.canvas(), + &view, + &options(), + &Affine::IDENTITY, + &context, + ) + .expect("valid effective-value frame"); + let pixels = read_pixels(&mut surface, W, H); + let query = product.query(); + + let mut inside = 0; + let mut outside = 0; + for local_y in (1..59).step_by(2) { + for local_x in (1..79).step_by(2) { + let x = 20 + local_x; + let y = 20 + local_y; + let offset = ((y * W + x) * 4) as usize; + let pixel = &pixels[offset..offset + 4]; + let hit = query.hit_point(x as f32 + 0.5, y as f32 + 0.5); + if pixel == [34, 197, 94, 255] { + inside += 1; + assert_eq!(hit, Some(child), "visible child pixel at ({x}, {y})"); + } else if pixel == [255, 255, 255, 255] { + outside += 1; + assert_ne!(hit, Some(child), "clipped child pixel at ({x}, {y})"); + } + } + } + assert!( + inside > 900, + "probe must cover the elliptical clip interior" + ); + assert!(outside > 100, "probe must cover clipped elliptical corners"); +} + +#[test] +fn zero_width_effective_clip_is_empty_for_query_and_raster() { + let mut builder = DocBuilder::new(); + let mut clip_header = Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)); + clip_header.x = AxisBinding::start(20.0); + clip_header.y = AxisBinding::start(20.0); + let clip = builder.add( + 0, + clip_header, + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: true, + }, + ); + let child = builder.add( + clip, + Header::new(SizeIntent::Fixed(30.0), SizeIntent::Fixed(30.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(child).fills = Paints::solid("#22C55E".into()); + let document = builder.build(); + let values = value_set( + &document, + [( + clip, + PropertyKey::Width, + PropertyValue::SizeIntent(SizeIntent::Fixed(0.0)), + )], + ); + let view = ValueView::new(&document, &values).unwrap(); + let context = PaintCtx::new(None); + let mut surface = surfaces::raster_n32_premul((W, H)).expect("raster surface"); + surface.canvas().clear(SkColor::WHITE); + let (product, _) = frame::render_view( + surface.canvas(), + &view, + &options(), + &Affine::IDENTITY, + &context, + ) + .expect("valid effective-value frame"); + let pixels = read_pixels(&mut surface, W, H); + let query = product.query(); + + let x = 21; + let y = 25; + let offset = ((y * W + x) * 4) as usize; + assert_eq!(&pixels[offset..offset + 4], [255, 255, 255, 255]); + assert_ne!( + query.hit_point(x as f32 + 0.5, y as f32 + 0.5), + Some(child), + "an empty clip cannot expose a descendant the raster hides" + ); +} + +#[test] +fn fills_and_opacity_produce_paint_damage_without_geometry_changes() { + let (document, rect) = simple_scene(); + let base_values = PropertyValues::default(); + let fill_values = value_set( + &document, + [( + rect, + PropertyKey::Fills, + PropertyValue::Paints(Paints::solid("#DC2626".into())), + )], + ); + let opacity_values = value_set( + &document, + [(rect, PropertyKey::Opacity, PropertyValue::Number(0.35))], + ); + + let base_view = ValueView::new(&document, &base_values).unwrap(); + let fill_view = ValueView::new(&document, &fill_values).unwrap(); + let opacity_view = ValueView::new(&document, &opacity_values).unwrap(); + let context = PaintCtx::new(None); + let base_product = + frame::resolve_and_build_view(&base_view, &options(), &context).expect("valid base frame"); + let fill_product = + frame::resolve_and_build_view(&fill_view, &options(), &context).expect("valid fill frame"); + let opacity_product = frame::resolve_and_build_view(&opacity_view, &options(), &context) + .expect("valid opacity frame"); + assert!(resolved_bits_eq( + base_product.resolved(), + fill_product.resolved() + )); + assert!(resolved_bits_eq( + base_product.resolved(), + opacity_product.resolved() + )); + + assert_changed_node(&diff_frame(&base_product, &fill_product), rect); + assert_changed_node(&diff_frame(&base_product, &opacity_product), rect); + assert!(matches!( + opacity_product + .drawlist() + .items + .first() + .map(|item| &item.kind), + Some(ItemKind::BeginOpacity { opacity }) if *opacity == 0.35 + )); + + assert_ne!( + render_view(&document, &base_values), + render_view(&document, &fill_values) + ); +} + +#[test] +fn inserting_a_parent_opacity_scope_does_not_spuriously_damage_unchanged_children() { + let mut builder = DocBuilder::new(); + let parent = builder.add( + 0, + Header::new(SizeIntent::Fixed(80.0), SizeIntent::Fixed(60.0)), + Payload::Frame { + layout: LayoutBehavior::default(), + clips_content: false, + }, + ); + let child = builder.add( + parent, + Header::new(SizeIntent::Fixed(30.0), SizeIntent::Fixed(20.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + builder.node_mut(child).fills = Paints::solid("#2563EB".into()); + let document = builder.build(); + let values = value_set( + &document, + [(parent, PropertyKey::Opacity, PropertyValue::Number(0.5))], + ); + let base_view = ValueView::base(&document); + let opacity_view = ValueView::new(&document, &values).unwrap(); + let context = PaintCtx::new(None); + let base_product = + frame::resolve_and_build_view(&base_view, &options(), &context).expect("valid base frame"); + let opacity_product = frame::resolve_and_build_view(&opacity_view, &options(), &context) + .expect("valid opacity frame"); + + let damage = diff_frame(&base_product, &opacity_product); + assert_eq!(damage.changed, vec![parent]); + assert!(!damage.changed.contains(&child)); + assert!(damage.union_world.is_some()); +} + +#[test] +fn strokes_change_bounds_and_active_prunes_every_consumer() { + let (document, rect) = simple_scene(); + let mut stroke = Stroke::default_for(&document.get(rect).payload).unwrap(); + stroke.paints = Paints::solid("#F59E0B".into()); + stroke.width = StrokeWidth::Uniform(10.0); + stroke.align = StrokeAlign::Outside; + let stroke_values = value_set( + &document, + [( + rect, + PropertyKey::Strokes, + PropertyValue::Strokes(vec![stroke]), + )], + ); + let hidden_values = value_set( + &document, + [(rect, PropertyKey::Active, PropertyValue::Boolean(false))], + ); + + let base_view = ValueView::base(&document); + let stroke_view = ValueView::new(&document, &stroke_values).unwrap(); + let hidden_view = ValueView::new(&document, &hidden_values).unwrap(); + let context = PaintCtx::new(None); + let base_product = + frame::resolve_and_build_view(&base_view, &options(), &context).expect("valid base frame"); + let stroke_product = frame::resolve_and_build_view(&stroke_view, &options(), &context) + .expect("valid stroke frame"); + assert!(stroke_product.resolved().aabb_of(rect).w > base_product.resolved().aabb_of(rect).w); + assert_changed_node(&diff_frame(&base_product, &stroke_product), rect); + assert!(stroke_product + .drawlist() + .items + .iter() + .any(|item| matches!(item.kind, ItemKind::RectStroke { .. }))); + + let hidden_product = frame::resolve_and_build_view(&hidden_view, &options(), &context) + .expect("valid hidden frame"); + assert!(hidden_product.resolved().world_opt(rect).is_none()); + assert!(hidden_product.drawlist().items.is_empty()); + assert_eq!( + hidden_product.query().hit_point(30.0, 35.0), + Some(document.root), + "only the viewport backdrop remains hittable" + ); + assert_changed_node(&diff_frame(&base_product, &hidden_product), rect); +} From e2307f6a579f9dc353a9f7edac901b6e9d17a786 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 16:41:30 +0900 Subject: [PATCH 28/33] test(model-v2): add effective-value visual proof --- model-v2/a/spike-canvas/shots/crosszero.png | Bin 29914 -> 30799 bytes model-v2/a/spike-canvas/shots/default.png | Bin 33268 -> 33852 bytes model-v2/a/spike-canvas/shots/rot45.png | Bin 30054 -> 30582 bytes model-v2/a/spike-canvas/shots/ungroup.png | Bin 26505 -> 27325 bytes model-v2/a/spike-canvas/src/paint.rs | 17 +- .../engine/src/bin/effective_values_demo.rs | 414 ++++++++++++++++++ 6 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 model-v2/engine/src/bin/effective_values_demo.rs diff --git a/model-v2/a/spike-canvas/shots/crosszero.png b/model-v2/a/spike-canvas/shots/crosszero.png index 1ea2a83e604380a70b56d04a15ceeff42509ee3e..f225e62ed780b317937ecf30b422e206db1d5d16 100644 GIT binary patch literal 30799 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 literal 29914 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 eSO2$$eT8rJ)-5ys@x@;+Y7TVTi=z(0^9{p}o=i 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 J@QTg-e*+|=uCV|B literal 33268 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 MDqO4J58Fro0=uZRXaE2J diff --git a/model-v2/a/spike-canvas/shots/rot45.png b/model-v2/a/spike-canvas/shots/rot45.png index f5a423d2aa972065a055398764241d140732406e..dbd6e6f39a3606a4cb6a58cae342a6d9d6c10a64 100644 GIT binary patch literal 30582 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±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 IdFlG!0EjzqO#lD@ literal 26505 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 diff --git a/model-v2/a/spike-canvas/src/paint.rs b/model-v2/a/spike-canvas/src/paint.rs index 5a41cf0ad8..abaac6597c 100644 --- a/model-v2/a/spike-canvas/src/paint.rs +++ b/model-v2/a/spike-canvas/src/paint.rs @@ -1,16 +1,19 @@ //! Typeface acquisition for the spike. The SCENE is painted by the engine -//! (`frame::resolve_and_build` followed by `anchor_engine::paint::execute`) — +//! (`frame::resolve_and_build` followed by checked `FrameProduct::execute`) — //! the spike no longer owns a scene painter. This module only supplies the //! resolved typeface that the engine executor and the HUD text share, as an //! engine [`PaintCtx`]. use anchor_engine::paint::PaintCtx; -/// The default paint context: the platform's legacy default typeface (the -/// same face the pre-engine painter used), or `None` — in which case text is -/// skipped, exactly as before. +/// The deterministic spike and shot-gate paint context. The bundled face +/// keeps text measurement, shaping, and antialiasing independent of the host +/// machine's default-font configuration. pub fn paint_ctx() -> PaintCtx { - PaintCtx::new( - skia_safe::FontMgr::new().legacy_make_typeface(None, skia_safe::FontStyle::default()), - ) + const INTER: &[u8] = + include_bytes!("../../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); + let typeface = skia_safe::FontMgr::new() + .new_from_data(INTER, None) + .expect("bundled Inter typeface"); + PaintCtx::new(Some(typeface)) } diff --git a/model-v2/engine/src/bin/effective_values_demo.rs b/model-v2/engine/src/bin/effective_values_demo.rs new file mode 100644 index 0000000000..0b9413d648 --- /dev/null +++ b/model-v2/engine/src/bin/effective_values_demo.rs @@ -0,0 +1,414 @@ +//! Visual proof of the pre-animation value seam. +//! +//! One Version 4 Grida XML source program is materialized once, then painted +//! twice: first from authored values and then from an immutable `ValueView`. +//! There is deliberately no clock, timeline, keyframe, or playback state. + +use anchor_engine::damage::diff_frame; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml_source::{ + self, AuthoredMemberId, MaterializedProgram, SourceProvider, SourceSnapshot, +}; +use anchor_lab::math::Affine; +use anchor_lab::model::{ + Alignment, Color, GradientStop, LinearGradientPaint, Paint, Paints, Payload, + RadialGradientPaint, RectangularCornerRadius, Stroke, StrokeAlign, StrokeWidth, +}; +use anchor_lab::properties::{ + PropertyKey, PropertyTarget, PropertyValue, PropertyValues, ValueView, +}; +use anchor_lab::resolve::ResolveOptions; +use skia_safe::{surfaces, EncodedImageFormat, Font, Paint as SkPaint}; + +const SOURCE: &str = include_str!("../../rig/fixtures/durable-addressing.grida.xml"); +const INTER: &[u8] = + include_bytes!("../../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); +const SCENE_WIDTH: f32 = 352.0; +const SCENE_HEIGHT: f32 = 224.0; +const SCALE: f32 = 1.45; + +struct NoDependencies; + +impl SourceProvider for NoDependencies { + fn resolve( + &mut self, + _containing: &SourceSnapshot, + location: &str, + ) -> Result { + Err(format!("unexpected external source `{location}`")) + } +} + +fn color(hex: &str) -> Color { + Color::from_hex(hex).expect("demo colors are valid") +} + +fn linear(from: &str, to: &str) -> Paints { + Paints::new([Paint::LinearGradient(LinearGradientPaint { + xy1: Alignment::from_uv(0.0, 0.0), + xy2: Alignment::from_uv(1.0, 1.0), + stops: vec![ + GradientStop { + offset: 0.0, + color: color(from), + }, + GradientStop { + offset: 1.0, + color: color(to), + }, + ], + ..Default::default() + })]) +} + +fn radial(inner: &str, outer: &str) -> Paints { + Paints::new([Paint::RadialGradient(RadialGradientPaint { + stops: vec![ + GradientStop { + offset: 0.0, + color: color(inner), + }, + GradientStop { + offset: 1.0, + color: color(outer), + }, + ], + ..Default::default() + })]) +} + +fn box_stroke(hex: &str, width: f32, align: StrokeAlign) -> Stroke { + let payload = Payload::Frame { + layout: Default::default(), + clips_content: false, + }; + let mut stroke = Stroke::default_for(&payload).expect("frames accept strokes"); + stroke.paints = Paints::solid(color(hex)); + stroke.width = StrokeWidth::Uniform(width); + stroke.align = align; + stroke +} + +fn node( + program: &MaterializedProgram, + member: AuthoredMemberId, + use_id: Option<&str>, +) -> anchor_lab::model::NodeKey { + program + .addresses() + .find(|(address, _)| { + address.member.id == member + && match use_id { + Some(id) => address + .use_path + .iter() + .any(|occurrence| occurrence.id == id), + None => true, + } + }) + .map(|(_, node)| node) + .expect("fixture member occurrence exists") +} + +fn target( + node: anchor_lab::model::NodeKey, + property: PropertyKey, + value: PropertyValue, +) -> (PropertyTarget, PropertyValue) { + (PropertyTarget::new(node, property), value) +} + +fn effective_values(program: &MaterializedProgram) -> PropertyValues { + let directory = node(program, AuthoredMemberId::Id("directory".into()), None); + let first_card = node(program, AuthoredMemberId::ComponentRoot, Some("first-card")); + let second_card = node( + program, + AuthoredMemberId::ComponentRoot, + Some("second-card"), + ); + let first_avatar = node( + program, + AuthoredMemberId::Id("avatar".into()), + Some("first-card"), + ); + let second_avatar = node( + program, + AuthoredMemberId::Id("avatar".into()), + Some("second-card"), + ); + let first_name = node( + program, + AuthoredMemberId::Id("display-name".into()), + Some("first-card"), + ); + let second_name = node( + program, + AuthoredMemberId::Id("display-name".into()), + Some("second-card"), + ); + let first_action = node(program, AuthoredMemberId::Id("first-action".into()), None); + let second_action = node(program, AuthoredMemberId::Id("second-action".into()), None); + + let mut layout = match program.document.get(directory.id()).payload { + Payload::Frame { layout, .. } => layout, + _ => unreachable!("directory is a frame"), + }; + layout.gap_main = 8.0; + + PropertyValues::new( + &program.document, + [ + target( + directory, + PropertyKey::Fills, + PropertyValue::Paints(Paints::solid(color("#090E1A"))), + ), + target( + directory, + PropertyKey::Layout, + PropertyValue::Layout(layout), + ), + target( + directory, + PropertyKey::CornerRadius, + PropertyValue::CornerRadius(RectangularCornerRadius::circular(28.0)), + ), + target( + directory, + PropertyKey::CornerSmoothing, + PropertyValue::Number(0.72), + ), + target( + directory, + PropertyKey::Strokes, + PropertyValue::Strokes(vec![box_stroke("#334155", 2.0, StrokeAlign::Inside)]), + ), + target( + first_card, + PropertyKey::Fills, + PropertyValue::Paints(linear("#4338CA", "#7E22CE")), + ), + target( + first_card, + PropertyKey::CornerRadius, + PropertyValue::CornerRadius(RectangularCornerRadius::circular(22.0)), + ), + target( + first_card, + PropertyKey::CornerSmoothing, + PropertyValue::Number(0.68), + ), + target( + first_card, + PropertyKey::Strokes, + PropertyValue::Strokes(vec![ + box_stroke("#A78BFA", 3.0, StrokeAlign::Outside), + box_stroke("#EDE9FE", 1.0, StrokeAlign::Inside), + ]), + ), + target( + first_card, + PropertyKey::Rotation, + PropertyValue::Number(-1.4), + ), + target( + second_card, + PropertyKey::Fills, + PropertyValue::Paints(linear("#0F172A", "#1E293B")), + ), + target( + second_card, + PropertyKey::CornerRadius, + PropertyValue::CornerRadius(RectangularCornerRadius::circular(22.0)), + ), + target( + second_card, + PropertyKey::CornerSmoothing, + PropertyValue::Number(0.68), + ), + target( + second_card, + PropertyKey::Strokes, + PropertyValue::Strokes(vec![box_stroke("#22D3EE", 2.0, StrokeAlign::Inside)]), + ), + target( + second_card, + PropertyKey::Rotation, + PropertyValue::Number(1.2), + ), + target( + second_card, + PropertyKey::Opacity, + PropertyValue::Number(0.9), + ), + target( + first_avatar, + PropertyKey::Fills, + PropertyValue::Paints(radial("#FDE68A", "#F97316")), + ), + target( + second_avatar, + PropertyKey::Fills, + PropertyValue::Paints(linear("#22D3EE", "#2563EB")), + ), + target( + first_name, + PropertyKey::Fills, + PropertyValue::Paints(Paints::solid(color("#FFFFFF"))), + ), + target( + second_name, + PropertyKey::Fills, + PropertyValue::Paints(Paints::solid(color("#F8FAFC"))), + ), + target( + first_action, + PropertyKey::Fills, + PropertyValue::Paints(Paints::solid(color("#FDE68A"))), + ), + target( + second_action, + PropertyKey::Fills, + PropertyValue::Paints(Paints::solid(color("#67E8F9"))), + ), + target( + second_name, + PropertyKey::Width, + PropertyValue::SizeIntent(anchor_lab::model::SizeIntent::Fixed(128.0)), + ), + target( + second_action, + PropertyKey::Width, + PropertyValue::SizeIntent(anchor_lab::model::SizeIntent::Fixed(64.0)), + ), + ], + ) + .expect("demo effective state is valid") +} + +fn label( + canvas: &skia_safe::Canvas, + font: &Font, + text: &str, + x: f32, + y: f32, + color: skia_safe::Color, +) { + let mut paint = SkPaint::default(); + paint.set_anti_alias(true); + paint.set_color(color); + canvas.draw_str(text, (x, y), font, &paint); +} + +fn main() { + let output = std::env::args() + .nth(1) + .unwrap_or_else(|| "target/grida-effective-values-demo.png".into()); + let mut provider = NoDependencies; + let program = grida_xml_source::materialize( + SourceSnapshot::new("demo:durable-addressing", "demo:/", SOURCE), + &mut provider, + ) + .expect("Version 4 demo materializes"); + let values = effective_values(&program); + let value_view = ValueView::new(&program.document, &values).expect("validated value view"); + + let typeface = skia_safe::FontMgr::new() + .new_from_data(INTER, None) + .expect("bundled Inter typeface"); + let context = PaintCtx::new(Some(typeface.clone())); + let options = ResolveOptions { + viewport: (SCENE_WIDTH, SCENE_HEIGHT), + ..Default::default() + }; + let (width, height) = (1240, 560); + let mut surface = surfaces::raster_n32_premul((width, height)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::from_argb(255, 0xF5, 0xF7, 0xFB)); + + let heading = Font::new(typeface.clone(), 28.0); + let caption = Font::new(typeface.clone(), 15.0); + let detail = Font::new(typeface, 13.0); + label( + canvas, + &heading, + "One source document, two immutable reads", + 56.0, + 48.0, + skia_safe::Color::from_argb(255, 0x0F, 0x17, 0x2A), + ); + label( + canvas, + &caption, + "AUTHORED BASE", + 56.0, + 94.0, + skia_safe::Color::from_argb(255, 0x47, 0x55, 0x69), + ); + label( + canvas, + &caption, + "EFFECTIVE SNAPSHOT", + 676.0, + 94.0, + skia_safe::Color::from_argb(255, 0x6D, 0x28, 0xD9), + ); + + let left = Affine::translate(56.0, 120.0).then(&Affine::scale(SCALE, SCALE)); + let right = Affine::translate(676.0, 120.0).then(&Affine::scale(SCALE, SCALE)); + let (base, _) = frame::render(canvas, &program.document, &options, &left, &context) + .expect("authored frame renders"); + let (effective, _) = frame::render_view(canvas, &value_view, &options, &right, &context) + .expect("effective frame renders"); + + let unchanged = frame::resolve_and_build(&program.document, &options, &context) + .expect("authored state still renders"); + assert_eq!( + base.drawlist(), + unchanged.drawlist(), + "source was not mutated" + ); + let damage = diff_frame(&base, &effective); + + label( + canvas, + &detail, + "PropertyValues = empty", + 56.0, + 486.0, + skia_safe::Color::from_argb(255, 0x64, 0x74, 0x8B), + ); + label( + canvas, + &detail, + &format!( + "{} typed overrides • {} changed nodes • authored source untouched", + values.len(), + damage.changed.len() + ), + 676.0, + 486.0, + skia_safe::Color::from_argb(255, 0x64, 0x74, 0x8B), + ); + label( + canvas, + &detail, + "V4 address → NodeKey → PropertyTarget → ValueView → Resolved → frame", + 676.0, + 516.0, + skia_safe::Color::from_argb(255, 0x7C, 0x3A, 0xED), + ); + + let image = surface.image_snapshot(); + let png = image + .encode(None, EncodedImageFormat::PNG, None) + .expect("PNG encode"); + std::fs::write(&output, png.as_bytes()).expect("write visual proof"); + println!( + "wrote {output} ({} overrides, {} changed nodes, {} bytes)", + values.len(), + damage.changed.len(), + png.len() + ); +} From b516ecba7be93aedf22ce84566032a7d52483646 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 16:42:01 +0900 Subject: [PATCH 29/33] docs(model-v2): specify the pre-animation value contract --- docs/wg/format/grida-xml-addressing.md | 656 ++++++++++++++++++ docs/wg/format/grida-xml-animation.md | 52 +- .../format/grida-xml-component-parameters.md | 7 +- docs/wg/format/grida-xml-component-slots.md | 10 +- docs/wg/format/grida-xml-modules.md | 19 +- docs/wg/format/grida-xml-properties.md | 157 ++++- docs/wg/format/grida-xml.md | 65 +- docs/wg/format/index.md | 1 + model-v2/a/ENGINE.md | 83 ++- model-v2/a/lab/README.md | 106 ++- model-v2/engine/ANIMATION.md | 214 +++--- model-v2/engine/DATA-MODEL.md | 7 +- model-v2/engine/EFFECTIVE-VALUES.md | 127 ++++ model-v2/engine/README.md | 116 ++-- model-v2/problems.md | 13 + 15 files changed, 1391 insertions(+), 242 deletions(-) create mode 100644 docs/wg/format/grida-xml-addressing.md create mode 100644 model-v2/engine/EFFECTIVE-VALUES.md diff --git a/docs/wg/format/grida-xml-addressing.md b/docs/wg/format/grida-xml-addressing.md new file mode 100644 index 0000000000..5092e84c5b --- /dev/null +++ b/docs/wg/format/grida-xml-addressing.md @@ -0,0 +1,656 @@ +--- +title: "Grida XML durable addressing" +description: "RFD for durable authored node and component-occurrence addresses, typed property targets, and immutable effective values before animation semantics are introduced." +keywords: + - grida xml + - durable identity + - component instances + - property addressing + - effective values + - scene graph +tags: + - internal + - wg + - format-schema + - canvas + - authoring + - collaboration +format: md +--- + +# Grida XML durable addressing + +**Status:** Accepted direction for a Version 4 proving implementation. + +This RFD defines the identity and typed-value boundary that must exist before +declarative animation can be specified. It does not define animation syntax, +time, timelines, keyframes, interpolation, easing, composition, playback, or +scheduling. + +The decision is: + +1. authored identity, component occurrence, property identity, compiled + runtime identity, and effective frame values are five different facts; +2. Grida XML Version 4 gives every authored render node and every `use` a + durable owner-local `id`; +3. a materialized node address combines its authored owner and member with an + ordered path of durable use occurrences; +4. a property address adds one key from a closed typed property registry; +5. a runtime may compile that durable address to a generation-checked handle; +6. an immutable sparse value map may override registered base values for one + evaluation without mutating authored content; and +7. an empty map is exactly the existing static scene. + +These decisions also serve inspection, durable diagnostics, previews, future +cross-session operations under a stable canonical source-identity contract, +and other evaluated-state systems. Animation is one future producer of +effective values, not the owner of identity or the property registry. + +## Why one `id` is not the whole answer + +A component member can be authored once and materialized many times. The same +member `label` under uses `first-card` and `second-card` must therefore retain +one definition-local identity while producing two distinct occurrences. The +materialized engine slot for either occurrence can also be deleted and reused. +Finally, `opacity` and `width` on that occurrence have different types and +invalidate different stages. + +Collapsing those facts into one string or integer makes at least one lifetime +dishonest. The format instead keeps the following tiers separate: + +| Tier | Lifetime | Example | May be serialized as authored intent? | +| ----------------------- | --------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- | +| Authored owner/member | Across parse, print, and unrelated edits | component `card`, member `label` | Yes | +| Use occurrence path | Across parse, print, and unrelated edits | `[Use(SceneOwner(S), first-card)]` | Yes, as the individual `id` values on each `use` | +| Property key | Across implementations of the same language version | `opacity` | Yes, when a later source construct needs a property target | +| Compiled runtime target | One materialization generation | scene A, slot 42, generation 3, `opacity` | No | +| Effective value | One declared evaluation | `opacity = 0.6` | No | + +Source spans, sibling indexes, element paths, human-readable `name` values, +arena slots, hashes of property strings, and generated draw commands belong to +none of the durable tiers. + +## Prior-art constraints + +The separation is a synthesis of browser and game-engine practice rather than +a new all-purpose identifier: + +- Blink keeps a live target element and a typed property/attribute identity, + then writes sampled SVG values into a separate animated slot before style, + layout, and paint. The [Chromium SVG animation + study](../research/chromium/svg/animation-and-smil) records that pipeline. +- Unity's + [`GenericBindingUtility`](https://github.com/Unity-Technologies/UnityCsReference/blob/979bc204a0c6506d87595a02fc89452687ed820d/Modules/Animation/ScriptBindings/GenericBinding.bindings.cs) + compiles hierarchy/type/property descriptors into + [`BoundProperty`](https://github.com/Unity-Technologies/UnityCsReference/blob/979bc204a0c6506d87595a02fc89452687ed820d/Modules/Animation/ScriptBindings/BoundProperty.bindings.cs) + handles whose runtime identity is an index plus version. Transient float, + integer, and object-reference values are supplied separately to `SetValues`. + The versioned index accelerates a binding; it does not make the authored + hierarchy path durable. +- Unreal's + [`FMovieSceneObjectBindingID`](https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/MovieScene/FMovieSceneObjectBindingID) + combines persistent binding identity with sequence-instance context, while + [`FMovieScenePropertyBinding`](https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/MovieScene/FMovieScenePropertyBinding) + keeps property identity separate. + +The useful common shape is durable authored target, instance context, typed +property, replaceable runtime handle, and transient value storage. None of +these systems justifies serializing a runtime slot or using a hierarchy string +as the only identity. + +## Scope + +This RFD specifies: + +- the Version 4 `id` grammar and uniqueness scopes; +- component-root, member, use-occurrence, nested-use, and identity preservation + through slot projection; +- the abstract node and property address forms; +- the contract for a closed typed property registry; +- nullability and sparse effective-value semantics; +- compilation to replaceable generation-checked runtime targets; +- strict validation and canonical-writing requirements; and +- the static-equivalence law. + +It does not specify: + +- a textual selector or URI syntax for addresses; +- paint-layer, stroke-layer, gradient-stop, text-run, or lens-operation member + identity; +- mutation, collaboration, or merge protocols; +- archive encoding; +- animation or event semantics; or +- incremental evaluation and compositor optimization. + +The abstract address is intentionally decided before a compact spelling. A +later syntax can serialize the structure without forcing every processor to +parse an opaque path string. + +## Version 4 source contract + +Version 4 inherits the complete Version 3 language: source modules, components, +typed scalar parameters, and named slot projection. Its only new authored +vocabulary is durable `id` on render elements and `use`. + +```xml + + + + Ada River + + + + + + Follow + + + Message + + + +``` + +### Required IDs + +In Version 4: + +- every authored render element has exactly one `id`; +- every `use` has exactly one `id`; +- `id` is a literal and cannot be supplied by a component parameter; +- `id` uses the existing lowercase-kebab identifier grammar; +- `name` remains an optional human label and is never an identity fallback; +- `tspan`, `prop`, `arg`, `slot`, paints, strokes, and gradient stops do not + gain `id` from this RFD; and +- the generated host viewport node has no authored identity. + +A `component` is a definition construct rather than an authored render +element. Its existing `id` remains the module-local export symbol that +qualifies the component owner; it is not copied or reused as a render-node ID. +The root member is the tagged member variant `Root`; descendants use the +tagged variant `Node(authored-id)`. The tag keeps `Root` distinct from a legal +descendant `id="root"`. + +Versions 0 through 3 retain their exact grammar. They neither require nor +accept render-node or use-occurrence `id`, and processors must not synthesize +durable addresses for them. + +### Uniqueness scope + +One lexical owner owns one identity namespace: + +- the scene tree in one source unit is one owner; and +- each component definition is a separate owner. + +Within an owner, render-element IDs and use IDs share one namespace and must be +unique across the complete lexical subtree, including caller-authored slot +assignments. Component export IDs remain in the source unit's component-export +namespace and may coincide with a member ID without ambiguity. + +Sharing the render/use namespace guarantees unambiguous owner-local lookup and +prevents two differently typed authored sites from claiming the same token. +Refactoring `Node(card)` into `Use(card)` still changes identity and must be an +explicit edit. Allowing IDs to repeat in different components keeps +definition-local names concise and is safe because the owner is part of every +address. + +Version 4 source units link only Version 4 component sources. A processor must +not claim a complete Version 4 address space while expanding an older +definition whose members and uses have no durable IDs. Older callers likewise +cannot link a newer Version 4 definition. + +This deliberately tightens the earlier monotone link pattern: + +| Caller | Version 1 target | Version 2 target | Version 3 target | Version 4 target | +| --------- | ---------------------------- | ------------------ | -------------------------- | --------------------- | +| Version 1 | Static use | Invalid | Invalid | Invalid | +| Version 2 | Static use / empty interface | Scalar-capable use | Invalid | Invalid | +| Version 3 | Static use | Scalar arguments | Scalar arguments and slots | Invalid | +| Version 4 | Invalid | Invalid | Invalid | Identity-complete use | + +Versions 1 through 3 remain unchanged. Version 4 trades backward linking for +the stronger invariant that every authored node in its materialized closure +has a durable address; partial identity would make target validity depend on +which dependency happened to be older. + +### Stability and editing + +An ID survives parsing, canonical writing, reformatting, sibling insertion, +reordering, reparenting within the same owner, and unrelated property edits. +Changing an ID changes identity. A tool may rename and retarget references in +one transaction, but a reader never guesses that two different IDs denote the +same object. + +That is authored-member stability, not unconditional occurrence-address +stability. Reparenting through a different component use or slot projection +changes the ordered occurrence path. Changing canonical source identity, +renaming a component export, changing a member/use ID, or changing an enclosing +use path likewise changes the corresponding address. + +Moving a subtree within one owner preserves its IDs. Copying a subtree into an +owner where any copied ID already exists must mint non-conflicting IDs before +the copy becomes valid. The source language does not prescribe an editor's ID +generation algorithm; it only requires the committed result to satisfy the +grammar and uniqueness rules. + +## Address model + +### Authored owner + +An authored owner is one of: + +```text +scene-owner = (canonical source identity, scene) +component-owner = (canonical source identity, component export id) +``` + +Canonical source identity is supplied by the source-resolution environment, +as already required by the module RFD. A relative `href`, source bytes, or +working-directory path is not substituted for it. + +### Authored member + +An authored member is a tagged variant: + +```text +Root +Node(authored render-element id) +``` + +The pair `(owner, member)` identifies authored definition intent but not one +materialized component instance. + +### Use occurrence + +A use occurrence is: + +```text +(caller owner, authored use id) +``` + +The referenced component, source span, `href`, and `name` remain valuable +provenance, but none replaces the caller-owned use identity. + +### Materialized node address + +A materialized node address is: + +```text +(authored owner, authored member, ordered use-occurrence path) +``` + +The path is outermost first. An ordinary scene node has an empty path. A member +inside one component use has one segment. A member inside nested uses has one +segment per expansion boundary. + +For the example above, let `S` be the entry's canonical source identity. The +two component roots and avatars differ only by their typed occurrence path: + +```text +(ComponentOwner(S, profile-card), Root, [Use(SceneOwner(S), first-card)]) +(ComponentOwner(S, profile-card), Node(avatar), [Use(SceneOwner(S), first-card)]) +(ComponentOwner(S, profile-card), Root, [Use(SceneOwner(S), second-card)]) +(ComponentOwner(S, profile-card), Node(avatar), [Use(SceneOwner(S), second-card)]) +``` + +The scene root's `id="feed"` is its member ID; it is not a use-path segment. + +Caller-authored slot content retains its caller owner and member. Its path +still includes the receiving use and every enclosing use through which that +caller content was materialized. Projection changes placement and painter +context; it does not transfer authorship to the callee. + +One logical materialized node occurrence has one canonical node address. +Distinct canonical addresses must never resolve to the same logical +occurrence except where a future RFD explicitly defines an aliasing construct; +Version 4 defines none. An implementation may still share immutable backing +data for repeated component definitions, as long as each occurrence retains a +distinct address and observable instance behavior. + +## Typed property addresses + +A property address is exactly: + +```text +(materialized node address, registered property key) +``` + +Property keys come from one closed, versioned registry rather than arbitrary +field paths. Each registry entry defines: + +- its canonical semantic key; +- its exact value type, including any property-specific optional state; +- the node kinds on which it applies; +- how to read the authored base value; +- validation of an effective value; +- a conservative set of affected stages: measure, layout, transform, bounds, + paint, and resource; and +- deterministic equality. + +The initial registry is exactly the following. Keys are semantic property +identities, not necessarily XML attribute spellings. `active`, for example, +is the positive runtime value projected from the inverse `hidden` source +attribute; `layout`, `fills`, and `strokes` are whole model values assembled +from their compact or structured source syntax before this boundary. + +Impact legend: M = measure, L = layout, T = transform, B = bounds, P = paint, +and R = resource. + +| Semantic key | Exact value type | Applicability | Authored base projection | Impact | +| -------------------------- | --------------------- | ------------------------------------------------------------ | ----------------------------------------------------------- | ----------- | +| `x`, `y` | `AxisBinding` | Every node | Materialized axis binding | M/L/T/B/P | +| `width` | `SizeIntent` | Frames, rectangles, ellipses, lines, paths, and text | Materialized fixed/auto width intent | M/L/T/B/P | +| `height` | `SizeIntent` | Frames, rectangles, ellipses, paths, and text; never lines | Materialized fixed/auto height intent | M/L/T/B/P | +| `min-width`, `max-width` | `OptionalNumber` | Frames, rectangles, ellipses, lines, paths, and text | Materialized optional width constraint | M/L/T/B/P | +| `min-height`, `max-height` | `OptionalNumber` | Frames, rectangles, ellipses, paths, and text; never lines | Materialized optional height constraint | M/L/T/B/P | +| `aspect-ratio` | `OptionalAspectRatio` | Rectangles, ellipses, and paths | Materialized optional width/height ratio | M/L/T/B/P | +| `active` | `Boolean` | Every node | Positive activity value; inverse of source `hidden` | M/L/T/B/P/R | +| `rotation` | `Number` | Every node | Clockwise degrees | M/L/T/B/P | +| `flip-x`, `flip-y` | `Boolean` | Every node | Native mirror flags | M/L/T/B/P | +| `flow` | `Flow` | Every node | In-flow or absolute participation | M/L/T/B/P | +| `grow` | `Number` | Every node | Main-axis growth factor | M/L/T/B/P | +| `self-align` | `SelfAlign` | Every node | Per-child alignment projected from source `align` | M/L/T/B/P | +| `opacity` | `Number` | Every node | Node/subtree compositing opacity | P | +| `layout` | `Layout` | Frames | Complete normalized layout behavior | M/L/T/B/P | +| `clips-content` | `Boolean` | Frames | Content-clip flag projected from source `clips` | B/P | +| `corner-radius` | `CornerRadius` | Frames and rectangles | Complete four-corner elliptical radius value | B/P | +| `corner-smoothing` | `Number` | Frames and rectangles | Smoothing factor | B/P | +| `fills` | `Paints` | Fill-paintable frames, rectangles, ellipses, paths, and text | Complete ordered fill paints after shorthand normalization | P/R | +| `strokes` | `Strokes` | Stroke-paintable frames, shapes, and text | Complete ordered stroke geometries and their ordered paints | B/P/R | + +`Number` is not one universal numeric domain. Rotation is any finite number; +growth is finite and non-negative; opacity and corner smoothing are finite in +`[0, 1]`. Fixed sizes and present optional constraints are finite and +non-negative. A present aspect ratio contains two finite positive numbers. +Layout, corner, paint, gradient-stop, image-resource, and stroke values retain +the validation rules of their static model and renderer contracts. Validation +checks the complete effective node state, not only each replacement in +isolation: for example, smoothing plus elliptical corner radii and per-side +strokes plus smoothing must be rejected regardless of which member came from +authored state. An aggregate paint or stroke that the renderer cannot +materialize is invalid rather than silently omitted. Applicability and domain +validation happen before evaluation. + +That shared authored-state fence includes finite, mathematically invertible +gradient transforms and, for a linear gradient, a binary32 unit-space endpoint +distance greater than `2^-15`. Authored source, canonical writing, and a +replacement `Paints` value therefore cannot disagree about malformed gradient +intent or a ramp that would be replaced with a degenerate color. + +Backend representability is a later, resolved-value question. A conforming +evaluator composes each visible gradient transform with its actual resolved +paint box using the backend's numeric representation, verifies that the +resulting local matrix is invertible, and proves that its selected gradient +implementation can construct the shader before publishing or drawing the frame. +A very small or large authored transform may fail for one box and succeed for +another; the paint box can worsen or rescue its conditioning. Failure must +identify the owning node and whether the paint came from a fill, stroke, or +text run, and must occur before any frame pixels are emitted. This evaluation- +time failure does not make the mathematically valid authored aggregate invalid. + +The registry does not, by itself, make a property animatable. A later +animation specification must select a subset and define interpolation, +composition, boundary, and resource rules for every selected key. + +Source spelling and semantic property identity are related but not required to +be byte-equal. For example, compact `fill="#fff"` and an expanded `fill` +element both project the same ordered fills property. The source-level +`hidden` spelling projects a visibility value rather than creating a second +runtime concept. A processor must not create separate effective-value keys for +compact and expanded syntax. + +Structural facts are outside this registry: node kind, parent, child order, +component reference, use path, slot assignment, text-run partition, and path +command topology cannot be changed by an effective-value entry. + +The first registry is deliberately node-level. Whole ordered fills and strokes +are aggregate typed values because the current scene model owns those values at +the node. Replacing either aggregate may change its internal list length; +addressing one paint, stroke, stop, run, or lens operation remains invalid +until that subobject has a durable identity contract. + +## Nullability and absence + +There is no universal overlay `null`. Two states must not collapse: + +1. **No effective-value entry** — read the authored base value. +2. **One exact registered value** — use that validated typed value for this + evaluation. + +When the model property itself is optional, optionality belongs inside its +registered type—for example, `OptionalNumber(None)` versus +`OptionalNumber(Some(24))`. That typed `None` is a real property value and is +still distinct from an absent map entry. It is never encoded as an empty +string, zero, false, NaN, a generic null token, or a magic enum member. + +Source omission remains governed by the static property grammar. It may mean a +default, inheritance, automatic sizing, or an absent optional value depending +on that property. It is not automatically equivalent to effective-map +absence. + +## Materialization and compilation + +Materialization produces an ordinary scene plus a checked bidirectional +relationship between canonical node addresses and live scene nodes. It must +reject an address collision rather than select one node by traversal order. + +A runtime may compile a property address to the equivalent of: + +```text +(runtime scene incarnation, arena slot, slot generation, property key) +``` + +That compiled target is a replaceable acceleration artifact. It is valid only +for the materialization generation against which it was checked. Deletion, +slot reuse, rematerialization, identity edits, or relevant component-link +changes require validation or recompilation. A stale target must fail closed; +it must never affect a new node that happens to reuse the same slot. + +Slot generation alone is insufficient: two freshly materialized scenes can +both contain slot 7 at generation zero. An implementation must therefore +include a non-authored runtime-scene/incarnation scope or make applying a +compiled target to any scene other than the exact borrowed scene +unrepresentable. That scope remains a runtime lifetime guard and is not a +sixth authored identity tier. + +Cloning or replacing a scene likewise requires rebinding unless the runtime +can prove both values share one non-divergent arena identity. Slot generations +must never wrap and revive an ancient target; exhaustion fails explicitly or +retires the slot permanently. + +Neither the compiled target nor a hash derived from it may be written as the +authored address. Conversely, a renderer does not resolve source strings or +walk component definitions for every frame; it consumes compiled targets. + +## Immutable effective values + +An effective-value input is an immutable sparse map: + +```text +compiled property target -> typed property value +``` + +Before evaluation it validates that every target is live and generation +correct, every key applies to the target node, and every value has the declared +type and domain. Duplicate targets are errors unless a later producer defines +composition before constructing the map. + +The reference evaluation is: + +```text +materialized ordinary scene + effective values + declared environment + -> measure and layout + -> transforms and bounds + -> draw list + -> raster and spatial queries +``` + +Every downstream consumer observes the same effective values. Layout-affecting +values are available before measurement and layout. Paint-affecting values are +not sampled independently by the painter. Queries use the resolved result of +the same evaluation; query-relevant traversal and clip state must be captured +there rather than supplied later through another independently pairable base or +effective-value view. Damage includes draw-list changes so paint-only value +changes cannot produce empty damage. + +The declared resource environment is a separate immutable input. A logical +image reference can retain the same identifier while its decoded bytes or +readiness changes, so equality of resolved geometry and draw-list commands is +complete only under the same resource-environment snapshot. The resolved +result, ordered draw list, and environment identity form one immutable frame +product; complete visual comparison accepts two such products rather than +separately pairable parts. A snapshot change must invalidate retained raster +state and produce conservative damage for resource-backed items, or for the +complete scene when affected items cannot be identified. Evaluation still +performs no resource I/O. Rastering a retained frame product against a different +resource-environment incarnation or revision must fail before drawing; a raw +unchecked draw-list replay cannot claim complete-frame semantics. + +Evaluation never mutates the authored scene, writes effective values into +source, emits document operations, performs source or resource I/O, or reads a +clock. + +### Static-equivalence law + +The empty map is the permanent static oracle: + +```text +effective-values = empty + => authored base values at every registered property + => identical resolved geometry and content + => identical draw list + => identical queries + => identical pixels +``` + +This law is exact, not approximate. The existing static entry point may remain +as a thin call using the empty map. It must not become a separate semantic +pipeline. + +## Validation and diagnostics + +A strict Version 4 processor rejects at least: + +- a render element or `use` without `id`; +- an empty, malformed, bound, or duplicate `id`; +- a Version 4 source linking a component source of another version; +- a materialized address collision or a materialized node without one address; +- an unknown property key; +- a property key inapplicable to the target kind; +- a value of the wrong registered type; +- a generic null token instead of the key's exact registered value type; +- a non-finite or out-of-domain numeric value; +- an aggregate paint, stroke, corner, or layout value outside the renderer's + declared capability; +- a valid replacement that forms an invalid complete node state with the + remaining authored values; +- a stale runtime scene or slot generation; +- two effective entries for the same compiled target; and +- an attempt to address an unregistered subobject or structural field. + +Diagnostics identify the authored source and owner, member ID, complete use +path, property key when present, and the immediate violated rule. Useful forms +include: + +- `Version 4 requires a lowercase-kebab id`; +- `duplicate id "avatar" in component "profile-card"; first declared on `; +- `member "label" through scene use "second-card" has no registered property "font-family"`; +- `property "max-width" expects OptionalNumber, received Number`; +- `property "fills" contains an image fit that this renderer cannot materialize`; +- `corner-smoothing cannot be nonzero with elliptical corner radii`; +- `compiled target for use "first-card", member "avatar" belongs to a replaced scene`; and +- `compiled target for use "first-card", member "avatar" is stale: expected generation 2, found 3`. + +Source spans may enrich those messages. They do not become address fields. + +## Canonical writing + +A canonical Version 4 writer: + +- emits `version="4"`; +- emits every required render and use ID unchanged at the semantic level; +- never substitutes `name`, source position, or a generated path for a missing + ID; +- preserves component export ID separately from member IDs; +- writes the existing canonical static property forms; +- never serializes occurrence paths, runtime handles, generations, effective + values, or resolved output as duplicate authored state; and +- refuses to write an invalid identity graph rather than silently renaming it. + +An editor may generate or repair IDs as an explicit authored edit before +calling the canonical writer. The writer itself is not an identity policy. + +## Current model compatibility + +The current scene contract distinguishes a stable string user-node ID from an +ephemeral numeric runtime node ID, which is the correct lifetime split for an +ordinary flat scene. Its lookup namespace is scene-global, however, and one +string does not preserve Version 4's definition-local member plus ordered use +path. Repeating one component member would either collide or require a +flattened generated string whose segments and escaping become an accidental +second address grammar. + +Version 4 therefore keeps its structured address in the linked/materialized +program above the ordinary scene. A runtime that requires one flat user-node +key may lower each logical occurrence to a collision-free opaque key, but it +must preserve the structured source address for inspection, diagnostics, +recompilation, and source writing. The opaque key is not the canonical authored +address and must not be reconstructed by splitting a concatenated string. + +This is a declared incompatibility with representing live component +occurrences through the current flat ID field alone. The RFD does not change +the scene paint, ordered-paint, stroke, layout, or runtime-slot models, and it +does not require the ordinary materialized scene to become component-aware. + +## Considered alternatives + +| Alternative | Decision | Reason | +| ------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------- | +| `name` as identity | Rejected | Human labels are optional, duplicate, and routinely edited. | +| Source span, sibling index, or element path | Rejected | Reformatting, insertion, reorder, and reparenting change them. | +| Arena `NodeId` as authored identity | Rejected | Runtime storage identity has the wrong lifetime and may be reused. | +| One globally flattened instance/member string | Rejected | It obscures owner and occurrence boundaries and weakens diagnostics and refactoring. | +| Generated IDs for Versions 0–3 | Rejected | Structural generation cannot promise stability across ordinary source edits. | +| Optional IDs in Version 4 | Rejected | A processor could not promise a complete durable address space or canonical writer. | +| Reflective string property paths | Rejected | Type, applicability, nullability, invalidation, and diagnostics would become late and inconsistent. | +| Clone and patch the authored scene per evaluation | Rejected | It creates a second mutation path and loses authored/effective separation. | +| Identify paint list members by index now | Deferred | Reordering and insertion would retarget values; subobject identity needs its own RFD. | +| Introduce animation syntax with identity | Rejected for this RFD | Timing and interpolation decisions are independent and remain open. | + +## Conformance + +A conforming Version 4 reader and materializer: + +1. **MUST** enforce required owner-local render and use IDs and the exact + version-link rule. +2. **MUST** preserve owner/member identity and ordered occurrence paths across + component expansion and slot projection. +3. **MUST** expose a one-to-one canonical address for every authored + materialized node and reject collisions. +4. **MUST NOT** treat provenance, source spans, names, runtime slots, or + generations as authored identity. +5. **MUST** validate typed property targets through one closed registry. +6. **MUST** distinguish sparse absence from an optional state inside an exact + registered value type and **MUST NOT** define a universal overlay null. +7. **MUST** reject stale compiled targets and invalid typed values before + evaluation. +8. **MUST** keep authored state immutable and make an empty effective-value + input observationally identical to static evaluation. +9. **MUST** feed one effective evaluation to layout, draw-list construction, + query, damage, and rendering. +10. **MUST** treat resource-environment revision as frame identity for cache + and damage even when logical resource identifiers are unchanged. +11. **MUST NOT** claim that registration makes a property animatable. + +## Deferred work + +Animation remains governed by the [Grida XML animation question set](./grida-xml-animation). +That work still needs to decide time representation, source placement, +target-reference spelling, keyframes, interpolation, easing, composition, +events, static processing, resource behavior, and playback. + +Durable identity for paint layers, strokes, gradient stops, text runs, path or +vector members, and lens-operation parameters is also deferred. Until each +domain has stable member identity, a cross-target property address may name +only the registered node-level value. A later RFD may add structured member +segments without changing the five-tier separation accepted here. diff --git a/docs/wg/format/grida-xml-animation.md b/docs/wg/format/grida-xml-animation.md index 2d3b59d8fd..2676ae4207 100644 --- a/docs/wg/format/grida-xml-animation.md +++ b/docs/wg/format/grida-xml-animation.md @@ -24,6 +24,14 @@ format: md **Status:** Open RFD — question framing only. No syntax, timing model, or runtime behavior in this document is adopted. +Two prerequisites are now decided outside this animation RFD. [Grida XML +durable addressing](./grida-xml-addressing) defines Version 4 authored +owner/member IDs, component occurrence paths, typed property targets, compiled +runtime handles, and an immutable sparse effective-value boundary. The +underlying property registry remains distinct from the future **animatable** +subset. These decisions remove identity and document-mutation as blockers; they +do not choose animation syntax or behavior. + ## The question Should declarative animation be part of the first stable Grida XML language, @@ -54,18 +62,19 @@ static scene language does not accidentally choose among them by omission. Animation is not only “values changing over time.” It affects several foundational contracts: -- whether a property has one value or distinct authored and sampled values; +- how animation populates the accepted authored and effective-value tiers; - whether animation is nested with its target or stored in a separate timeline graph; -- whether nodes need durable target identity; +- how the accepted structured node target is spelled, owned, and copied; - whether layout observes animated geometry; - how multiple animations combine on one property; - what a static renderer, thumbnailer, exporter, or agent sees; - whether time- and event-dependent source remains deterministic and safe. -Adding those distinctions after a stable static format may require a second -property model, a second identity scheme, or a second canonical source shape. -Raising them now does not require implementing all of animation now. +The durable-addressing RFD has closed the property-tier and node-identity +questions. Deferring the remaining distinctions could still require a second +canonical source shape. Raising them now does not require implementing all of +animation now. ## What SVG contributes to the question @@ -157,7 +166,7 @@ tradeoffs: | ------------------------- | ------------------------------------------- | --------------------------------------------------------------------------- | | Animation child of target | Local, readable, SVG-like | How are animation property children distinguished from render children? | | Structured keyframes | One grammar for arbitrary ramps | Is it unnecessarily verbose for the common two-value case? | -| Separate timeline graph | Central sequencing and cross-target editing | What durable target identity does `target` use? | +| Separate timeline graph | Central sequencing and cross-target editing | How does `target` spell the accepted structured node address? | | Named reusable animation | Reuse across nodes or instances | Are overrides and instance-local time worth a definition system on day one? | The RFD should not select a spelling until the value, timing, targeting, and @@ -168,20 +177,20 @@ rather than resolve those model decisions. ### 1. Base value and sampled value -- Does every animatable property have an authored base value and a separate - sampled value? +- Does an active animation always produce an entry in the accepted effective + value map, even when that entry equals the authored base? - Is sampling a pure function of `(document, environment, time)`, or may prior playback state affect the result? -- When no animation contributes, does the sampled value equal the base value - exactly? +- When an animation ceases contributing, when is its effective entry removed, + and may fill behavior keep the last contribution? - When an editor changes a property during playback, is it editing the base, the selected keyframe, an animation-relative offset, or a new override? - Can a canonical writer ever serialize a sampled frame, or only authored animation intent? - What does inspection show: base, sampled, both, or an explicit mode? -The SVG base/current split suggests that conflating the two is unsafe. It does -not decide what Grida's public vocabulary should be. +Grida has accepted the same safe separation in its static/effective boundary. +Animation still needs to define contribution lifetime and public vocabulary. ### 2. Timeline ownership @@ -270,15 +279,19 @@ bedrock question, even if day-one syntax permits only replacement. - Is an animation always nested under and implicitly targets its parent? - Can it target another node, paint, gradient stop, text run, component parameter, or effect? -- If cross-targeting exists, what stable authored identity names the target? -- Are property paths textual names, typed child selectors, or durable schema - identifiers? +- Cross-targeted nodes now have the structured Version 4 address from the + [durable-addressing RFD](./grida-xml-addressing); what source spelling should + carry that address remains open. +- Node properties now use typed registry keys rather than reflective runtime + field paths. Durable addressing for paint, stop, text-run, and other + subobjects remains deferred. - What happens when the target is deleted, renamed, moved, or instantiated through a component? - Does copying a subtree copy, retarget, share, or detach its animations? -Avoiding external targeting initially reduces the identity burden, but may -make synchronization and timeline editing awkward. The tradeoff remains open. +Avoiding external targeting initially may still simplify source syntax, but it +is no longer required to compensate for missing node identity. The source and +copying tradeoffs remain open. ### 9. Transform and motion-path specialization @@ -411,11 +424,12 @@ smallest scope that preserves the invariants Grida cannot afford to retrofit. A normative animation proposal should not begin until it can answer, with examples and counterexamples: -1. What are the base-value and sampled-value tiers? +1. How does animation populate the already-separated base and effective-value + tiers? 2. What timeline and deterministic-seeking model is canonical? 3. What is the day-one animatable property registry? 4. How do effects on the same property compose? -5. How are targets identified and copied? +5. How does animation spell, own, and copy the accepted structured targets? 6. Which animation constructs can affect layout, resources, or trust? 7. What does every static consumer render? 8. Which syntax is canonical, and why is it easier to author and diagnose than diff --git a/docs/wg/format/grida-xml-component-parameters.md b/docs/wg/format/grida-xml-component-parameters.md index 6b407d1441..64daa2186e 100644 --- a/docs/wg/format/grida-xml-component-parameters.md +++ b/docs/wg/format/grida-xml-component-parameters.md @@ -37,7 +37,8 @@ reuse](./grida-xml-modules) owns source units, boxed component definitions, `use`, linking, materialization, and component provenance. This RFD specifies only the parameterization delta. [Grida XML component slots](./grida-xml-component-slots) separately owns the Version 3 named render -projection delta. +projection delta. [Grida XML durable addressing](./grida-xml-addressing) owns +the later Version 4 member/use identity delta. ## Decision summary @@ -726,6 +727,10 @@ Prop provenance uses the source and use boundaries already required by the module contract; it does not pretend a runtime node handle is durable authored identity. +Version 4 subsequently adds explicit render-member and use IDs through the +[durable-addressing RFD](./grida-xml-addressing). It does not reinterpret a +Version 2 binding, source span, or materialized occurrence as identity. + ## Cycles and processing limits Scalar props cannot change `href`, element names, or structure. They therefore diff --git a/docs/wg/format/grida-xml-component-slots.md b/docs/wg/format/grida-xml-component-slots.md index d0c4507c69..9d96c3b43c 100644 --- a/docs/wg/format/grida-xml-component-slots.md +++ b/docs/wg/format/grida-xml-component-slots.md @@ -34,7 +34,9 @@ reuse](./grida-xml-modules) owns source units, boxed component definitions, `use`, linking, materialization, and component provenance. [Grida XML component parameters](./grida-xml-component-parameters) owns Version 2 scalar props, arguments, bindings, and lexical scalar scope. This RFD specifies only the -Version 3 render-slot delta. +Version 3 render-slot delta. [Grida XML durable +addressing](./grida-xml-addressing) owns the later Version 4 member/use +identity and occurrence-path delta. ## Decision summary @@ -409,6 +411,12 @@ Version 3 introduces no durable use, slot-occurrence, assignment-root, or projected-descendant ID. Source locations, child indices, and transient runtime handles must not be presented as durable authored identity. +Version 4 subsequently requires explicit render-member and use IDs. During +slot projection it retains the assignment root's caller owner and appends the +receiving use to its durable occurrence path; it does not promote a Version 3 +source location or child index into identity. See [Grida XML durable +addressing](./grida-xml-addressing). + ## Source preservation and canonical writing A source-preserving writer writes slot declarations and assignment roots rather diff --git a/docs/wg/format/grida-xml-modules.md b/docs/wg/format/grida-xml-modules.md index 525cc4d397..fb3f80f0ca 100644 --- a/docs/wg/format/grida-xml-modules.md +++ b/docs/wg/format/grida-xml-modules.md @@ -37,9 +37,15 @@ projection is proposed separately as a Version 3 delta in [Grida XML component slots](./grida-xml-component-slots). Deep overrides require still-later design. None of that later syntax is silently extensible Version 1 syntax. +[Grida XML durable addressing](./grida-xml-addressing) accepts the next exact +delta: Version 4 requires IDs on render members and uses and turns the retained +use chain into a durable occurrence path. It leaves this Version 1 grammar and +its span-based diagnostic provenance unchanged. + **Companion specifications:** [Grida XML](./grida-xml), [Grida XML component -parameters](./grida-xml-component-parameters), and [Grida XML component -slots](./grida-xml-component-slots). +parameters](./grida-xml-component-parameters), [Grida XML component +slots](./grida-xml-component-slots), and [Grida XML durable +addressing](./grida-xml-addressing). ## Decision summary @@ -162,6 +168,10 @@ this Version 1 boundary. The [component-slot RFD](./grida-xml-component-slots) separately proposes exact Version 3 named direct projection. It likewise does not alter Version 1. +The [durable-addressing RFD](./grida-xml-addressing) separately defines exact +Version 4 owner/member identity and use-occurrence paths. Version 1 provenance +remains diagnostic and does not become a generated durable identity. + ## Vocabulary | Term | Meaning | @@ -470,7 +480,10 @@ resolution. Every materialized node remains attributable to: - the ordered use chain through which it entered the entry scene. That provenance is diagnostic and source-preservation data. It is not an -authored transform, layout property, or globally stable node identifier. +authored transform, layout property, or globally stable node identifier in +Versions 1 through 3. Version 4 adds explicit IDs and derives its durable +address from those authored facts rather than promoting spans or traversal +positions. ### Validation closures diff --git a/docs/wg/format/grida-xml-properties.md b/docs/wg/format/grida-xml-properties.md index 726963d2fa..f4a8baf2d9 100644 --- a/docs/wg/format/grida-xml-properties.md +++ b/docs/wg/format/grida-xml-properties.md @@ -30,6 +30,11 @@ parameters](./grida-xml-component-parameters), and [component slots](./grida-xml-component-slots) RFDs. Those rows remain **Design** until their language versions are accepted; Draft 0 continues to reject them. +The [durable-addressing RFD](./grida-xml-addressing) accepts a Version 4 +identity and typed effective-value boundary. Version 4 inherits the earlier +component vocabulary, but it does not change which rows Draft 0 accepts and +does not make a registered property animatable. + This page is the canonical inventory of XML-facing property names and their valid targets. It is not, by itself, a grammar extension. Only rows marked **Draft 0** are accepted by a Draft 0 reader. **Placeholder** and **Design** @@ -89,6 +94,7 @@ Grida XML projects that model as an authored language: | Status | Meaning | | --------------- | --------------------------------------------------------------------------- | | **Draft 0** | Normative syntax accepted by the current RFD | +| **Version 4** | Accepted only by the exact Version 4 source contract | | **Placeholder** | Production concept exists; proposed XML name and target are held for design | | **Design** | Selected or unresolved later-version design; not operative Draft 0 syntax | | **Derived** | Runtime, archive, or resolved data that should not be authored directly | @@ -287,6 +293,75 @@ appears in the ordinary materialized scene. Version 3 has no wrapper, unnamed/default slot, fallback, requiredness, or explicit-empty form. Versions 0, 1, and 2 continue to reject all slot syntax. +### Version 4 durable identity vocabulary + +These rows summarize the accepted [durable-addressing +RFD](./grida-xml-addressing). Version 4 inherits the complete Version 3 +language but links only Version 4 component sources so every materialized +authored node has a complete address. + +| Attribute or identity | Valid on or in | Version 4 contract | +| --------------------- | ------------------------------------ | -------------------------------------------------------------------------- | +| `id` | Every authored render element | Required literal lowercase-kebab member ID, unique in its lexical owner | +| `id` | Every `use` | Required caller-owned occurrence ID in the same owner-local namespace | +| `id` | `component` | Existing source-unit export ID; qualifies the owner containing `Root` | +| `root` | Abstract component member addressing | Distinguished identity only; not an authored attribute | +| `name` | Render elements and `use` | Optional human label; never an identity fallback | +| occurrence path | Materialized component descendants | Ordered outer-to-inner sequence of caller-owner plus authored-use-ID pairs | + +Versions 0 through 3 do not accept this new render/use `id` syntax and never +receive generated durable addresses. + +### Version 4 effective-value registry + +Version 4 also fixes the node-level semantic properties that may enter one +immutable effective-value evaluation. This is not new authored animation +syntax and registration does not mean a property is animatable. A missing +entry reads the materialized base value; optional states live inside the exact +value type rather than a universal `null`. + +Keys name model properties, not alternative source spellings. Compact and +expanded fills both project `fills`; source `hidden` projects the inverse +`active` value; the flex attributes collectively project one `layout` value. + +Impact legend: M = measure, L = layout, T = transform, B = bounds, P = paint, +and R = resource. + +| Semantic key | Exact value type | Valid target | Base projection | Impact | +| -------------------------- | --------------------- | ------------------------------------------------------------ | --------------------------------------------- | ----------- | +| `x`, `y` | `AxisBinding` | Every node | Materialized axis binding | M/L/T/B/P | +| `width` | `SizeIntent` | `container`, `rect`, `ellipse`, `line`, `path`, `text` | Fixed or auto width intent | M/L/T/B/P | +| `height` | `SizeIntent` | `container`, `rect`, `ellipse`, `path`, `text`; never `line` | Fixed or auto height intent | M/L/T/B/P | +| `min-width`, `max-width` | `OptionalNumber` | `container`, `rect`, `ellipse`, `line`, `path`, `text` | Optional width constraint | M/L/T/B/P | +| `min-height`, `max-height` | `OptionalNumber` | `container`, `rect`, `ellipse`, `path`, `text`; never `line` | Optional height constraint | M/L/T/B/P | +| `aspect-ratio` | `OptionalAspectRatio` | `rect`, `ellipse`, `path` | Optional width/height ratio | M/L/T/B/P | +| `active` | `Boolean` | Every node | Positive activity; inverse of `hidden` | M/L/T/B/P/R | +| `rotation` | `Number` | Every node | Clockwise degrees | M/L/T/B/P | +| `flip-x`, `flip-y` | `Boolean` | Every node | Native mirror flags | M/L/T/B/P | +| `flow` | `Flow` | Every node | In-flow or absolute participation | M/L/T/B/P | +| `grow` | `Number` | Every node | Main-axis growth factor | M/L/T/B/P | +| `self-align` | `SelfAlign` | Every node | Per-child alignment from `align` | M/L/T/B/P | +| `opacity` | `Number` | Every node | Node/subtree opacity | P | +| `layout` | `Layout` | `container`/frame | Complete normalized layout behavior | M/L/T/B/P | +| `clips-content` | `Boolean` | `container`/frame | Content-clip flag from `clips` | B/P | +| `corner-radius` | `CornerRadius` | `container`/frame and `rect` | Four-corner elliptical radii | B/P | +| `corner-smoothing` | `Number` | `container`/frame and `rect` | Smoothing factor | B/P | +| `fills` | `Paints` | Fill-paintable frame, rect, ellipse, path, and text | Complete ordered fills | P/R | +| `strokes` | `Strokes` | Stroke-paintable frame, shape, and text | Complete ordered stroke geometries and paints | B/P/R | + +Rotation is any finite number. Growth is finite and non-negative. Opacity and +corner smoothing are finite in `[0, 1]`. Fixed sizes and present constraints +are finite and non-negative; a present aspect ratio contains two finite +positive numbers. Aggregate layout, corner, fill, and stroke values retain all +of their static nested and renderer-capability validation. Cross-property +rules are checked against the complete effective node state, so an otherwise +valid replacement cannot combine with an authored value to create a state the +renderer would silently omit. Keys with R impact may select logical resources +but never perform loading; resource readiness and bytes belong to a separately +revisioned evaluation environment. The exact addressing, +applicability, nullability, validation, and static-equivalence laws are +normative in [Grida XML durable addressing](./grida-xml-addressing). + ## Scene-backed property placeholders These tables reserve discussion slots for properties in the canonical scene @@ -296,19 +371,24 @@ model. They do not claim the proposed XML spelling is final. | Candidate XML property | Valid on | Production concept | Inspiration | Status | | ---------------------- | ---------------------------------------- | ---------------------------------------- | ------------ | --------------- | -| `id` | Every authored render node | Durable user node identity | HTML/SVG | **Design** | +| `id` | Every authored render node | Durable owner-local member identity | HTML/SVG | **Version 4** | | `locked` | Every authored render node, if persisted | Editor lock metadata | Design tools | **Design** | | `blend-mode` | Every compositing render node | Layer blend mode, including pass-through | CSS | **Placeholder** | | `mask-type` | A node that acts as a mask source | Geometry, alpha, or luminance mask | CSS/SVG | **Design** | | `transform` | Render nodes | Post-layout 2D affine transform | CSS/SVG | **Design** | | `transform-origin` | Boxed render nodes | Post-layout transform pivot | CSS | **Design** | -`id` must map to durable authored identity, not the runtime's transient node -handle. `transform` also cannot be admitted until its relationship to native +Version 4 `id` maps to durable authored identity, not the runtime's transient +node handle. It is required, literal, lowercase-kebab, and unique within its +scene or component owner. The same Version 4 spelling on `use` identifies the +caller-owned occurrence; the component export `id` remains a separate +module-local symbol. Component roots use the distinguished root-member +identity rather than a second attribute. The exact address and occurrence-path +contract is defined by [Grida XML durable addressing](./grida-xml-addressing). + +`transform` also cannot be admitted until its relationship to native `rotation`, `flip-x`, `flip-y`, and `lens ops` has one canonical answer. -The proposed `id` on top-level `component` is a separate module-local export -symbol; it does not make `id` valid on render nodes or settle durable node and -instance identity. +Versions 0 through 3 continue to reject `id` on render nodes and `use`. ### Primitive geometry @@ -405,6 +485,21 @@ section](./grida-xml#paint-channels-and-vocabulary). paint property, while on `solid` and `stop` it materializes through color alpha as described by the RFD's quantization rule. +A gradient `transform` must be finite and mathematically invertible. Linear +endpoints remain valid outside the unit box, but after centered-alignment +lowering their binary32 unit-space distance must be greater than `2^-15`; +shorter ramps are rejected rather than silently narrowed to the backend's +degenerate-color fallback. The same authored-state checks apply to source, +canonical writing, and a replacement `Paints` value. + +This does not promise that every transform is numerically invertible after a +particular resolved paint box is composed in a raster backend. Evaluation must +check that exact composed matrix and construct the selected gradient +implementation before publishing the frame. Extremely small or large values +can be accepted here yet fail for one box and succeed for another; such a +failure is contextual frame evaluation, not source repair or a second paint- +validity rule. + ## Stroke geometry attribute registry Each `stroke` is one geometry with direct typed paint children. An empty paint @@ -575,10 +670,10 @@ choosing a name alone. | Image resources and placement | Logical IDs, hashes, fit, affine placement, tiling | Packaging; base URI; network policy; intrinsic size; missing-resource behavior | | Boolean operations | Live child geometry and operation enum | Ordered operands; child ownership; nesting; flattening; paint inheritance | | Transform model | Native rotation, affine transforms, origin, source lens | One canonical authored form; layout-versus-paint transform; decomposition stability | -| Components and reuse | Named definitions, `use`, scalar props, and named projection | Durable use/member identity; deep overrides; archive persistence; animation binding | -| Animation | Time-varying values and motion-graphics behavior | Addressing, interpolation, timelines, expressions, easing, events, serialization | +| Components and reuse | Named definitions, `use`, scalar props, and named projection | Deep overrides; archive persistence; live-instance editing | +| Animation | Time-varying values and motion-graphics behavior | Interpolation, timelines, expressions, easing, events, composition, serialization | | Embedded Markdown/HTML | Opaque render-only content | Escaping, sanitization, fonts/resources, intrinsic sizing, deterministic rendering | -| Durable identity | Stable user IDs distinct from runtime handles | Uniqueness scope; copy/paste; merge; references; agent-generated IDs | +| Subobject identity | Paint, stroke, stop, run, vector, and lens-operation members | Stable member IDs; list edits; copy/paste; references; canonical writing | The vector-network and width-profile grammars are the clearest examples of where new syntax must be invented. Treating either as a JSON-shaped `data` @@ -591,28 +686,28 @@ This registry does not itself change the canonical scene or archive contracts. The following seams constrain conformance or require the explicit extension named by the Draft 0 RFD: -| Seam | Why it matters to Grida XML | -| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The current scene contract stores one stroke geometry with one ordered `Paints`, while Draft 0 repeats them. | One stroke maps directly; multiple strokes require an ordered list and must not be merged or hidden as duplicate scene nodes. | -| The durable stroke-width boundary collapses four equal sides to a uniform or no-width state. | Draft 0 uses the same normalization and never emits an all-equal four-value canonical form. | -| Production per-side coverage ignores corner smoothing and non-miter join choice. | Draft 0 rejects nonzero smoothing, round or bevel joins, and non-default miter limits on a per-side stroke rather than preserving lossy state. | -| Production smooth corners circularize elliptical radius pairs. | Draft 0 rejects nonzero smoothing when any `rx` and `ry` differ; smoothed ellipses remain unavailable pending lossless renderer support. | -| Production smooth paths begin dash traversal at the top-edge midpoint. | Draft 0 consistently begins every rectangle at the top-left curve's top-edge join; geometry is identical, but dashed smoothed strokes have a different phase. | -| The scene model can hold malformed gradients that Draft 0 deliberately cannot spell. | A writer rejects too few stops, invalid or descending offsets, and coincident linear endpoints instead of repairing, sorting, clamping, or perturbing them. | -| Scene semantics and packed-archive coverage do not yet cover the same node set. | An authored element must not promise `.grida` round-trip until both contracts represent it. | -| Scene and archive contracts expose both raw intrinsic paths and normalized box-mapped paths. | Draft 0 selects the box-mapped form; a raw-only target must gain a stable mapping or reject `path` rather than claim lossy support. | -| One durable canonical-shape contract permits non-unit path data to be scaled to its tight fit. | Draft 0 deliberately rejects that fallback: non-unit geometry is invalid, and no reader may erase padding or remap untouched contours by auto-fitting it. | -| Path fill-rule persistence is not uniform across scene and archive contracts. | Draft 0 requires `nonzero` and `evenodd` to survive reading, rendering, inspection, and rewriting; hard-coding one rule is not conforming. | -| Draft 0 `hidden` removes a subtree from layout and paint. | A materializer must preserve that stronger behavior even when a target model names visibility differently. | -| Solid-paint alpha is stored in color, while Draft 0 exposes common paint `opacity`. | The documented 8-bit quantization is a boundary rule, not a parallel solid-opacity property. | -| Production `AttributedString` stores flat contiguous UTF-8 byte ranges. | XML concatenates mixed text in document order and derives ranges at character boundaries; authored offsets and nested runs are neither needed nor accepted. | -| The packed archive collapses an empty run-fill vector to an absent override. | Draft 0 distinguishes explicit `` no ink from omitted node-fill fallback; packing must reject it or preserve presence before claiming round-trip. | -| Production attributed paint currently resolves run fills against `(width, width)`. | Draft 0 requires the resolved full text-node width and height, matching node fills; the square paint box is an implementation bug, not XML semantics. | -| `StyledTextRun` has one optional stroke stack plus one width and alignment. | It cannot losslessly project repeatable independent XML stroke geometries, so Draft 0 rejects run strokes rather than merging or hiding them. | -| Production attributed runs carry visual style but no semantic/accessibility annotation. | Draft 0 rejects HTML semantic tags instead of pretending bold or italic styling preserves their meaning. | -| Responsive bindings and per-child alignment carry more source intent than a resolved Cartesian scene. | A resolved scene can consume their result but must not be mistaken for a lossless rewrite of the authored source. | -| Names, locks, durable identity, guides, and connections belong to different persistence concerns. | `id`, `name`, and `locked` need explicit authored-versus-editor rules; a root fill already covers visual background without deciding editor-only metadata. | -| `lens` operations and native flips may lower through more general transforms. | Materialization can preserve pixels without promising a lossless inverse decomposition; source round-trip requires an intent-preserving representation. | +| Seam | Why it matters to Grida XML | +| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| The current scene contract stores one stroke geometry with one ordered `Paints`, while Draft 0 repeats them. | One stroke maps directly; multiple strokes require an ordered list and must not be merged or hidden as duplicate scene nodes. | +| The durable stroke-width boundary collapses four equal sides to a uniform or no-width state. | Draft 0 uses the same normalization and never emits an all-equal four-value canonical form. | +| Production per-side coverage ignores corner smoothing and non-miter join choice. | Draft 0 rejects nonzero smoothing, round or bevel joins, and non-default miter limits on a per-side stroke rather than preserving lossy state. | +| Production smooth corners circularize elliptical radius pairs. | Draft 0 rejects nonzero smoothing when any `rx` and `ry` differ; smoothed ellipses remain unavailable pending lossless renderer support. | +| Production smooth paths begin dash traversal at the top-edge midpoint. | Draft 0 consistently begins every rectangle at the top-left curve's top-edge join; geometry is identical, but dashed smoothed strokes have a different phase. | +| The scene model can hold malformed or backend-degenerate gradients that Draft 0 deliberately cannot spell. | A reader and writer reject too few stops, invalid or descending offsets, non-invertible transforms, and linear endpoint distances at or below `2^-15` instead of repairing, sorting, clamping, or perturbing them. | +| Scene semantics and packed-archive coverage do not yet cover the same node set. | An authored element must not promise `.grida` round-trip until both contracts represent it. | +| Scene and archive contracts expose both raw intrinsic paths and normalized box-mapped paths. | Draft 0 selects the box-mapped form; a raw-only target must gain a stable mapping or reject `path` rather than claim lossy support. | +| One durable canonical-shape contract permits non-unit path data to be scaled to its tight fit. | Draft 0 deliberately rejects that fallback: non-unit geometry is invalid, and no reader may erase padding or remap untouched contours by auto-fitting it. | +| Path fill-rule persistence is not uniform across scene and archive contracts. | Draft 0 requires `nonzero` and `evenodd` to survive reading, rendering, inspection, and rewriting; hard-coding one rule is not conforming. | +| Draft 0 `hidden` removes a subtree from layout and paint. | A materializer must preserve that stronger behavior even when a target model names visibility differently. | +| Solid-paint alpha is stored in color, while Draft 0 exposes common paint `opacity`. | The documented 8-bit quantization is a boundary rule, not a parallel solid-opacity property. | +| Production `AttributedString` stores flat contiguous UTF-8 byte ranges. | XML concatenates mixed text in document order and derives ranges at character boundaries; authored offsets and nested runs are neither needed nor accepted. | +| The packed archive collapses an empty run-fill vector to an absent override. | Draft 0 distinguishes explicit `` no ink from omitted node-fill fallback; packing must reject it or preserve presence before claiming round-trip. | +| Production attributed paint currently resolves run fills against `(width, width)`. | Draft 0 requires the resolved full text-node width and height, matching node fills; the square paint box is an implementation bug, not XML semantics. | +| `StyledTextRun` has one optional stroke stack plus one width and alignment. | It cannot losslessly project repeatable independent XML stroke geometries, so Draft 0 rejects run strokes rather than merging or hiding them. | +| Production attributed runs carry visual style but no semantic/accessibility annotation. | Draft 0 rejects HTML semantic tags instead of pretending bold or italic styling preserves their meaning. | +| Responsive bindings and per-child alignment carry more source intent than a resolved Cartesian scene. | A resolved scene can consume their result but must not be mistaken for a lossless rewrite of the authored source. | +| Names, locks, durable identity, guides, and connections belong to different persistence concerns. | `id`, `name`, and `locked` need explicit authored-versus-editor rules; a root fill already covers visual background without deciding editor-only metadata. | +| `lens` operations and native flips may lower through more general transforms. | Materialization can preserve pixels without promising a lossless inverse decomposition; source round-trip requires an intent-preserving representation. | When the language, runtime, and archive disagree, a processor must limit its claimed subset or make the model change explicit. It must never create a diff --git a/docs/wg/format/grida-xml.md b/docs/wg/format/grida-xml.md index fa35d48312..6917fa64f9 100644 --- a/docs/wg/format/grida-xml.md +++ b/docs/wg/format/grida-xml.md @@ -693,8 +693,8 @@ back does not restore discarded state. All gradient geometry is defined in the unit paint box `[0,1] × [0,1]`, with `(0,0)` at top-left and `(1,1)` at bottom-right. The optional -`transform="a b c d e f"` is a finite affine transform in that unit space, -represented as: +`transform="a b c d e f"` is a finite, mathematically invertible affine +transform in that unit space, represented as: ```text [ a c e ] @@ -706,11 +706,28 @@ its translation and scale with this transform, including the declared degenerate-axis fallback, so gradient geometry remains independent of the node's pixel size. +Mathematical invertibility is the authored-state invariant. Once layout has +produced the actual paint box, an evaluator must also verify that the exact +paint-box/transform composition is invertible in its backend representation. +Extreme finite transforms can be representable for one box and not another; +box scale may rescue or defeat their numerical conditioning. An evaluator +must also prove that its selected implementation can construct every gradient +kind, including custom/runtime-effect kinds. It must report any contextual +failure before drawing the frame. It must not rewrite the transform, omit the +gradient, or treat the source document as syntactically invalid. Applying one +uniform capability rule to every gradient kind preserves kind-switch behavior; +it does not claim that every backend kind reaches failure through the same +factory path. + A linear gradient additionally carries `from="u v"` and `to="u v"`. They default to `0 0.5` and `1 0.5`. Each requires exactly two finite values; values -outside `[0,1]` are allowed to place an endpoint outside the paint box, but the -two endpoints must differ. They are first-class linear-kind state, not a -second spelling of the transform. +outside `[0,1]` are allowed to place an endpoint outside the paint box. After +the model's centered-alignment lowering and binary32 unit-space projection, +the endpoint distance must be greater than `2^-15`. Coincident, quantized- +coincident, and shorter ramps are invalid because the current proving backend +otherwise replaces them with a degenerate color shader before applying the +declared transform. They are first-class linear-kind state, not a second +spelling of the transform. The remaining base geometries are fixed before the transform: @@ -753,11 +770,15 @@ paint model or XML vocabulary. #### Compatibility quarantine: unconstrained gradient model state The current paint data types can hold gradient values that Draft 0 deliberately -rejects: fewer than two stops, out-of-range or descending stop offsets, and -coincident linear endpoints. Those states have no valid Grida XML spelling. A +rejects: fewer than two stops, out-of-range or descending stop offsets, +mathematically non-invertible transforms, and linear endpoints at or below the +declared degeneracy threshold. Those states have no valid Grida XML spelling. A canonical writer must report the failed invariant and must not invent stops, -sort or clamp offsets, or perturb an endpoint. Complete model-to-XML round-trip -requires these invariants to be established in the common paint model first. +sort or clamp offsets, replace a transform, or perturb an endpoint. Complete +model-to-XML round-trip requires these invariants to be established in the +common paint model first. Backend failure after composition with a pathological +resolved paint box is not part of this quarantine: the authored gradient +remains valid and frame evaluation reports the contextual capability failure. #### Compatibility quarantine: gradient editing model @@ -807,6 +828,18 @@ exists, its resolved location. It must not silently discard the image paint and claim a complete resolved result. An interactive host may display a diagnostic placeholder while keeping the unresolved state explicit. +Image sampling also has an evaluation-time numeric condition. Immediately +before drawing, a strict evaluator composes the requested view, the image +owner's resolved world transform, and the image-fit transform using the +backend's numeric representation. When the geometry transform itself is +invertible but that complete sampling matrix is not, evaluation fails with the +node, paint context, and resource identifier before any frame pixels are +emitted. A different view may rescue or defeat an extreme fit matrix, so this +check cannot be reduced to XML validation or permanently attached to a view- +independent frame product. Singular geometry transforms remain valid collapsed +coverage; they are not reported as image failures merely because they have no +2D inverse. + The meaning of `image` is contextual. Inside `fill` or `stroke` it is always a paint. Outside those channels it occupies the scene-element namespace, where a later version may define an image node. Draft 0 defines no scene-image node and @@ -1448,7 +1481,8 @@ declared Draft 0 scene or reports a typed failure. It must reject: invalid paint blend mode, or a non-boolean paint visibility value; - a gradient with a missing or unknown `kind`, an attribute not valid for its kind, fewer than two stops, a stop outside `[0,1]`, descending stop offsets, - coincident linear endpoints, or a malformed gradient affine; + a non-invertible gradient affine, or linear endpoints at or below the + declared unit-space degeneracy threshold; - an empty image `src` or unsupported image fit; - a fill on `line`, any fill or stroke on `group` or `lens`, an empty stroke whose geometry equals its target defaults, a stroke attribute invalid for its @@ -1637,8 +1671,10 @@ rectangles, variable-width strokes, dash offset, endpoint markers, effects, scene image nodes, resource declaration or packaging syntax, advanced image placement, image filters, rich color spaces, advanced typography, per-run strokes, semantic text annotations, grid layout, animation, or durable node -identity. Their eventual addition must preserve the one-tree, local-space, -intent-only model established here. +identity. The later [durable-addressing RFD](./grida-xml-addressing) accepts +required render/use identity for exact Version 4 while leaving Draft 0 +unchanged. Every other eventual addition must preserve the one-tree, +local-space, intent-only model established here. Reusable paint definitions and references are also deferred. Inline paint values remain sufficient and canonical; a later reference system must define @@ -1677,8 +1713,9 @@ works. Adding any such behavior is a versioned language change. stable source vocabulary without weakening the typed-paint model? - How are fonts and external image resources declared or packaged so that resolution remains portable while relative identifiers remain authorable? -- What durable identity contract supports agent edits and component - instances without turning transient runtime handles into file facts? +- What durable member identity and selector grammar should address paints, + strokes, gradient stops, text runs, vector members, and lens operations + without using unstable list indexes? - Which resolved inspection form should accompany the intent source without being mistaken for it? - Does the compound `.grida.xml` suffix remain the best long-term authored diff --git a/docs/wg/format/index.md b/docs/wg/format/index.md index 70ee1e0352..d865655243 100644 --- a/docs/wg/format/index.md +++ b/docs/wg/format/index.md @@ -22,6 +22,7 @@ Tracking docs for the Grida IR schema and how external formats map into it. | [Grida XML modules](./grida-xml-modules) | Open linking/component RFD with a proving implementation | | [Grida XML component parameters](./grida-xml-component-parameters) | Open typed prop/arg RFD with a proving implementation | | [Grida XML component slots](./grida-xml-component-slots) | Open named slot projection RFD with a proving implementation | +| [Grida XML durable addressing](./grida-xml-addressing) | Version 4 node/use identity and typed effective-value RFD | | [Grida XML animation](./grida-xml-animation) | Open day-one animation question set for `.grida.xml` | ## Import mappings diff --git a/model-v2/a/ENGINE.md b/model-v2/a/ENGINE.md index 32204391ff..97a8fc34f3 100644 --- a/model-v2/a/ENGINE.md +++ b/model-v2/a/ENGINE.md @@ -44,11 +44,11 @@ sockets** any answer plugs into. ## ENG-0 · the meta-layer: stage purity and the oracle law The one asset every layer below trades on: **the pipeline is a chain of -pure stages**. `document → resolve → drawlist → raster/composite`, plus -the read tier (`pick`, spatial queries) off the resolved columns. The -lab already holds the first link (`resolve(document, fonts, resources, -viewport) → Resolved` is a pure function `[MEASURED]`); phase 4 extends -the chain, never breaks it. +pure stages**. `(document + immutable effective values) → resolve → drawlist +→ raster/composite`, plus the read tier (`pick`, spatial queries) off the +resolved columns. An empty effective-value set reads the authored document +exactly. The lab holds the first link as a pure function `[MEASURED]`; phase 4 +extends the chain, never breaks it. - **ENG-0.1 · stage purity** `[CONTRACT]` — every stage is a pure function of the previous stage plus declared inputs. No stage reads @@ -152,20 +152,30 @@ model before adopting. **Major contracts.** -- **ENG-2.1 · drawlist is a pure stage** `[CONTRACT]` — - `resolved → drawlist` is deterministic and diffable, exactly like - resolve (ENG-0.1). Paint reads the resolved tier and the drawlist — - never the document. -- **ENG-2.2 · damage flows forward only** `[CONTRACT]` — op → dirty - class (ENG-1.2) → resolved-tier diff → drawlist diff → screen rects. - No stage invents damage; no stage widens it silently. Damage is data, - loggable and assertable per frame. -- **ENG-2.3 · generational cache keys** `[CONTRACT]` — cache identity - is `(slot, generation)`, never the bare NodeId. The lab arena - tombstones deleted slots but does not yet promise non-reuse - `[MEASURED]` — day 1: add the generation counter (or an explicit - append-only promise) so a reused slot can never alias another node's - cached raster/layout artifacts. +- **ENG-2.1 · drawlist is a pure stage** `[CONTRACT]` — the same immutable + effective model view plus its `Resolved` product deterministically projects + one diffable drawlist, exactly like resolve (ENG-0.1). The raster executor + reads only that drawlist and declared resources; it never re-reads authored + properties or independently applies effective values. A complete frame + checks that the resource-environment incarnation and revision still match + the key captured at build before replay begins. +- **ENG-2.2 · damage flows forward only** `[CONTRACT]` — authored op or + effective-value change → declared impact class → resolved-tier diff plus + drawlist diff → screen rects. Resolved state, the ordered drawlist, and the + declared resource-environment revision travel as one immutable frame + product; complete damage compares two products rather than separately + pairable parts. Changing bytes or readiness under one logical RID must + invalidate retained raster state and conservatively damage its consumers. + The impact class may skip work only after an optimization proves equality + with the complete product diff. No stage invents damage or silently drops a + paint-only change. Damage is data, loggable and assertable per frame. +- **ENG-2.3 · arena-scoped generational cache keys** `[CONTRACT]` — cache + identity is `(arena incarnation, slot, generation)`, never a bare NodeId or + a slot/generation pair detached from its arena. Slot generation prevents + reuse inside one arena; arena incarnation prevents a fresh materialization + or divergent document clone from validating an old key whose slot and + generation happen to match. Generation increment never wraps. The runtime + identity remains a storage artifact, not authored identity. - **ENG-2.4 · the compositor owns pacing** `[CONTRACT]` — one frame entry point schedules input → resolve → paint → present; hosts adapt to it, not vice versa. The legacy `FrameLoop` unification is the @@ -197,11 +207,12 @@ at large N `[PEER]`. Browsers hit-test by paint-order tree walk — the editor case (marquee, snap, cull, minimap) outgrows that quickly `[PEER]`. -**Evidence on file.** The read tier is already SOA: `world_aabb` is a -flat column indexed by NodeId `[MEASURED]` — literally the input array -a broadphase wants. `pick` exists as a model concern (oriented -inverse-world test, lens post-ops, transparent-select promotion to the -outermost derived) with a linear walk `[MEASURED]` — correct, and the +**Evidence on file.** The read tier is already SOA: `world_aabb` is a flat +column indexed by NodeId `[MEASURED]` — literally the input array a broadphase +wants. Resolution also snapshots child order, transparent-select behavior, and +effective descendant clips. `pick` consumes only that resolved frame and owns +the oriented inverse-world test, lens post-ops, transparent-select promotion to +the outermost derived, and the linear walk `[MEASURED]` — correct, and the permanent narrowphase + oracle. **Major contracts.** @@ -221,9 +232,10 @@ permanent narrowphase + oracle. narrowphase (today's `pick.rs`), NOT in consumers and NOT in the index. An index swap must never change what gets selected. - **ENG-3.4 · spatial reads read the read tier only** `[CONTRACT]` — - the index is built from resolved columns (`world_aabb`), never from - intent. It rebuilds/refits from a resolve diff (ENG-2.2's damage - feed), so it can never disagree with what was resolved. + the index and narrowphase read resolved columns and the resolved traversal/ + clip snapshot, never a separately pairable document or effective-value view. + The index rebuilds/refits from a resolve diff (ENG-2.2's damage feed), so it + can never disagree with what was resolved. **Growth path.** linear walk behind the API (today, correct) → per-frame rebuilt BVH over the SOA column (morton/LBVH — the column @@ -317,9 +329,10 @@ delta-form, enumerated write-sets, "a drag ends as if written once" is law `[MEASURED]`. The spike ships snapshot undo (documents are values) `[MEASURED]`. The C-matrix demonstrated field-level merges in tests but has NEVER met a real replicated backend — named in the REPORT lose -column `[MEASURED]`. Stable identity is the named gap: NodeId is a -session-stable arena slot; format/IR stable ids are open (a.md §12) -`[MEASURED]`. +column `[MEASURED]`. Grida XML Version 4 now defines durable structured source +addresses separately from arena-scoped runtime keys `[MEASURED]`; the current +operation and replay wires remain slot-addressed and therefore in-process +only. **Major contracts.** @@ -336,10 +349,12 @@ session-stable arena slot; format/IR stable ids are open (a.md §12) (structure-aware op fuzzing), and conformance fixture. No parallel bespoke trace formats. - **ENG-5.4 · stable identity precedes distribution** `[CONTRACT]` — - ops address nodes by stable id. Session-local slot ids suffice for - in-process history; the format-level id story (a.md §12) must lock - BEFORE any cross-session replay, persistence-of-history, or - multiplayer claim. Until then those features are walled, not fudged. + distributed ops address nodes by stable authored identity. Session-local + slot ids suffice for in-process history. Version 4 locks the source and + component-occurrence address shape, but no cross-session replay, + persistence-of-history, or multiplayer claim exists until operation and + replay schemas deliberately adopt that contract. Those features remain + walled, not inferred from the source parser. - **ENG-5.5 · snapshots are the honest floor** `[CONTRACT]` — history = invertible op log where proven, document snapshots where not (the spike's posture). An op is only "invertible" once its inverse is diff --git a/model-v2/a/lab/README.md b/model-v2/a/lab/README.md index 6a4d5e34c4..6827e4f950 100644 --- a/model-v2/a/lab/README.md +++ b/model-v2/a/lab/README.md @@ -31,9 +31,17 @@ Map: - `src/ops.rs` — gesture ops with typed errors + write-count doctrine - `src/textir.rs` — the agent text IR parser + canonical printer (E3) - `src/grida_xml.rs` — strict Draft 0 `.grida.xml` parser/writer boundary -- `src/grida_xml_source.rs` — pure retained Version 1/2/3 source-program - linker, typed scalar specializer, named static slot projector, and - ordinary-scene materializer; hosts supply immutable dependency snapshots +- `src/grida_xml_source.rs` — pure retained Version 1/2/3/4 source-program + linker, typed scalar specializer, named static slot projector, durable + authored-address index, and ordinary-scene materializer; hosts supply + immutable dependency snapshots +- `src/properties.rs` — closed node-property registry, immutable sorted value + sets, and the validated `ValueView` consumed by resolution and the engine; + picking reads only the effective traversal and clips captured in `Resolved` +- `src/renderability.rs` — shared whole-state geometry, paint, corner, and + stroke capability validation for source and effective values +- `src/rounded_box.rs` — normalized ordinary and smooth-corner geometry shared + by query containment and engine paths - `src/svgout.rs` — SVG snapshots of resolved documents - `src/math.rs` / `src/measure.rs` — affine + deterministic text metric - `tests/` — the conformance-derived suites; `tests/common/mod.rs` helpers @@ -107,7 +115,7 @@ mapped once into the final box; that resolved command artifact supplies bounds and rendering without reparsing or independently rescaling geometry. Filesystem I/O, resources, and rasterization remain host concerns. -## Retained Version 1/2/3 source programs +## Retained Version 1/2/3/4 source programs `grida_xml_source` is a separate source-level boundary above Draft 0. It retains immutable source snapshots, resolves Version 1 `component`/`use` @@ -133,6 +141,29 @@ accepted only by Version 3 targets. Version 1/2 callers cannot link Version 3 definitions. These compatibility checks happen at the source-program boundary without reinterpreting an older source grammar. +Version 4 callers may link and render-assign only Version 4 component sources; +Version 1–3 callers still cannot link Version 4 definitions. This intentional +compatibility break gives every valid Version 4 materialization a complete +durable-identity closure: every ordinary node except the implicit document +root has exactly one `MaterializedNodeAddress`. Versions 1–3 retain their +existing compatibility matrix unchanged. + +Version 4 retains the Version 3 grammar and adds durable authored identity. +Every render element and `` has a lowercase-kebab `id`; a component's +existing export `id` continues to identify the component, while its render +root has the structural `ComponentRoot` member identity. Render-member and +use-occurrence ids share one namespace within each lexical scene/component +owner. Versions 0–3 do not accept or generate these ids. + +A `MaterializedNodeAddress` is a typed authored member plus an outer-to-inner +path of typed authored use occurrences. It contains no source span, element +position, name, or arena slot. `MaterializedProgram` indexes both directions +between that address and a live `NodeKey`; duplicate addresses fail +materialization, and deletion makes the retained address fail closed as +stale. `addresses()` enumerates only generation-valid live pairs after public +document mutation; direct lookup of a retained removed address still reports +`StaleAddress`. Older-version nodes have no durable address. + This does not broaden `grida_xml::parse`, change `grida_xml::VERSION`, add component variants to the node model, or make materialized copies canonical source. Draft 0 passed through this higher source-program boundary receives @@ -151,6 +182,73 @@ string-only, so a specialization failure retains the complete candidate binding set for its failing specialization or projected subtree rather than a minimal causal subset. +## Node property values + +`NodeKey` is `(arena, slot, generation)` with opaque fields and +`Document`-only minting/validation. Independently built documents have +different arena identities. `Document::clone` deliberately mints a new arena +identity too: semantic equality remains true, but values must be rebound to +the clone before use. Tombstoning increments generations with checked +arithmetic before removal. + +`PropertyValues::new(&Document, entries)` produces an immutable `BTreeMap` +projection with sorted unique `PropertyTarget { node: NodeKey, property }` +keys. Construction and `ValueView::new` reject duplicate or stale targets, +wrong value variants, inapplicable keys, non-finite numbers, invalid ranges, +invalid nested paint/stroke domains, and invalid whole-node effective geometry, +corner, stroke, and paint combinations. The model-owned `renderability` +module is the shared capability fence for source writing and effective values. +Map absence reads authored base. +`OptionalNumber(None)` and `OptionalAspectRatio(None)` explicitly clear a +registered nullable value and therefore remain distinct from map absence. + +`ValueView::base(&Document)` is the empty projection. `resolve_view` reads the +validated view; the existing `resolve` and `resolve_with_text_layout` functions +are exact thin wrappers over `ValueView::base`. Resolution also snapshots child +order, transparent-select behavior, and effective descendant-clip geometry in +`Resolved`. `pick(&Resolved, ...)` therefore has no document or value-view input +that can be paired with the wrong evaluation. The view's closed typed accessors +are public for renderer consumers; the arbitrary key-to-variant matcher remains +internal to the facade. + +Impact legend: M = measure, L = layout, T = transform, B = bounds, P = paint, +R = resource. Sets are conservative. + +| keys | `PropertyValue` variant | applicability | impact | +| ------------------------ | ----------------------- | -------------------------------------- | ----------- | +| `X`, `Y` | `AxisBinding` | every node | M/L/T/B/P | +| `Width` | `SizeIntent` | frame, rect, ellipse, line, path, text | M/L/T/B/P | +| `Height` | `SizeIntent` | frame, rect, ellipse, path, text | M/L/T/B/P | +| `MinWidth`, `MaxWidth` | `OptionalNumber` | frame, rect, ellipse, line, path, text | M/L/T/B/P | +| `MinHeight`, `MaxHeight` | `OptionalNumber` | frame, rect, ellipse, path, text | M/L/T/B/P | +| `AspectRatio` | `OptionalAspectRatio` | rect, ellipse, path | M/L/T/B/P | +| `Active` | `Boolean` | every node | M/L/T/B/P/R | +| `Rotation` | `Number` | every node | M/L/T/B/P | +| `FlipX`, `FlipY` | `Boolean` | every node | M/L/T/B/P | +| `Flow` | `Flow` | every node | M/L/T/B/P | +| `Grow` | `Number` | every node | M/L/T/B/P | +| `SelfAlign` | `SelfAlign` | every node | M/L/T/B/P | +| `Opacity` | `Number` | every node | P | +| `Layout` | `Layout` | frame | M/L/T/B/P | +| `ClipsContent` | `Boolean` | frame | B/P | +| `CornerRadius` | `CornerRadius` | frame or rect | B/P | +| `CornerSmoothing` | `Number` | frame or rect | B/P | +| `Fills` | `Paints` | fill-paintable node | P/R | +| `Strokes` | `Strokes(Vec)` | stroke-paintable node | B/P/R | + +The registry is the supported node-level set, not a claim that every model +field is projectable. Text content/style, shape descriptors, lens operations, +and nested paint/stroke/stop members remain outside it. + +## Anti-goals + +- No string paths, reflective field access, or host-defined property provider. +- No structural values: payload kind, parent, children, and order remain + authored document facts. +- No generated identities for Versions 0–3 and no arena identity in source. +- No nested paint/stroke/stop targets before those subobjects have durable + identity. + Known lab simplifications (declared in the REPORT's lose column): children as ordered `Vec` (no fractional index), per-container Taffy trees (engine uses one tree), no incremental invalidation, no diff --git a/model-v2/engine/ANIMATION.md b/model-v2/engine/ANIMATION.md index d1276d0af4..8e8525d57e 100644 --- a/model-v2/engine/ANIMATION.md +++ b/model-v2/engine/ANIMATION.md @@ -1,9 +1,9 @@ # ANIMATION — explicit time through the anchor engine -**Status:** Open engine RFD. This document proposes an implementation shape -and the contracts that shape would need. No animation syntax, timing model, -property registry, or runtime API is adopted yet, and no implementation exists -in the engine today. +**Status:** Open animation RFD. No animation syntax, timing model, program, +sampler, interpolation, composition, or playback API is adopted. The +pre-animation identity, typed-property, and immutable effective-value +foundation is specified separately; it contains no time semantics. This is the engine-side companion to the [Grida XML animation RFD](https://grida.co/docs/wg/format/grida-xml-animation). @@ -20,6 +20,9 @@ It is deliberately implementation-facing. It may name the current engine stages and candidate Rust types; it does not define `.grida.xml` syntax or make format decisions on the format RFD's behalf. +The implemented input boundary this proposal must reuse is specified in +[`EFFECTIVE-VALUES.md`](./EFFECTIVE-VALUES.md). + ## Short answer Animation should enter the engine as **an explicit sample-time input to a pure, @@ -38,7 +41,7 @@ Document + AnimationProgram + SampleTime + declared environment sample | v - sparse typed SampledValues + immutable typed PropertyValues | v resolve -> drawlist -> raster / composite @@ -52,16 +55,34 @@ to determine visual state, never writes sampled values back into the authored document, and never lets the painter sample animation independently. The first implementation should run the full reference pipeline after every -sample. Incremental sampling, scoped resolve, paint-only damage, and +sample. Incremental sampling, scoped resolve, partial repaint, and compositor-only execution are later optimizations, each differential-tested against that permanent reference path. ## What exists today -The anchor engine is static: +The anchor engine remains static, but its pre-animation value boundary now +exists: - `frame::render` takes a `Document`, resolve options, a view, and paint context, then runs `resolve -> build -> execute`; +- `PropertyValues` is the immutable, sorted, unique map from arena-scoped + generational node-property targets to exact typed effective values; +- `ValueView` validates that map and makes an absent entry read the authored + base value; +- value-aware resolve, frame, drawlist, and cache entries consume one + `ValueView`, while their static entries are exact `ValueView::base` wrappers; +- resolution captures the traversal and effective clip state needed by query, + so the spatial read tier accepts only `Resolved` rather than a separately + pairable document or value view; +- `damage::diff_frame` compares immutable `FrameProduct`s, so fills, opacity, + strokes, clips, painter order, text, path, and paint-environment changes + cannot disappear behind unchanged geometry; +- `PaintEnvironmentKey` identifies one host resource/font context plus its + checked revision, and each `FrameProduct` carries the key belonging to that + frame and refuses raster execution under a different key; +- the retained scene cache keys both the runtime document incarnation and the + exact `PropertyValues` and `PaintEnvironmentKey`; - its uses of `std::time::Instant` measure stage duration only; there is no semantic time input; - `journal` and `replay` record document operations. ENG-5's “time as data” is @@ -69,12 +90,12 @@ The anchor engine is static: - the probe's `anim_*` cases mutate the source document between measured frames. They are useful workloads, but they are not an animation model or sampler; -- `damage::diff` compares resolved geometry columns. A paint-only animation - can change pixels while current damage remains empty. +- `damage::diff` remains the geometry-only compatibility primitive; callers + that claim visual damage use complete `damage::diff_frame` products. + Geometry-only tests may continue to exercise `diff` directly. -Therefore there was no dedicated “how to build animation” document before -this RFD. The existing sockets are useful, but none should be mistaken for an -implemented animation runtime. +None of those types sample time or describe animation. There is still no +animation program, sampler, interpolation, composition, or playback runtime. ## Scope @@ -112,7 +133,8 @@ The names below separate facts that must not collapse into one value: authored animation; - **track/effect** — one time-varying contribution to one target property; - **sampled value** — a value produced for one property at one sample time; -- **sample overlay** — the sparse set of sampled values for a frame; +- **sample overlay** — the future sampler's sparse `PropertyValues` output for + a frame; - **sampled scene** — the resolved scene produced from base values plus the overlay; - **impact class** — the phases a changed sampled value invalidates, such as @@ -155,20 +177,18 @@ the same result as playing continuously to `t`. Stateful playback helpers may cache prior samples, but their output must be differentially equal to the stateless reference sampler. -### A-4 · properties and values are typed `[PROPOSED]` +### A-4 · animation extends the typed property registry `[PROPOSED]` -An animation program targets a registered property key and carries the value -type that property accepts. The runtime does not use reflective string paths -to write arbitrary document fields. +The pre-animation foundation already requires registered property keys, exact +value types, applicability, base access, validation, deterministic equality, +and conservative impact classes. An animation program targets that registry; +it does not create reflective string paths or a parallel value model. -One registry should own, for each animatable property: +Animation must add, for each admitted animatable key: -- the base-value type and accessor; -- validation rules; - interpolation and discrete-fallback rules; -- allowed composition operations; -- its impact class; -- equality and deterministic encoding behavior. +- allowed composition operations; and +- any animation-specific deterministic encoding/oracle version. The XML vocabulary may map source names to this registry, but must not create a second set of interpolation semantics. @@ -246,42 +266,45 @@ than disappear during paint. Whether compilation belongs to the model crate or an engine module remains open. Its input and output must be format-neutral either way. -### 3. Sparse `SampledValues` +### 3. Existing sparse `PropertyValues` -The reference sampler produces a sparse overlay keyed by resolved target -identity and typed property key. An absent entry means “use the base value.” -The overlay is ephemeral frame data, not a cloned or mutated `Document`. +The pre-animation foundation already supplies the exact data contract a future +reference sampler must produce: immutable `PropertyValues`, keyed by an +arena-scoped generational node identity and typed property key. An absent entry +means “use the authored base value” through `ValueView`. The values are +ephemeral frame input, not a cloned or mutated `Document`. A sparse overlay makes the null-animation law cheap and explicit: ```text -sample(empty_program, any_time) == empty_overlay -resolve(document, empty_overlay) == resolve(document_today) +sample(empty_program, any_time) == PropertyValues::default() +resolve(ValueView::new(document, empty_values)) == resolve(document) ``` -The exact storage layout is open. A first implementation should favor a plain, -inspectable ordered value over a cache-friendly but opaque structure. SOA, -track-local caches, and change masks come only after measurements. +The reference storage is a plain deterministic ordered map with duplicate, +type, applicability, value-domain, arena, and generation validation. A future +sampler must produce this contract rather than introduce a parallel +`SampledValues` abstraction. SOA, track-local caches, and change masks remain +measured optimizations behind the same observable values. -## Identity is a blocker, not an implementation detail +## Node identity is decided; subobject identity remains bounded -An executable track needs a stable target. The current `NodeId` is an engine -slot identity and must not silently become the serialized animation identity. -Parse-assigned IDs are sufficient for some in-memory tests, but not obviously -for source edits, subtree copies, component instances, cross-target timelines, -or durable replay. +The Version 4 source contract now separates authored owner/member identity, +ordered component-use occurrence paths, typed property keys, and +arena-incarnation-scoped generational runtime keys. An executable node-property +target can therefore compile without serializing `NodeId` or a runtime key. -Before implementation, the format/model work must answer at least: +Animation still must answer: - whether nested animation may target only its containing node; - whether cross-target references exist; -- what survives parse/print, copy/paste, and component instantiation; -- how a compiled target is invalidated when authored identity changes; -- whether a target may be a paint layer, gradient stop, text range, or only a - scene node. +- what source spelling carries a structured target and how copy/paste retargets + animation; and +- whether a target may be a paint layer, gradient stop, text range, lens + operation, or only one of the registered node-level aggregate values. -The engine may compile durable authored identity to a generational runtime key, -but it must preserve the distinction between the two domains. +Paint, stroke, stop, run, and lens-operation indexes remain invalid durable +targets. Each needs member identity before animation can target it directly. ## Sampling and resolve order @@ -299,12 +322,11 @@ Sampling must precede resolve for layout-affecting properties. A paint-only or compositor-eligible property may later skip work, but that is an optimization selected from its impact class, not a different semantic path. -This exposes the first concrete incompatibility with the current model API: -`anchor_lab::resolve(&Document, &ResolveOptions)` has no sampled-value input. -Layout-aware animation requires either a format-neutral overlay input or an -equivalent typed value-provider seam in the model resolver. That model change -must be agreed explicitly; the engine should not mutate a temporary document -to avoid it. +The effective-value view resolves the prior model incompatibility: layout, +transform, bounds, draw-list projection, and spatial queries can consume one +immutable typed overlay while the authored `Document` remains unchanged. +Animation still owes the pure sampler that produces that overlay at a declared +time. ## A candidate frame seam @@ -404,23 +426,27 @@ RFD. ## Damage, caching, and compositor lowering -The current geometry-only `damage::diff` is insufficient for animation. A -complete reference diff needs to cover: +The reference `damage::diff_frame` already compares complete immutable +`FrameProduct`s. It covers effective paint-only changes and geometry, and +compares the product-owned opaque `PaintEnvironmentKey`; a mismatch +conservatively damages every node owning draw items before or after. This +covers resource readiness, same-RID byte replacement, and font-context +revision without coupling damage to `PaintCtx`. Animation must retain those +contracts and additionally account for: -- changed sampled properties, including paint-only values; - before and after visual bounds; - drawlist item changes and ordering; - resource readiness changes; - disappearance and appearance at timing boundaries. -The first correct implementation can conservatively repaint the viewport. -Once paint-aware damage exists, damage may narrow while remaining a forward -product of sample and resolve diffs. No painter invents damage after the fact. +The first animation implementation may still run the full reference pipeline. +Later partial repaint may narrow damage while remaining observationally equal +to `diff_frame`. No painter invents damage after the fact. Cache keys must depend on the document/program generations, oracle versions, -resource snapshot, and relevant sampled values—not wall-clock time. Two -different times that produce the same sampled value should be able to reuse -the same derived result. +opaque paint-environment key, and relevant sampled values—not wall-clock time. +Two different times that produce the same sampled value under the same +environment should be able to reuse the same derived result. Transform and opacity tracks may eventually be lowered to a compositor. That path is legal only when it is observationally equivalent to the reference @@ -486,15 +512,26 @@ before animated fixtures can claim bit-identical results. The build order should preserve the engine's oracle law. -### Phase 0 — install a null seam +### Pre-animation foundation — no time semantics -- define an explicit `SampleTime` input without choosing source syntax; -- pass an empty overlay through the frame pipeline; +- define durable authored/member/use-occurrence addresses; +- compile node-property targets to arena-scoped generational keys; +- define the closed typed node-property registry; +- pass an immutable empty effective-value set through resolve, drawlist, + resolved query state, damage, cache, and frame; - prove static fixtures, drawlists, queries, replays, and pixels are unchanged; -- keep the current static API as a thin compatibility call if useful. + and +- keep the static API as a thin empty-value call. + +This foundation requires no `SampleTime` and invents no XML animation. + +### Phase 0 — explicit time and a null animation program -This phase is only legitimate after the sample-time representation is decided. -It should not invent XML animation. +- decide and define `SampleTime` without reading an ambient clock; +- define a format-neutral empty animation program; +- prove that sampling the empty program at every valid time produces the empty + effective-value set; and +- retain the pre-animation static-equivalence oracle. ### Phase 1 — typed replace-only kernel @@ -510,9 +547,8 @@ adopted list. Each must survive the registry and resolver design before entry. ### Phase 2 — layout and paint completeness -- feed sampled values into measure/layout through the agreed model seam; -- add paint-aware and bounds-aware damage; -- make all query APIs consume the sampled resolved tier; +- validate sampled layout, transform, bounds, paint, and query transitions + against the existing `PropertyValues`/`ValueView` reference seam; - define missing-resource and resource-change behavior; - add deterministic export sampling. @@ -538,19 +574,19 @@ measurement that shows why the complexity pays for itself. No file layout is adopted, but the current engine suggests these strict seams: -| concern | likely owner | -| ----------------------------- | ------------------------------------ | -| typed program and sampler | one initial `animation` module | -| frame orchestration | `frame` | -| sampled-value resolve seam | model resolver plus engine call site | -| sampled/resolved change data | `damage` | -| drawlist projection | `drawlist` | -| mechanical raster execution | `paint` | -| sampled spatial reads | `query` | -| conformance playback | `replay` | -| interpolation/version stamps | `oracle` | -| CPU work measurement | `probe` | -| real-window scheduling/pacing | host/compositor and frame log | +| concern | likely owner | +| ----------------------------- | ------------------------------------- | +| typed program and sampler | one initial `animation` module | +| frame orchestration | `frame` | +| effective-value resolve seam | existing `PropertyValues`/`ValueView` | +| sampled/resolved change data | `damage::diff_frame` | +| drawlist projection | `drawlist` | +| mechanical raster execution | `paint` | +| sampled spatial reads | `query` | +| conformance playback | `replay` | +| interpolation/version stamps | `oracle` | +| CPU work measurement | `probe` | +| real-window scheduling/pacing | host/compositor and frame log | `paint` is intentionally not the owner of animation semantics. The module boundary should remain small at first: one coherent sampler/registry unit is @@ -611,18 +647,16 @@ Implementation should not begin until the owning RFD or model contract answers the decisions needed for the selected first slice: 1. What exact type and unit represents `SampleTime`? -2. What durable authored identity can an animation target? +2. What source syntax carries local and cross-node targets? 3. What is the format-neutral authored animation model? -4. Which properties form the first typed registry, and what are their impact - classes? +4. Which registered properties form the first **animatable** subset? 5. What interpolation, boundary, easing, and discrete rules apply to them? 6. Is day-one composition replace-only, and how is conflicting source rejected or ordered? -7. What overlay/value-provider seam may the model resolver consume? -8. How are resource availability and resource-valued properties represented? -9. What static sample does a non-playing processor request? -10. Which oracle versions are stamped into animation conformance and replay - artifacts? +7. How are resource availability and resource-valued properties represented? +8. What static sample does a non-playing processor request? +9. Which oracle versions are stamped into animation conformance and replay + artifacts? ## Rejected shortcuts diff --git a/model-v2/engine/DATA-MODEL.md b/model-v2/engine/DATA-MODEL.md index 448428c47a..5f0e2b09ab 100644 --- a/model-v2/engine/DATA-MODEL.md +++ b/model-v2/engine/DATA-MODEL.md @@ -111,8 +111,11 @@ node-data-layout.md`'s "ComputedStyle is reference-counted" is now stale — it memory-scaling win). Pairs with a `RareData` cold/hot split on `Header`. 3. **Wide-gamut color** — `Color` → `f32×4` + a color-space tag, folded into the color-management day-1 gap (the audit's item). -4. **Damage paint channel** — `damage::diff` is geometry-only; a fill/opacity - channel is a prerequisite for Win 2 consuming it (already flagged). +4. **Consume complete damage** — `damage::diff_frame` now compares immutable + frame products containing resolved, drawlist, and paint-environment state, + including resource revisions under stable logical IDs. Win 2 still needs + to consume that complete damage for scoped repaint; the reference channel + itself is **[ADOPTED]**. ## References diff --git a/model-v2/engine/EFFECTIVE-VALUES.md b/model-v2/engine/EFFECTIVE-VALUES.md new file mode 100644 index 0000000000..7af8005174 --- /dev/null +++ b/model-v2/engine/EFFECTIVE-VALUES.md @@ -0,0 +1,127 @@ +# EFFECTIVE VALUES — the pre-animation engine contract + +**Status:** Implemented foundation. This contract contains no semantic time, +animation program, track, keyframe, interpolation, composition, scheduling, or +playback behavior. + +## Boundary + +The model owns one closed typed property registry and one immutable effective +value representation: + +```text +authored Document + PropertyValues + | + v + validated ValueView + | + v + resolve -> drawlist -> raster + | | + +------ query / damage / cache +``` + +`PropertyValues` maps `PropertyTarget` to one exact `PropertyValue`. A target +is an arena-incarnation-scoped, generation-stamped `NodeKey` plus a closed +`PropertyKey`. Missing means the authored base value. Nullable properties use +their own exact optional type; there is no universal null. Duplicate, stale, +cross-arena, mistyped, inapplicable, and invalid values fail before traversal. + +Structural facts—payload kind, parenthood, child order, and nested paint, +stroke, stop, text-run, or lens-operation members—are not node properties. +Those nested objects need durable identity before they can become individual +targets. + +## Identity handoff + +Version 4 source materialization owns authored owner/member/use-occurrence +addresses. It resolves one address to one live `NodeKey`; pairing that key with +a `PropertyKey` produces the runtime `PropertyTarget`. Engine source does not +import or interpret XML addresses. Runtime keys are never serialized. + +The arena incarnation prevents keys from crossing an independent parse or +document clone. The slot generation prevents a deleted occupant from aliasing +a later occupant of the same slot. Both counters fail closed on exhaustion. + +## Pipeline laws + +- Static resolver, drawlist, frame, and raster entries have exactly the + `ValueView::base(document)` semantics. Their authored hot path is privately + monomorphized to direct document reads; it is not a second contract or + projection algorithm. +- One validated `ValueView` feeds resolution and drawlist construction. +- Resolution snapshots child order, transparent-select behavior, and effective + descendant-clip geometry. Query accepts only that `Resolved` product, so no + document or value view can be paired with another frame's hot columns. +- The authored `Document` remains immutable while effective values are read. +- The full resolver, full drawlist, and full raster path remain the permanent + reference. Property impact flags do not select an optimized path yet. +- Frame construction is fallible after the exact drawlist exists. Every visible + gradient is revalidated for programmatic-document safety, then its authored + transform is composed with the actual resolved paint box and checked with + the raster backend's own matrix inversion, and the selected shader factory + (including the diamond runtime effect) is actually probed. A complete + `FrameProduct` is minted only after this preflight succeeds; rendering emits + no canvas command on failure. + +The canonical frame boundary still assumes that its `Document` came through +source parsing or the shared model renderability fence. Gradient revalidation +is repeated here because direct programmatic documents could otherwise reach a +fallible shader factory, and gradient/image contextual checks require resolved +boxes, backend arithmetic, resources, or a final view that authored validation +cannot know. This is not an exhaustive validator for every malformed hand- +built stroke, corner, path, or image-model combination. + +## Damage and resources + +`damage::diff` compares resolved geometry only and remains a compatibility +primitive. `damage::diff_frame` is the complete visual reference: it compares +two immutable `FrameProduct`s, including paint-only changes, opacity scopes, +strokes, clips, text/path artifacts, painter order, and paint environments. + +Host fonts and decoded image bytes are separate declared inputs. `PaintCtx` +exposes an opaque `PaintEnvironmentKey` containing its checked incarnation and +revision. Each `FrameProduct` captures that key, and `damage::diff_frame` +conservatively damages all nodes owning before/after draw items when the keys +differ. This covers resource readiness and replacing bytes under the same +logical RID without pulling `PaintCtx` into damage data. + +Complete product execution checks the current `PaintCtx` key before drawing and +returns an explicit mismatch error after any context replacement or resource +revision. It then preflights each evaluated image paint against the exact +requested view: resource presence, supported fit state, shader construction, +and the backend inverse of `view × world × image-fit`. A failure identifies the +node, fill/stroke/text-run context, visible drawlist paint index, RID, and +reason before touching the destination canvas. A singular geometry CTM is +valid collapsed coverage and is not misreported as an image sampling failure. +Raw drawlist replay is named `execute_unchecked`; it remains an explicitly +quarantined structural/internal entry that may omit unsupported or unavailable +image paints. + +`SceneCache` keys the runtime document root, resolve options, exact +`PropertyValues`, and `PaintEnvironmentKey`. A changed effective value, +document incarnation, font, or image resource therefore rebuilds the retained +drawlist/raster even without a document-dirty hint. Rebuild is transactional: +a frame-build or checked-execution failure preserves both the destination +canvas and the previous retained image, drawlist, and keys. + +## Guarding tests + +- `tests/values.rs`: empty static equivalence; layout, rotation, fills, + opacity, strokes, active state, query, damage, and pixels. +- `tests/ident.rs`: live-only keys and cross-document refusal. +- `tests/damage.rs`: geometry, text artifacts, and painter-order changes. +- `tests/frame.rs`: unchanged-context execution; wrong-context and post-build + resource-revision refusal; source/effective extreme-gradient acceptance; + resolved-box capability failures; contextual diagnostics; and no canvas + mutation on build error. +- `tests/query.rs`: resolved-only query parity and retained traversal/clip + independence from later authored mutation. +- `tests/cache.rs`: exact values, document replacement, same-RID resource + replacement, and transactional failure preserving the prior cached frame. +- `tests/grida_xml_source.rs` with + `rig/fixtures/durable-addressing.grida.xml`: authored occurrence address to + typed runtime target to evaluated frame output. + +The future animation design must produce `PropertyValues`; it must not invent +a parallel sampled-value abstraction. See [ANIMATION.md](./ANIMATION.md). diff --git a/model-v2/engine/README.md b/model-v2/engine/README.md index dcfbc48cb9..5e53fd4b39 100644 --- a/model-v2/engine/README.md +++ b/model-v2/engine/README.md @@ -1,10 +1,11 @@ # anchor-engine — the phase-4 canvas engine skeleton -The pipeline the `crates/grida` migration will read: `document → resolve → -drawlist → paint`, plus the read tier (`query`), time-as-data -(`journal`/`replay`), and the sockets every future optimization plugs into -(`damage`, `ident`, `oracle`). It consumes [`anchor-lab`](../a/lab) as a -library — the same relationship the migration will have with the model crate. +The pipeline the `crates/grida` migration will read: `(document + immutable +effective values) → resolve → drawlist → paint`, plus the read tier +(`query`), time-as-data (`journal`/`replay`), and the sockets every future +optimization plugs into (`damage`, `ident`, `oracle`). It consumes +[`anchor-lab`](../a/lab) as a library — the same relationship the migration +will have with the model crate. The contracts it encodes are catalogued in [`../a/ENGINE.md`](../a/ENGINE.md) (ENG-0…ENG-5, S-1…S-7); each module names the contract it serves. @@ -24,24 +25,35 @@ cd model-v2/engine && cargo test # the trace arm must keep compiling cargo check --features trace -# the legacy re-host gate: replay determinism, differential/cache checks, -# benchmark budgets, and the pre-Draft-0 screenshot oracle -# (needs the spike built first — it owns the legacy golden pixels) +# the re-host gate: replay determinism, differential/cache checks, +# benchmark budgets, and the deterministic screenshot oracle +# (needs the spike built first — it owns the golden pixels) (cd ../a/spike-canvas && cargo build --release) cargo run --release --bin gate + +# visual proof of the pre-animation effective-value seam +cargo run --bin effective_values_demo -- target/grida-effective-values-demo.png ``` ## Versioned `.grida.xml` ingestion There is deliberately no XML-specific engine API. Draft 0 still has the model crate's pure `anchor_lab::grida_xml::parse(&str)` boundary. The retained -source-program boundary additionally parses and links Version 1/2/3 source -units, specializes Version 2/3 scalar props, projects Version 3 named render -slots, and materializes the same ordinary `Document` used everywhere else. A -host supplies immutable source snapshots, then passes only that concrete +source-program boundary additionally parses and links Version 1–4 source +units, specializes Version 2–4 scalar props, projects Version 3/4 named render +slots, retains Version 4 durable occurrence addresses, and materializes the +same ordinary `Document` used everywhere else. A host supplies immutable +source snapshots, then passes only that concrete document to `frame::render`. The engine library performs no filesystem I/O and never reparses a document in the frame loop. +The canonical frame seam assumes that a document reached it through source +parsing or the model's shared renderability validation. Its gradient and image +preflights deliberately close later facts—resolved paint boxes, pinned-backend +arithmetic and shader construction, loaded resources, and the final view—but +are not an exhaustive validator for arbitrary hand-built invalid stroke, +corner, path, or image-model state. + The local-file host resolves component dependencies from each containing source's canonical base, retains node/use/specialization/slot-projection provenance, and preflights an origin-aware resource manifest. Equal relative @@ -110,9 +122,9 @@ complete. Current limits are: - derived group/lens flex-slot growth still needs an explicit slot-versus-geometry model rule; - image-paint free transforms, tiling, filters, and quarter-turns are outside - Draft 0 XML; default/fallback/required slot policies, durable - instance/member identity, full-library validation, and canonical multi-file - writing remain future work; + Draft 0 XML; default/fallback/required slot policies, durable nested + paint/stroke/stop/run identity, full-library validation, and canonical + multi-file writing remain future work; - the production smooth-corner construction is circular-only; Draft 0 rejects smoothed elliptical radii rather than silently changing their authored geometry, and defines production's per-corner half-short-side cap separately @@ -123,15 +135,21 @@ complete. Current limits are: dropping authored intent; - `PaintCtx` is the host resource environment for text resolution and image paint. The low-level infallible painter emits no pixels for an unregistered - image RID. A host claiming strict materialization must preflight resources; - `grida_xml_render` does so and reports authored plus resolved locations; + image RID. Checked frame execution instead rejects missing or unsupported + image paints and view-dependent noninvertible image sampling matrices before + touching the destination canvas; a singular geometry transform remains valid + collapsed coverage. A host claiming strict materialization must also + preflight filesystem/decode resources; `grida_xml_render` does so and reports + authored plus resolved locations. Its + opaque checked `PaintEnvironmentKey` lets damage and cache observe readiness, + font changes, and same-RID byte replacement; - the lab's ordered `Vec` implements the accepted extension, while the production scene/archive model still has one stroke geometry per node. - production scene/archive path contracts still expose incompatible raw and canonical box-mapped forms, do not uniformly preserve fill rule, and do not yet round-trip this Draft 0 path contract. -Checked-in Draft 0 files use the canonical grammar; Version 1–3 fixtures use +Checked-in Draft 0 files use the canonical grammar; Version 1–4 fixtures use the selected proving grammar of their open RFDs. The minimal consumer fixture and pixel probes live at `rig/fixtures/nested-rects.grida.xml` and `tests/grida_xml.rs`. `rig/examples/dynamic-slide.grida.xml` demonstrates flex, @@ -162,6 +180,10 @@ component-blind pixel output. library are the focused named-slot oracle: definition-owned header/footer order, caller-owned projected roots, one empty projection, ordinary-scene lowering, and component-blind interior pixel probes. +`rig/fixtures/durable-addressing.grida.xml` is the Version 4 identity oracle: +every authored ordinary node has one owner/member/use-occurrence address, and +the engine integration compiles one occurrence to an arena-scoped typed +property target before evaluating it through the ordinary frame pipeline. `rig/examples/social-feed/entry.grida.xml` and `post-card.grida.xml` form the real-world Version 3 showcase: one viewport-spanning, breakpoint-free scene uses center/end/span bindings for its rail, stories, timeline, suggestions, @@ -255,8 +277,12 @@ How the engine _stores_ its data — each memory/data-layout detail decided against verified browser prior art (`cc`/Blink/Stylo/Skia), validity-tagged ALIGNED / ADOPTED / SOCKET — is [`DATA-MODEL.md`](./DATA-MODEL.md). -How explicit sample time and animated values could enter this pipeline without -mutating authored state is the open engine RFD: [`ANIMATION.md`](./ANIMATION.md). +How a future sampler could produce the existing `PropertyValues` contract at +an explicit time without mutating authored state is the open engine RFD: +[`ANIMATION.md`](./ANIMATION.md). + +The implemented, strictly pre-animation identity/value/frame/query/damage/cache +contract is [`EFFECTIVE-VALUES.md`](./EFFECTIVE-VALUES.md). ## Contract → module → guarding test @@ -264,42 +290,46 @@ mutating authored state is the open engine RFD: [`ANIMATION.md`](./ANIMATION.md) | ------------------------------------ | ---------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | stage purity + the oracle law | (whole pipeline) | ENG-0 | the gate's differential + determinism runs | | versioned source consumer seam | link → frame | ENG-0 / S-2 | `tests/grida_xml.rs`, `tests/grida_xml_source.rs`, `tests/grida_xml_slots.rs`, `tests/grida_xml_social_feed.rs`, `tests/paints.rs`, `tests/strokes.rs`, `tests/rectangular_strokes.rs`, `tests/text.rs`, `tests/corners.rs`, `tests/paths.rs` | +| effective property values | model → frame | ENG-0/2/3 | `tests/values.rs` (empty equivalence · layout/transform · paint · bounds · visibility · query · pixels) | | drawlist (pure, diffable projection) | `drawlist.rs` | ENG-2.1 | `tests/drawlist.rs` (order · pruning · color · verbatim world · determinism) | | text shaping + shared glyph layout | `text_layout.rs` | ENG-4.1/4.5 | `../a/lab/tests/text_layout.rs`, `tests/text.rs` | | raster executor | `paint.rs` | ENG-2.1 | `tests/paints.rs`, `tests/strokes.rs`, `tests/rectangular_strokes.rs`, `tests/text.rs`, `tests/corners.rs`, `tests/paths.rs` (pixel probes) | -| one frame entry | `frame.rs` | ENG-2.4 | spike live loop + gate | -| damage as data | `damage.rs` | ENG-2.2 | `tests/damage.rs` (identity empty · single-op locality) | -| spatial read tier | `query.rs` | ENG-3 | `tests/query.rs` (`hit_point ≡ pick` over a grid) | +| one frame entry | `frame.rs` | ENG-2.4 | `tests/frame.rs` (checked paint environment) · spike live loop · gate | +| damage as data | `damage.rs` | ENG-2.2 | `tests/damage.rs`, `tests/values.rs`, `tests/cache.rs` (geometry · paint-only · opacity · painter order · environment · covering bounds) | +| spatial read tier | `query.rs` | ENG-3 | `tests/query.rs` (`hit_point ≡ pick` · retained traversal/clip snapshot) | | journal (op-log) | `journal.rs` | ENG-5.1 | `tests/journal.rs` | | replay (corpus, determinism) | `replay.rs` | ENG-5.2/5.3 | `tests/replay.rs` + `rig/corpus/*.replay` via the gate | -| cache identity | `ident.rs` | ENG-2.3/1.4 | `tests/ident.rs` (generation-stamped key) | +| cache identity | `ident.rs` | ENG-2.3/1.4 | `tests/ident.rs`, `tests/cache.rs` (arena + slot + generation · exact values · paint environment · document replacement) | | oracle version tags | `oracle.rs` | ENG-4.2 | the `.replay` header | | gated observability | `trace.rs` | S-6 | `cargo check --features trace` | | the rig | `bin/gate.rs` | ENG-0.2 / S-5 | it _is_ the gate | The model-crate side of the setup lives in [`../a/lab`](../a/lab): the typed -`Op` + `apply` dispatcher + `DirtyClass` (`ops.rs`), the per-slot `generations` -column (`model.rs`), the non-panicking `Resolved` opt accessors (`resolve.rs`), -and the optional `serde` feature (the op-log wire) — each additive, with the -full lab suite green throughout. +`Op` + `apply` dispatcher + `DirtyClass` (`ops.rs`), the arena-scoped per-slot +generation identity (`model.rs`), the closed property registry and immutable +`ValueView` (`properties.rs`), the non-panicking `Resolved` opt accessors +(`resolve.rs`), and the optional `serde` feature (the op-log wire) — each +additive, with the full lab suite green throughout. ## The re-host, concretely -The spike's scene painter is deleted; it calls `frame::resolve_and_build` + -`paint::execute`. The lower-level `drawlist::build_glyphless` entry is reserved -for deterministic lab and structural probes. Live pick, hover, handles, and -gestures resolve through the same host-font oracle as paint; spatial queries go -through `query`. All gesture ops go through `apply` and are recorded in the -`journal` (undo stays document snapshots — ENG-5.5). `--record` writes -`.replay` corpus files; the panel shows the per-frame damage count. - -The gate's replay, differential/cache, and benchmark sections remain green. -Its four legacy screenshots currently report deliberate semantic deltas: -frames no longer receive invented ink, authored parent strokes paint after -children, and host-font text now uses shaped metrics and positioned-glyph -replay. Those pre-change goldens have not been blessed or silently rewritten; -they need an explicit oracle rebaseline after the new painter and text oracle -are accepted for the spike corpus. +The spike's scene painter is deleted; it calls `frame::resolve_and_build` then +checked `FrameProduct::execute`. The lower-level `drawlist::build_glyphless_unchecked`, +`paint::execute_unchecked`, and `paint::raster_to_bytes_unchecked` entries are +reserved for deterministic structural probes and internal retained-list replay. +Live pick, hover, handles, and gestures read the frame's resolved traversal and +effective clip snapshot; spatial queries cannot accept a second document or +value view. All gesture ops go through `apply` and are recorded in the `journal` +(undo stays document snapshots — ENG-5.5). `--record` writes `.replay` corpus +files; the panel shows the per-frame damage count. + +The screenshot oracle was explicitly rebaselined after accepting three +historical semantic corrections: frames no longer receive invented ink, +authored parent strokes paint after children, and text uses shaped metrics and +positioned-glyph replay. The shot paint context now loads the repository's +bundled Inter face, so its four goldens are deterministic across host font +configurations. Replay, differential/cache, screenshot, and benchmark gates +are green together. ## Scope fence (named, not silent) diff --git a/model-v2/problems.md b/model-v2/problems.md index 4da694a4c7..813fdcaa31 100644 --- a/model-v2/problems.md +++ b/model-v2/problems.md @@ -454,6 +454,19 @@ exceptional construct (a `TransformGroup`-like node) instead? what is an animatable channel, where do evaluated animation values live relative to stored values, and does animated geometry re-enter layout? +**Foundation decided (2026-07-13).** Durable source occurrences compile to +arena-scoped generational node keys plus one key from a closed typed property +registry. Immutable sparse `PropertyValues` form a separate evaluated tier, +and the same `ValueView` enters measurement/layout before transform, bounds, and +drawlist. Resolution snapshots the exact traversal and effective clip state for +resolved-only query; raster execution checks the frame's captured resource +environment. Empty values are exactly the static scene; evaluation never writes +back. Resolved state, the ordered drawlist, and the resource-environment identity +form one frame product for complete damage and cache comparison. This +deliberately does **not** decide the animatable subset, interpolation, +composition, timing, or whether a future post-layout transform lane joins the +registry. + **Why it's hard.** - Interpolation is only well-defined over decomposed values; animating a From f5c6d0985d4a601f162a7ebb3383a21e3dac90ee Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 14 Jul 2026 00:23:25 +0900 Subject: [PATCH 30/33] docs(svg): define explicit-time animation profiles --- docs/wg/feat-svg/_category_.json | 6 +- docs/wg/feat-svg/animation-keyframes.md | 312 +++++++++++ docs/wg/feat-svg/animation.md | 331 +++++++++++ docs/wg/feat-svg/index.md | 28 + docs/wg/feat-svg/testing.md | 31 +- docs/wg/format/grida-xml-addressing.md | 9 +- docs/wg/format/grida-xml-animation.md | 526 ++++-------------- docs/wg/format/index.md | 2 +- docs/wg/format/svg.md | 24 +- .../chromium/svg/animation-and-smil.md | 476 ++++++++-------- docs/wg/research/chromium/svg/index.md | 84 +-- 11 files changed, 1138 insertions(+), 691 deletions(-) create mode 100644 docs/wg/feat-svg/animation-keyframes.md create mode 100644 docs/wg/feat-svg/animation.md create mode 100644 docs/wg/feat-svg/index.md diff --git a/docs/wg/feat-svg/_category_.json b/docs/wg/feat-svg/_category_.json index 2e5b87067d..a3d30b0aa1 100644 --- a/docs/wg/feat-svg/_category_.json +++ b/docs/wg/feat-svg/_category_.json @@ -1,3 +1,7 @@ { - "label": "SVG" + "label": "SVG", + "link": { + "type": "doc", + "id": "wg/feat-svg/index" + } } diff --git a/docs/wg/feat-svg/animation-keyframes.md b/docs/wg/feat-svg/animation-keyframes.md new file mode 100644 index 0000000000..db61c9be36 --- /dev/null +++ b/docs/wg/feat-svg/animation-keyframes.md @@ -0,0 +1,312 @@ +--- +title: "SVG Animation Profile 1: Keyframes and Easing" +description: "A deterministic SVG animate subset with value lists, explicit key times, and cubic Bézier easing." +keywords: + - svg + - animation + - keyframes + - easing + - smil + - deterministic rendering +tags: + - internal + - wg + - canvas + - svg + - rendering +format: md +--- + +# SVG Animation Profile 1: Keyframes and Easing + +**Status:** Accepted source profile. + +SVG Animation Profile 1 extends [SVG Animation Profile 0](./animation) with +ordered keyframe values, authored key times, and cubic Bézier easing. It is a +cumulative profile: every source accepted by Profile 0 is accepted by Profile +1 and must produce the same sampled values, including the same binary32 bits. + +All Profile 0 rules remain normative unless this document replaces them. In +particular, Profile 1 inherits its Base and Sample policies, retained-source +contract, targets and properties, exact nanosecond timeline, repeat and fill +behavior, replacement-only composition, whole-document failure, diagnostics, +and trust boundary. This document replaces only Profile 0's animation +attribute vocabulary and its **Values and interpolation** section. Where +Profile 0 says `from`, `to`, or frozen `to`, Profile 1 generalizes those terms +to the first or final effective keyframe. + +The vocabulary and broad behavior follow the [SVG animation value +attributes](https://svgwg.org/specs/animations/#ValueAttributes), which in +turn use the [SMIL animation-function +model](https://www.w3.org/TR/2001/REC-smil-animation-20010904/#AnimFuncValues). +Profile 1 deliberately narrows that model where this document says so. + +## Scope of the extension + +Profile 1 adds `values`, `keyTimes`, and `keySplines` to Profile 0's admitted +`` attributes. `calcMode` may be absent, `linear`, or `spline`. + +The complete value forms are: + +| Authored form | Profile 1 meaning | +| -------------------------------- | ------------------------------------------------------ | +| `values="v"` | one constant keyframe | +| `values="v0;v1;..."` | an ordered keyframe sequence | +| `from="v0" to="v1"` | the two-keyframe sequence `v0;v1` | +| any `values` plus `from` or `to` | `values` selects the form; `from` and `to` are ignored | + +Presence of the `values` attribute selects the values form before its contents +are parsed. Consequently, an empty or malformed `values` attribute fails +Profile 1 and never falls back to otherwise valid `from` and `to` attributes. +Conversely, ignored `from` and `to` attributes do not have to be valid target +values. This is SVG's precedence rule, not a conflict-repair rule: the +[`values` definition](https://svgwg.org/specs/animations/#ValuesAttribute) +states that `from`, `to`, and `by` are ignored when a values list is used. + +When `values` is absent, both `from` and `to` remain required exactly as in +Profile 0. `by` is outside Profile 1 even when SVG would ignore it beside +`values`; a lone `to` and a lone `from` are outside as well. + +`discrete` and `paced` calculation, addition, accumulation, and every other +animation family deferred by Profile 0 remain deferred. In particular, +Profile 1 does not broaden the target element or property set. + +## Keyframe values + +`values` is a semicolon-separated list. XML whitespace around each value and +separator is ignored. One final semicolon, optionally followed by whitespace, +is ignored; an empty interior item or more than one empty trailing item is an +error. This follows [SVG's values-list parsing](https://svgwg.org/specs/animations/#ValuesAttribute). +The validity of a one-item list is independently exercised by the +[single-value web-platform-test](https://github.com/web-platform-tests/wpt/blob/master/svg/animations/single-values-animation.html). + +Each item is parsed once under Profile 0's target-specific value and binary32 +rules. Every item must be finite and valid for the animated property: sizes +remain non-negative and opacity remains in `[0, 1]`. The parsed bit pattern is +the keyframe value. Parsing or interpolating another item cannot change it. + +A list has either one item or at least two: + +- A one-item list is a constant animation. It is admitted only with absent or + `linear` `calcMode` and without `keyTimes` or `keySplines`. The exact stored + value contributes throughout every active interval and is the frozen value. + This is a deliberate Profile 1 restriction: a constant has no interval to + time or ease. +- A list with at least two items supplies one value for every key time. The + first value applies at simple progress zero and the final value is the + terminal frozen value. + +## Key times + +For `N >= 2` keyframes, omitted `keyTimes` defines the exact offsets + +```text +t[i] = i / (N - 1), for i = 0 ... N - 1. +``` + +Thus both linear and spline modes divide the simple duration into `N - 1` +equal intervals when no key times are authored, matching the [SMIL calculation +mode rules](https://www.w3.org/TR/2001/REC-smil-animation-20010904/#AnimFuncCalcMode). + +An explicit `keyTimes` value is a semicolon-separated list of SVG numbers. +Whitespace around items and separators and one final semicolon are accepted; +the trailing-semicolon behavior has dedicated [web-platform-test +coverage](https://github.com/web-platform-tests/wpt/blob/master/svg/animations/scripted/keytimes-attribute-trailing-semi.html). +Each token is interpreted as its exact base-ten rational value, not first +rounded through binary floating point. + +For `N >= 2`, an explicit list must satisfy all of these rules: + +1. it contains exactly `N` offsets; +2. every offset is in `[0, 1]`; +3. the first offset is exactly `0` and the last is exactly `1`; and +4. each offset is strictly greater than its predecessor. + +[SVG permits equal neighboring offsets](https://svgwg.org/specs/animations/#KeyTimesAttribute) +by requiring only a non-decreasing list. Profile 1 deliberately requires +strict increase. This removes zero-duration segments and gives every accepted +simple progress one unambiguous interval while retaining SVG behavior for the +admitted domain. + +Let simple progress be the exact rational `p`. For `0 <= p < 1`, interval +selection chooses the unique `i` satisfying + +```text +t[i] <= p < t[i + 1]. +``` + +If `p` equals `t[i]`, the sampled result is the exact stored bits of keyframe +`i`; interpolation is not evaluated. At an internal repeat boundary, Profile +0's timing rule starts the next iteration at `p = 0`, so the first keyframe is +selected. At final active end, `remove` contributes nothing and `freeze` +contributes the exact final keyframe bits. + +## Linear interpolation + +Absent `calcMode` means `linear`. Within the selected interval, segment-local +progress is the exact rational + +```text +q = (p - t[i]) / (t[i + 1] - t[i]). +``` + +For neighboring stored binary32 values `a` and `b`, the sampled value is the +exact mathematical result + +```text +a + (b - a) × q +``` + +rounded once to IEEE 754 binary32 using round-to-nearest, ties-to-even. No +intermediate binary floating-point rounding participates. Exact key times use +the stored keyframe bits directly, including signed-zero bits. + +SVG says `keySplines` is ignored unless `calcMode="spline"` ([SVG +`keySplines`](https://svgwg.org/specs/animations/#KeySplinesAttribute)). +Profile 1 follows that rule: a syntactically valid `keySplines` attribute on a +linear animation does not affect timing, interpolation, or list-count +validation. The strict source policy still validates every present tuple and +coordinate; malformed syntax or an out-of-range coordinate remains an error. + +## Spline interpolation + +`calcMode="spline"` retains the same keyframe values, key times, and interval +selection as linear mode. It replaces the segment-local progress `q` with an +eased progress `e` before the final property interpolation. + +For `N >= 2`, `keySplines` is required and contains exactly `N - 1` control +point tuples, one per interval. `keyTimes` remains optional; when it is absent, +the equal offsets above apply. The combination of authored splines and +implicit equal times is part of the [SMIL calculation-mode +model](https://www.w3.org/TR/2001/REC-smil-animation-20010904/#AnimFuncCalcMode). +The [spline-values web-platform-test](https://github.com/web-platform-tests/wpt/blob/master/svg/animations/animate-calcMode-spline-values.html) +provides a reference case with explicit times. + +Tuples are separated by semicolons. A tuple has four SVG numbers +`x1 y1 x2 y2`; commas, whitespace, or a mixture of both may separate its +coordinates. XML whitespace around tuples and one final semicolon are +accepted. Empty tuples, the wrong number of coordinates, or the wrong number +of tuples are errors. The [mixed-separator web-platform-test](https://github.com/web-platform-tests/wpt/blob/master/svg/animations/animate-keySplines.html) +is a representative grammar oracle. + +Every coordinate, including `y1` and `y2`, must lie in `[0, 1]`. This is SVG's +control-point domain and is intentionally narrower than [CSS +`cubic-bezier()`](https://www.w3.org/TR/css-easing-1/#cubic-bezier-easing-functions), +whose y coordinates may escape the unit interval. SVG's y restriction is +covered directly by [web-platform-tests](https://github.com/web-platform-tests/wpt/blob/master/svg/animations/keysplines-y-limits.html). +The exact authored decimal is checked against `[0, 1]` before numeric storage; +binary32 rounding cannot repair an out-of-domain control. Each admitted +coordinate is then parsed once into finite IEEE 754 binary32 using Profile 0's +round-to-nearest, ties-to-even source-number rule. The exact rational value of +that stored binary32 bit pattern participates in the cubic calculations below; +the authored decimal is not reparsed during sampling. Signed zero is +semantically zero for the control-point domain and diagonal comparison. + +For one tuple, define the cubic coordinate function + +```text +C(a, b, u) = 3(1-u)^2 u a + 3(1-u) u^2 b + u^3. +Bx(u) = C(x1, x2, u) +By(u) = C(y1, y2, u) +``` + +The implied endpoints are `(0, 0)` and `(1, 1)`. Eased progress is `By(u)` +where `u` solves `Bx(u) = q`. + +Profile 1 fixes inversion rather than leaving a tolerance or machine math +library as hidden input: + +1. `q = 0` returns `e = 0`, and `q = 1` returns `e = 1` exactly. +2. If `x1 = y1` and `x2 = y2`, the curve lies on the diagonal and returns + `e = q` exactly. +3. Otherwise begin with the exact rational bracket `[0, 1]`. At each step, + evaluate `Bx` at the exact midpoint. Equality returns `By` at that midpoint + immediately. A result below `q` replaces the lower bound; a result above + `q` replaces the upper bound. +4. After 128 unequal comparisons, evaluate `By` at the exact midpoint of the + final bracket. That rational result is `e`. + +The final property value is the exact mathematical interpolation +`a + (b - a) × e`, rounded once to binary32 with ties-to-even. The inversion, +cubic evaluation, and property interpolation use the exact rational values of +the already parsed keyframe and control-point bits and perform no intermediate +binary floating-point rounding. These rules make direct seek, sequential +playback, and different conforming implementations agree without sharing a +tolerance, iteration-by-error exit, or platform math library. + +## Bounded source domain + +Profile 1 places finite limits on the new source vocabulary so validation and +sampling have a portable resource bound: + +- a `values` list contains at most 4,096 keyframes; +- a `keySplines` list contains at most 4,095 tuples, including when linear mode + ignores the tuples semantically; +- each spline-control token is at most 128 bytes after XML-whitespace + separation; +- a spline-control decimal exponent is in the inclusive range `[-128, 128]`; +- each XML-whitespace-trimmed `keyTimes` token is at most 128 bytes; +- a `keyTimes` decimal exponent is in the inclusive range `[-128, 128]`; and +- after exact decimal reduction, each `keyTimes` numerator and positive + denominator fits the unsigned 64-bit integer domain. + +These are deliberate Profile 1 restrictions, not claims about SVG's general +limits. Exceeding any limit is a source-profile error discovered during atomic +validation, not a sample-time fallback or partial animation. + +## Validation and diagnostics + +Profile 0's atomic Sample policy applies to the complete Profile 1 inventory. +In addition to inherited failures, Profile 1 rejects: + +- a missing value form, or `from` without `to` when `values` is absent; +- an empty, internally empty, mistyped, non-finite, or out-of-domain keyframe; +- interpolation metadata on a one-keyframe constant; +- a malformed, out-of-range, non-endpoint, non-increasing, or wrong-length + explicit `keyTimes` list; +- `calcMode` other than absent, `linear`, or `spline`; +- a missing or wrong-length spline list in spline mode; or +- a malformed or out-of-range spline control point. + +A diagnostic identifies the animation and source location, the attribute and +list index when applicable, the required count or domain, and the observed +value. Value-form selection happens before validation, so diagnostics for a +present malformed `values` attribute must not claim that `from` or `to` was +missing. + +## Conformance + +Profile 1 adds these laws to Profile 0's conformance contract: + +- every Profile 0 source samples bit-identically under Profile 1; +- `values` wins over `from` and `to`, and malformed `values` never falls back; +- a one-value animation is bit-constant across active time and freeze; +- implicit equal times and their explicitly authored equivalents agree; +- every interior key time is probed immediately before, exactly at, and + immediately after the boundary, with exact keyframe bits at equality; +- equal, descending, non-zero-first, non-one-last, and wrong-count key times + fail atomically; +- valid spline syntax without authored key times uses equal intervals; +- linear mode is unchanged by a valid `keySplines` attribute; +- diagonal splines and segment endpoints take their exact fast paths; +- non-trivial splines match reference vectors produced by the 128-step exact + inversion contract; +- each source limit is tested at its admitted edge and immediately beyond it; + and +- the final frozen value is the last keyframe, not an ignored `to` attribute. + +The applicable [web-platform-tests SVG animation +suite](https://github.com/web-platform-tests/wpt/tree/master/svg/animations) +remains a semantic oracle. Profile 1's strict-increase rule, exact rational +boundaries, binary32 bit rules, and fixed spline inversion are profile +conformance requirements, not browser-differential tolerances. Browser and +profile conformance results therefore remain separate, as required by Profile 0. + +## Deferred families + +Profile 1 resolves only Profile 0's keyframe-list and easing deferral. +`calcMode="discrete"`, `calcMode="paced"`, `by`, lone-`to` animation, +addition, accumulation, animation sandwiches, transforms, motion paths, +colors and paints, additional SVG targets and properties, indefinite timing, +event timing, CSS animation, and optimized execution remain outside this +profile. diff --git a/docs/wg/feat-svg/animation.md b/docs/wg/feat-svg/animation.md new file mode 100644 index 0000000000..f1d9fa97d6 --- /dev/null +++ b/docs/wg/feat-svg/animation.md @@ -0,0 +1,331 @@ +--- +title: "SVG Animation Profile 0" +description: "A strict, deterministic first subset of SVG declarative animation for explicit-time rendering." +keywords: + - svg + - animation + - smil + - animate + - deterministic rendering +tags: + - internal + - wg + - canvas + - svg + - rendering +format: md +--- + +# SVG Animation Profile 0 + +**Status:** Accepted source profile. + +SVG Animation Profile 0 is the first authored animation contract. It admits a +small, strict subset of SVG's `` element, sampled at an explicit time. +It is not a claim of complete SVG animation, SMIL, playback, interactive SVG, +or secure animated-SVG support. + +The purpose of Profile 0 is to prove the animation kernel with existing SVG +syntax and semantics before another authored animation language is designed. +For a fixed source snapshot, materialization, profile revision, and sample time, +every accepted animation inventory produces one deterministic effective-value +set, which may be empty. +Fonts, images, and other static rendering inputs remain part of the declared +rendering environment. +Anything outside the profile is retained by a source-preserving processor but +is never silently approximated or partially executed. + +[SVG Animation Profile 1](./animation-keyframes) is the cumulative extension +for ordered keyframe values, exact key times, and per-segment cubic Bézier +easing. This page remains the normative baseline inherited by that extension. + +## Standards baseline + +The source vocabulary comes from the [SVG Animations Level 2 Editor's Draft of +14 September 2025](https://svgwg.org/specs/animations/). That draft delegates +the animation model, except for SVG-specific rules, to the [SMIL Animation +Recommendation](https://www.w3.org/TR/2001/REC-smil-animation-20010904/). + +Profile 0 narrows those standards deliberately. An admitted construct follows +their behavior unless this document states a stricter input domain or numeric +projection. A processor must name this profile, not advertise the broader +standards, when reporting support. + +## Processing policies + +A processor exposes two distinct policies: + +- **Base:** animation contributes no values. The authored base scene is + rendered. The result reports whether animation markup was present, so the + ignored behavior is visible to the caller. +- **Sample:** the caller supplies one exact time. The complete animation + inventory is validated as Profile 0, then sampled. One unsupported or invalid + animation prevents the animated result; no partial animated frame is + produced. + +Base is not shorthand for sampling at time zero. At time zero, an animation +whose active interval begins at zero contributes its `from` value; under Base, +it contributes nothing. + +Playback, pausing, frame pacing, and wall clocks are host behavior: playback is +only repeated calls to Sample. No processor may consult ambient time to decide +the sampled scene. + +These policy names are intentionally not SVG's `static`, `animated`, or +`secure` processing-mode names. Those standard modes govern more than the +bounded animation behavior defined here. + +## Source preservation + +Animation markup is authored source, not a sequence of document mutations. +A source-preserving processor retains an immutable original SVG representation +and source locations stable within that snapshot for all animation elements, +including unsupported ones. Static normalization is not the source of +animation truth, and sampled values are never written back into the retained +source. + +A processor that discards animation-bearing source may still produce a Base +render, but it must not claim lossless import or later animation support. A +lossless import request must fail instead of silently stripping the markup. + +## Admitted source + +Profile 0 admits only `` in the SVG namespace. ``, +``, ``, CSS animations, CSS transitions, and +script-created animations are outside the profile. + +An admitted `` contains whitespace only. Apart from namespace +declarations and optional `id`, its complete attribute vocabulary is `href`, +`attributeName`, `from`, `to`, `begin`, `dur`, `repeatCount`, `fill`, +`calcMode`, `additive`, and `accumulate`. Any other attribute on the animation +element is rejected, even when it would express a standard SVG/SMIL feature. + +Sample also rejects `