diff --git a/docs/wg/feat-paragraph/index.md b/docs/wg/feat-paragraph/index.md index c158674a87..750f4bb011 100644 --- a/docs/wg/feat-paragraph/index.md +++ b/docs/wg/feat-paragraph/index.md @@ -1,14 +1,33 @@ --- -title: Paragraph Feature Roadmap +title: Paragraph and Text Layout +description: "Specifications, focused studies, and implementation coverage for paragraph and typography behavior." tags: - internal - wg - typography - text - fonts +format: md --- -# Paragraph Feature Roadmap +# Paragraph and Text Layout + +## Specifications + +- [Universal Shaped Text Layout](./text-layout) defines the canonical + resolution contract shared by geometry-sensitive consumers. + +## Focused studies + +- [Font fallback](./impl-font-fallback) +- [Italic and oblique](./impl-italic) +- [Optical size automation](./impl-opsz-auto) +- [Emoji placeholder rendering](./impl-emoji-placeholder) + +## Coverage inventory + +The checklist below records implementation coverage. It does not define +measurement, shaping, line, glyph, or editing geometry semantics. **Core / Modeling** @@ -65,7 +84,7 @@ tags: - [x] text-decoration-thickness (only supports %) - [ ] text-decoration-skip-ink - https://github.com/rust-skia/rust-skia/issues/1187 - [ ] known limitations https://github.com/gridaco/grida/issues/416 -- [x] [Variable axes](https://github.com/gridaco/grida/blob/canary/docs/reference/open-type-variable-axes.md) +- [x] [Variable axes](../../reference/open-type-variable-axes) - [x] `wght` - [x] `wdth` - [x] `slnt` @@ -84,7 +103,7 @@ tags: - [x] `YTAS` - [x] `YTDE` - [x] `YTFI` -- [x] [open type featuers](https://github.com/gridaco/grida/blob/canary/docs/reference/open-type-features.md) +- [x] [OpenType features](../../reference/open-type-features) **Text Editor** diff --git a/docs/wg/feat-paragraph/text-layout.md b/docs/wg/feat-paragraph/text-layout.md new file mode 100644 index 0000000000..6c5ed59b4a --- /dev/null +++ b/docs/wg/feat-paragraph/text-layout.md @@ -0,0 +1,601 @@ +--- +title: Universal Shaped Text Layout +description: "Open RFD for one versioned text-resolution contract shared by measurement, painting, hit testing, editing, and export." +keywords: + - text layout + - text shaping + - typography + - glyph runs + - line breaking + - rich text +tags: + - internal + - wg + - typography + - text + - fonts + - layout +format: md +--- + +# Universal Shaped Text Layout + +**Status:** Open RFD. + +## Thesis + +Text resolution must happen once. + +Attributed Unicode text, paragraph intent, box constraints, an explicit font +and language environment, and a text-oracle version resolve to one immutable +text-layout artifact. Measurement, painting, hit testing, editing geometry, +damage calculation, and faithful export all consume that same artifact. None +of those consumers may independently estimate widths, choose line breaks, +select fallback fonts, or reshape the text. + +This is the **single text-resolution contract**: + +```text +attributed text ++ paragraph properties ++ box constraints ++ explicit resolution environment ++ text-oracle version + -> resolved text layout | typed resolution failure +``` + +The contract does not require every host to use one global typography +implementation forever. It requires every resolved result to identify the +oracle and environment that produced it, and every consumer of that result to +agree on its geometry. + +## Problem + +Text is not a sequence of independently sized characters. Font selection, +shaping, bidirectional ordering, glyph substitution, cluster formation, line +breaking, alignment, and truncation jointly determine its geometry. A width +estimate made before shaping is therefore not a weaker version of the final +answer; it may be a different answer. + +If measurement and painting resolve text separately, they can disagree on: + +- which font renders a source range; +- how source characters combine into glyphs and clusters; +- glyph advances and offsets; +- legal break opportunities and final line breaks; +- line metrics and baseline positions; +- the width and height reported to surrounding layout; +- caret stops, hit results, and selection geometry; +- truncation and the placement of an ellipsis or other marker; +- logical bounds versus the bounds of visible ink. + +These disagreements compound. A different measured width changes a parent +layout; that new layout changes wrapping; the new wrapping changes height, +paint bounds, hit testing, and export. The system must remove the split at its +source rather than make each downstream consumer imitate the others. + +## Meaning of universal + +“Universal” describes **consumption**, not unlimited feature support. + +- One resolved artifact is universal across all geometry-sensitive consumers + of one text node. +- The contract is independent of a particular scene language, renderer, + operating system, or programming language. +- Script, writing-mode, feature, and font coverage may grow by oracle version. + Unsupported input fails explicitly; it does not receive approximate layout. +- A different constraint, font manifest, language policy, or oracle version + produces a different resolution. It is not another view of the old result. + +Universal does not mean that rasterized pixels must be identical across every +graphics backend. It means the selected fonts, glyphs, positions, lines, +baselines, mappings, and base bounds are already fixed before rasterization. + +## Vocabulary + +| Term | Meaning | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Source text** | The exact Unicode content presented to resolution, addressed by UTF-8 byte offsets at Unicode-scalar boundaries. | +| **Shaping text** | The derived character sequence presented to shaping after any explicit source transformation, with a mapping back to source. | +| **Attributed text** | Source text plus complete, non-overlapping style coverage and stable associations to any paint-only run data. | +| **Paragraph intent** | Direction, writing mode, alignment, spacing, wrapping, line limit, truncation, and other line-level choices. | +| **Resolution environment** | The complete external facts needed to resolve text, including exact font resources, fallback policy, language data, and scale. | +| **Text oracle** | The versioned shaping, segmentation, bidirectional, breaking, metrics, and numeric policy that turns the inputs into geometry. | +| **Shaping cluster** | A unit emitted by shaping that relates a shaping-text range to zero or more positioned glyphs and back to authored source. | +| **Caret stop** | A legal editing boundary with a visual position and affinity; it is not assumed to exist at every UTF-8 or glyph boundary. | +| **Logical bounds** | Typographic layout extents, including advances and line boxes even where no ink is drawn. | +| **Ink bounds** | The tight base drawing extents of resolved glyphs and layout-owned marks before node paints, strokes, filters, or effects. | +| **Resolved text layout** | The immutable, inspectable result described by this RFD. | + +A grapheme cluster, a shaping cluster, a glyph, and a caret stop are distinct +concepts. They often align for simple Latin text and often do not align for +ligatures, combining sequences, emoji sequences, or complex scripts. The +resolved artifact must not collapse them into one index space. + +## Input contract + +Resolution begins only after authoring syntax has been parsed and defaults +have been made complete. An authoring format may expose a compact subset of +these inputs, but it must not make omitted values depend on undeclared host +state. + +### Attributed source + +The attributed source supplies: + +1. the exact source string; +2. complete style coverage of that string; +3. character-boundary run ranges in UTF-8 byte coordinates; and +4. any explicit language, script, direction, or shaping overrides attached to + those ranges. + +For non-empty text, style ranges are ordered, contiguous, non-overlapping, and +cover the complete source string. An empty string carries a complete default +style without inventing a source character. + +Layout-affecting style includes at least the requested font families or faces, +size, weight, width, posture, variable axes, OpenType features, optical sizing, +letter spacing, word spacing, baseline shift, and language or script hints +when exposed. Paint-only values may remain associated with source ranges, but +they do not change shaping or invalidate layout unless their semantics alter +geometry. + +Authored source is not Unicode-normalized by default. If a source feature +transforms text before shaping—for example case transformation—the +transformation policy is an explicit input and the result retains a complete +mapping back to the original UTF-8 source ranges. + +### Paragraph intent + +Paragraph intent is complete before resolution. It includes, when supported: + +- base direction and writing mode; +- horizontal and vertical alignment within the assigned box; +- line-height policy and paragraph spacing; +- soft-wrap and legal-break policy; +- tab and whitespace handling; +- locale-sensitive line or word breaking; +- hyphenation policy and dictionary identity; +- a maximum line count; and +- overflow and truncation-marker policy. + +Explicit source line breaks are source content. A soft break chosen by the +oracle is derived geometry. The result distinguishes the two. + +### Box constraints + +Constraints describe the available inline and block extents in logical units. +Each axis may be unconstrained, exact, or bounded by a finite minimum and +maximum. Values are finite and non-negative, and minima do not exceed maxima. + +The constraint input and the final assigned text box are both retained in the +result. The assigned box is distinct from the logical content bounds and ink +bounds. Fixed block extent alone does not imply clipping, truncation, or an +ellipsis; those are explicit paragraph policies. + +### Resolution environment + +The environment is a manifest, not an ambient promise. It identifies every +external fact capable of changing layout: + +| Environment part | Required identity | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Font resources | Exact content identity, face index for collections, declared family metadata, and availability state. | +| Font selection | Ordered candidates, family and fallback rules, synthesis policy, and permitted missing-glyph behavior. | +| Language services | Unicode, bidirectional, segmentation, and line-breaking data versions, plus locale and hyphenation resources when relevant. | +| Coordinate policy | Logical-unit scale and any text scale applied before layout. Device-pixel rounding is not part of text resolution. | +| Safety limits | Declared bounds on input length, glyph count, line count, or work, when a host imposes them. | + +A family name, file name, URL, or operating-system font handle is not a +sufficient font identity. The same name may resolve to different bytes. A +portable result identifies the exact font content and face used, together with +the effective variation coordinates, features, and any declared synthesis. + +There is no silent “system fallback.” A host may offer system fonts, but their +resolved identities and order become part of the environment before a result +is considered complete. + +### Text-oracle version + +The oracle version identifies the complete geometry-producing policy: +shaping, bidirectional resolution, cluster construction, line breaking, +alignment, truncation, font-metric interpretation, and numeric behavior. + +Any change that can alter a glyph choice, glyph position, line, baseline, +mapping, or reported bound requires a new oracle version. Changing font bytes +or a hyphenation dictionary changes the environment identity instead; changing +how the oracle interprets those resources changes the oracle version. + +The resolved result records both identities. “Latest” is not a valid durable +oracle version. + +## Resolution rules + +### Source preservation and preprocessing + +Resolution never rewrites authored text. Any transformed shaping text is a +derived sequence in its own character coordinate space, with a complete, +possibly many-to-many mapping back to source UTF-8 ranges. +Controls that draw no glyph, explicit line terminators, and characters hidden +by truncation still retain source mappings. + +Every source-backed layout unit is traceable to one or more complete source +scalar ranges. Synthetic units such as an inserted hyphen or truncation marker +have no source byte range and instead record the source boundary and policy +that introduced them. A synthetic unit must never masquerade as authored +content during copy, editing, or accessibility traversal. + +### Font selection and shaping + +Font fallback is resolved at the smallest source unit the oracle can shape +correctly; it is not required to follow authored run boundaries. A paint-only +run boundary may remain transparent to shaping only when every resulting +cluster still has unambiguous paint ownership. Otherwise that boundary is a +declared shaping boundary. This boundary policy is part of the oracle version +and its cache identity; it cannot vary between measurement and painting. + +Each shaped run records the exact resolved face and shaping state. Glyph +identifiers are meaningful only together with that face identity. Consumers +must not reinterpret glyph identifiers against another version of a font. + +The oracle preserves fractional advances and offsets in logical units. Pixel +snapping and raster-device hinting may affect final coverage, but they do not +change the resolved line breaks, caret geometry, or base bounds. + +### Line construction + +Lines are ordered in block progression. Within a line, visual glyph order may +differ from logical source order. The result therefore records both orders +rather than assuming source byte offsets increase from left to right. + +Each line identifies: + +- the source ranges it covers, including any consumed line terminator; +- whether its end is explicit, soft, terminal, or truncated; +- its origin, baseline, ascent, descent, leading, advance, and line box; +- the ordered shaped runs and clusters it displays; and +- its logical and ink bounds. + +Every non-truncated paragraph has exactly one terminal line. An empty source +therefore resolves to one empty terminal line carrying the default style's +line metrics without inventing a source character. When the source ends in an +explicit line terminator, the preceding line ends explicitly and the empty +line after it is terminal. A terminal line is distinct from both a soft break +and an explicit break. + +Trailing whitespace, zero-ink controls, and explicit terminators may affect +logical coverage or caret placement without contributing ink. They remain +represented. + +### Truncation + +Truncation occurs only when paragraph intent requests it. A line limit or box +extent does not silently select a marker policy. + +When truncation is active, the marker is shaped in the same explicit +environment and participates in fitting the final visible line. The result +records: + +- that truncation occurred; +- every omitted source range; +- the source boundary to which the marker is anchored; +- the marker's shaped glyphs, font identity, clusters, and bounds; and +- caret and hit behavior at the truncation boundary. + +The marker is synthetic: copying the visible result does not insert it into +source text unless a separate conversion explicitly asks for that behavior. +The artifact must not assume omitted content is always one logical suffix; +bidirectional presentation can make the visual omission more complex. + +### Coordinates + +All geometry is expressed in the text node's local logical coordinate space. +The inline and block axes, line progression, and baseline direction are +explicit. Fractional values are preserved. A parent transform maps the +resolved local artifact into scene or device space; it does not cause text to +be reshaped. + +## Resolved artifact + +The resolved text layout is immutable and self-identifying. At minimum it +contains the following information. + +### Resolution identity + +- a fingerprint of every layout-affecting input; +- the exact constraint input and final assigned text box; +- the resolution-environment identity; +- the text-oracle version; and +- a record of fallback, synthesis, transformation, and replacement decisions + exercised during resolution. + +### Paragraphs and lines + +- paragraph boundaries and base-direction results; +- lines in block order; +- line origins, baselines, metrics, advances, and break kinds; +- logical and ink bounds per line; and +- alignment and vertical-placement results. + +### Shaped font and glyph runs + +- exact font-content and face identity; +- effective size, axes, features, synthesis, script, language, and direction; +- glyph identifiers in paint order; +- per-glyph positions, advances, offsets, and base ink bounds; and +- stable associations back to effective source style and paint selection. + +### Clusters and UTF-8 mapping + +- each cluster's shaping-text range, mapped source UTF-8 ranges, or explicit + synthetic status; +- the glyph span, line, direction, advance, and logical bounds for that + cluster; +- mappings from every source scalar boundary to its cluster and line; +- mappings from every glyph and cluster back to source ranges; +- legal caret stops, positions, visual order, and upstream/downstream + affinity; and +- enough geometry to derive discontiguous selection regions in bidirectional + text. + +An offset inside a multi-byte UTF-8 scalar is invalid input to source-mapping +queries; it is not rounded silently. A source range that produces no glyph +still maps to a line and editing boundary where the text policy permits one. + +### Bounds + +The artifact keeps three extents separate: + +| Extent | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Assigned text box | The box obtained from authored size intent and constraints; surrounding box layout uses this extent. | +| Logical content bounds | The union of typographic line boxes and advances, including layout space without glyph ink. | +| Base ink bounds | The union of glyph, truncation-marker, inserted-hyphen, and layout-owned decoration ink before external paint expansion. | + +Node strokes, shadows, filters, and other paint effects expand visual bounds +after text resolution. They must use the artifact's base geometry and must not +feed altered measurements back into shaping. Color-glyph intrinsic drawing +bounds are base ink; device antialiasing fringe is not. + +### Completeness invariants + +A complete result satisfies all of these conditions: + +1. Every visible glyph belongs to exactly one shaped run, line, and cluster. +2. Every source scalar range has a defined source-to-layout disposition: + visible, zero-ink control, explicit break, transformed, or omitted by an + explicit policy. +3. Non-synthetic clusters cover the represented shaping text without + accidental gaps or overlap. Their authored-source mapping may be + many-to-many only when an explicit transformation requires it; synthetic + clusters are labeled separately. +4. Font identity and effective shaping state are complete for every glyph. +5. Line, cluster, caret, logical-bound, and ink-bound geometry share one local + coordinate space. +6. The environment and oracle recorded by the artifact are exactly those used + to produce it. +7. No resource remains pending and no fallback decision remains implicit. + +## One result, many consumers + +The resolved artifact is the authority for every geometry-sensitive use: + +| Consumer | Required projection of the one result | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Surrounding layout | Uses the assigned box and logical metrics; it does not estimate character widths. | +| Painting | Uses the recorded faces, glyphs, positions, lines, and layout-owned marks; it does not reshape or rebreak. | +| Hit testing | Uses line, cluster, and caret geometry, including visual order and affinity. | +| Editing and selection | Uses UTF-8 mappings and legal caret stops; it does not infer positions from glyph count or code-point count. | +| Bounds and damage | Starts from base ink bounds and applies known paint expansion; it does not substitute the assigned box for ink. | +| Faithful export | Preserves recorded line breaks and glyph placement, embedding the resolved fonts or outlining when the target cannot express them exactly. | + +Measurement is therefore a query over the resolved artifact, not a parallel +text operation. Likewise, painting is a realization of already resolved +glyph geometry, not a second chance to select fonts or line breaks. + +An export whose purpose is semantic reflow may intentionally resolve the +source again under a target environment. That is a conversion with a new +resolution identity, not faithful consumption of the existing result. + +## Determinism, versioning, and caching + +For the same canonical layout-affecting inputs, environment identity, safety +limits, and oracle version, resolution produces the same semantic artifact. +This includes font choices, glyphs, line breaks, positions, mappings, and +bounds under the oracle's declared numeric policy. + +Determinism does not extend across undeclared system fonts, mutable font URLs, +different font bytes with the same family name, different Unicode or +hyphenation data, or different oracle versions. Those are different inputs. + +Every cache key must account for every fact capable of changing layout, +including: + +- source bytes and all layout-affecting attributes; +- paragraph intent and constraints; +- exact font-content identities and fallback order; +- language, segmentation, breaking, and hyphenation data identities; +- text scale and numeric policy; and +- oracle version and declared safety limits. + +Paint-only changes need not invalidate the geometry artifact. A cache that +also stores painted output must key that output on the paint state separately. +The arrival or replacement of a font resource creates a new environment +identity and a new resolved artifact; a supposedly immutable result is never +patched in place by a late font swap. + +Incremental resolution and internal cache reuse are permitted only when the +published artifact is equivalent to a complete fresh resolution under the +same identity. Partial internal state is not a second public layout contract. + +## Validation and failure behavior + +The resolver returns either one complete artifact or a typed failure. It does +not return plausible metrics with unresolved glyphs and let painting silently +choose a different answer later. + +### Input validation failures + +These include: + +- malformed UTF-8 or a range boundary inside a UTF-8 scalar; +- style ranges that overlap, leave gaps, reverse, or exceed the source; +- incomplete layout-affecting style or paragraph state; +- non-finite or contradictory constraints; +- invalid font sizes, spacing, line limits, feature tags, or variation values; + and +- a requested policy not supported by the selected oracle version. + +### Resolution failures + +These include: + +- a required font resource that is missing, pending, or has a different + content identity; +- no permitted face for a source cluster; +- no permitted glyph fallback for a cluster; +- missing language, segmentation, or hyphenation resources required by the + declared policy; +- an unsupported script, writing mode, or shaping feature; and +- an exceeded declared safety limit. + +Diagnostics identify the source byte range and relevant style or paragraph +property, state the failed policy, and name the resource identities or +candidates involved. A missing-font diagnostic reports the requested family +and resolved resource identity rather than presenting the failure as malformed +source text. + +The default portable policy is strict: absent fonts and unresolved glyph +coverage fail resolution. An environment may explicitly permit a particular +missing-glyph face or replacement policy. If so, that policy and the exact +replacement face become inputs, and the resulting replacement clusters remain +fully represented. Silent tofu, silent font substitution, and silent feature +dropping are never conforming behavior. + +An interactive host may display an explicitly diagnostic placeholder while a +resource loads. That placeholder is not a resolved text-layout artifact and +must not be cached, measured, exported, or presented as the final result. + +## Defaults + +Defaults at this boundary are deliberately independent of ambient platform +state: + +| Input | Default | +| ----------------------- | ----------------------------------------------------------------------------------------------- | +| Unicode normalization | None; preserve the authored sequence. | +| Source language | Undetermined (`und`) unless declared; no host locale is inferred. | +| Base direction | Automatic from content, with left-to-right for a paragraph containing no strong character. | +| Writing mode | Horizontal lines progressing top to bottom. | +| Inline alignment | Start. | +| Soft wrapping | Enabled at legal opportunities when the inline axis has a finite limit; otherwise no soft wrap. | +| Line height | Normal font-metric line height under the selected oracle version. | +| Maximum lines | Unbounded. | +| Truncation | None. | +| Missing fonts or glyphs | Strict resolution failure. | +| Device-pixel rounding | None in the resolved artifact. | + +There is no universal default font face, font size, or fallback list in this +RFD. The authoring language or enclosing document model supplies a complete +default style, and the resolution environment supplies exact font resources. +For example, Grida XML owns its own authored typography defaults; this RFD +owns what happens after those defaults become complete inputs. + +## Conformance + +### Resolver + +A conforming resolver: + +1. **MUST** validate attributed source, paragraph intent, constraints, + environment completeness, and oracle support before claiming a result. +2. **MUST** resolve font choice, shaping, clusters, bidirectional order, line + breaks, baselines, alignment, and requested truncation exactly once into a + complete immutable artifact. +3. **MUST** retain exact font identities, source mappings, logical and visual + order, and the three distinct bound classes. +4. **MUST** preserve fractional logical geometry and keep device rounding out + of the result. +5. **MUST** expose the exact environment identity and oracle version used. +6. **MUST NOT** silently substitute resources, drop shaping requests, repair + invalid ranges, or publish a partial artifact as complete. + +### Consumer + +A conforming geometry-sensitive consumer: + +1. **MUST** use the resolved artifact for measurements, glyph placement, + lines, hit testing, and source mapping relevant to its task. +2. **MUST NOT** independently shape, wrap, select fallback fonts, or infer + caret positions for the same resolution. +3. **MUST** request a new resolution when layout-affecting source, + constraints, environment, or oracle version changes. +4. **MUST** treat semantic reflow under another target as a new resolution, + not as faithful rendering of the old artifact. + +## Relationship to authored formats + +This RFD owns shaped text resolution. An authored format owns its source +syntax, defaulting rules, and mapping into the inputs above. + +[Grida XML](../format/grida-xml) stores Unicode content, flat attributed +`tspan` runs, and text-box intent. Its format defaults and supported or future +paragraph attributes project into paragraph intent. It does not serialize +glyph identifiers, fallback choices, line breaks chosen by the oracle, +baselines, caret positions, or measured bounds. Those are derived output. + +The relationship is one-way: + +```text +authored Grida XML text + -> attributed source + paragraph intent + constraints + -> universal shaped text resolution + -> immutable resolved text layout +``` + +Writing a resolved artifact back into `.grida.xml` as if it were source intent +would destroy responsiveness and bind the document to one font environment. +A separate inspection or cache representation may persist the artifact only +if it preserves its resolution identity and never claims to be canonical +authored source. + +## Non-goals + +- Defining Grida XML's `text` or `tspan` syntax. The XML RFD owns that grammar. +- Changing the scene paint model or introducing a text-specific paint model. +- Defining font discovery, distribution, networking, licensing, or packaging. + The resolution environment identifies fonts after those concerns resolve. +- Requiring identical raster coverage across graphics backends. +- Persisting glyph positions as canonical authored document state. +- Defining arbitrary scene nodes or widgets embedded inside text. A future + inline-object model must extend the input and artifact explicitly. +- Defining editor commands, selection policy, or accessibility semantics. The + artifact supplies source and geometry mappings those systems may consume. +- Claiming every oracle version supports every script, writing mode, text-on-a + path behavior, or typographic feature. Unsupported input fails explicitly. + +## Considered alternatives + +1. **One versioned resolved artifact — accepted.** It makes the actual shaping + result inspectable and gives every consumer the same lines, glyph geometry, + font choices, mappings, and bounds. +2. **Fast approximate measurement followed by real shaping during paint — + rejected.** The approximation can choose a different box and different + lines, so every later correction occurs after surrounding layout has + already consumed the wrong geometry. +3. **Let each consumer call the same shaping service independently — + rejected.** Equal algorithms are insufficient when constraints, font + availability, resource timing, defaults, or versions differ. It also turns + caret and export drift into cache-coordination problems. +4. **Make the renderer's native paragraph object the contract — rejected.** + An opaque backend result does not guarantee inspectable UTF-8 mappings, + stable font identity, complete logical and ink bounds, or portability to a + second renderer and exporter. +5. **Persist resolved glyphs and lines in authored source — rejected.** It + would freeze one environment's output into responsive intent, duplicate the + source string's authority, and create conflicting canonical + representations. +6. **Use ambient operating-system text layout as the environment — rejected.** + Font aliases, fallback sets, language data, and versions vary across hosts. + Ambient behavior can participate only after it is resolved into an explicit + environment identity. 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 new file mode 100644 index 0000000000..2676ae4207 --- /dev/null +++ b/docs/wg/format/grida-xml-animation.md @@ -0,0 +1,443 @@ +--- +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. + +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, +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: + +- how animation populates the accepted authored and effective-value tiers; +- whether animation is nested with its target or stored in a separate + timeline graph; +- 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. + +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 + +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 | 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 +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 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 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? + +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 + +- 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? +- 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 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 + +- 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. 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 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 + 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/grida-xml-component-parameters.md b/docs/wg/format/grida-xml-component-parameters.md new file mode 100644 index 0000000000..64daa2186e --- /dev/null +++ b/docs/wg/format/grida-xml-component-parameters.md @@ -0,0 +1,1002 @@ +--- +title: "Grida XML component parameters" +description: "Open RFD proposing statically evaluated typed scalar props and arguments for Grida XML components while keeping the rendering scene component-blind." +keywords: + - grida xml + - component props + - component arguments + - typed parameters + - static materialization + - slots +tags: + - internal + - wg + - format-schema + - canvas + - authoring + - architecture + - resources + - scene-graph + - text +format: md +--- + +# Grida XML component parameters + +**Status:** Open RFD — selected Version 2 scalar-parameterization delta with a +proving implementation of all six scalar types, defaults and required +arguments, bindings, brace escaping, forwarding, resource origins, +specialization provenance, ordinary-scene materialization, and local-file +rendering. Canonical source writers and complete-library validation remain +unimplemented. None of the parameter vocabulary introduced by this RFD is +valid Version 0 or Version 1 syntax. + +**Companion specifications:** [Grida XML](./grida-xml) owns ordinary authored +scene values. [Grida XML modules and static component +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. [Grida XML durable addressing](./grida-xml-addressing) owns +the later Version 4 member/use identity delta. + +## Decision summary + +Grida XML components may declare a small, typed scalar interface. A use +supplies explicit arguments to that interface. Parameter evaluation completes +during linking and materialization; layout and rendering receive only an +ordinary concrete Grida scene. + +```xml + + + + + + + + + + + + + + {title} + + +``` + +```xml + + + + + + + + + + + + + + + + +``` + +The proposal makes these choices: + +| Question | Proposed answer | +| -------------------------- | --------------------------------------------------------------------- | +| Ownership | Grida XML source linker and materializer | +| Rendering representation | Existing ordinary scene nodes only | +| Declaration | Leading direct `` child of `component` | +| Argument | Direct `` child of `use` | +| Binding | Exact `{prop-name}` reference in an established scalar value position | +| Value model | Closed typed scalar set; no generic value or XML fragment | +| Missing argument | Declaration default, otherwise an error | +| Null | Not a Version 2 value | +| Forwarding | Exact outer-prop reference as a nested argument value | +| Expressions and reactivity | Outside Grida XML parameterization | +| Render-valued input | Proposed Version 3 named-slot delta; invalid in Version 2 | + +## Why Grida owns this above rendering + +A component parameter is authored intent. It states that several concrete +values come from one declared component API and that omitted callers continue +to track the definition's default. Flattened render nodes cannot recover that +relationship. + +Leaving even static parameters entirely to a secondary runtime would make +that runtime—not Grida XML—the source of truth for defaults, resource origin, +diagnostics, linked editing, and canonical writing. Grida XML would become a +generated render interchange rather than a complete file-first authored +language. + +Parameterization is nevertheless not a paint, geometry, layout, or runtime +node concept. Its boundary is: + +```text +entry source + source environment + → parse and link source units + → resolve typed component arguments + → substitute scalar bindings + → validate and materialize ordinary scene + provenance + resources + → resolve layout, text, paths, and paints + → render +``` + +No prop declaration, argument, binding token, default state, or unresolved +value enters ordinary scene resolution. A low-level consumer may continue to +supply an already concrete scene without using components. A higher-level +runtime may generate Grida XML or concrete scenes, but one is not required for +static component customization. + +## Goals + +Version 2 scalar parameterization must: + +- let a component expose a deliberate, inspectable API rather than its entire + internal property tree; +- keep call sites independently parseable through explicit `arg` elements; +- type values before they enter ordinary property validation; +- distinguish an omitted argument from an explicit value equal to the + current default; +- support exact forwarding through nested components without introducing an + expression language; +- retain the lexical origin of resource-valued defaults and arguments; +- produce a finite, fully concrete ordinary scene before layout or rendering; +- preserve declarations, arguments, bindings, and their provenance during + source writing; and +- remain predictable for direct human and language-model authoring. + +## Non-goals + +This proposal does not define: + +- conditions, loops, arithmetic, functions, property paths, or a general + expression language; +- state, events, async values, data fetching, or reactive updates; +- dynamic component references or parameterized `href`; +- implicit conversion through a generic string type; +- nullable values or parameterized attribute presence; +- whole paints, stroke lists, paths, transforms, collections, or render nodes + as prop values; +- arbitrary component attributes directly on `use`; +- deep descendant overrides or patches; +- component variants or inheritance; +- durable instance or component-descendant identity; +- live-component persistence in the packed archive; or +- render-valued slots, children, or fallback subtrees. + +The named direct-projection subset now has a focused Version 3 proposal in +[Grida XML component slots](./grida-xml-component-slots). It does not alter +this Version 2 boundary. Fallback subtrees and other absence policies remain +deferred. + +## Vocabulary + +| Term | Meaning | +| ----------------------- | ---------------------------------------------------------------------- | +| **Prop** | One named typed scalar member declared by a component | +| **Argument** | One explicit value supplied by a use for a declared prop | +| **Default** | Definition-owned literal selected when a use omits that prop | +| **Binding** | One authored scalar sink whose complete value comes from a prop | +| **Prop environment** | The complete effective prop values for one component use | +| **Forwarding** | Supplying an outer effective prop as an argument to a nested use | +| **Value origin** | The source unit and source location where a literal value was authored | +| **Specialization** | Resolving one use's prop environment and substituting its bindings | +| **Render-valued input** | Caller-authored scene content supplied to a component | + +Component ID, prop name, use occurrence, argument, binding target, +materialized node handle, and slot identity are different facts. This RFD does +not substitute one for another. + +## Version relationship + +Version 2 inherits the complete Version 1 module and boxed-component model, +then adds only the syntax and semantics in this RFD. + +| Source relationship | Result | +| ------------------------------------------- | ------------------------------------------------- | +| Version 0 source | Has no component vocabulary | +| Version 1 component used by Version 1 | Static use; `use` remains empty | +| Version 2 component used by Version 2 | Scalar props and arguments are available | +| Version 1 component used by Version 2 | Accepted as an empty-interface static component | +| Version 2 component referenced by Version 1 | Rejected as an unsupported newer component source | + +Version semantics are selected per source unit and reference edge, not from +the entry source or overall link operation. The source unit containing a +`use` determines that use's grammar; the source unit containing a component +definition determines that definition body's grammar. Accepting a Version 1 +component from Version 2 does not upgrade, reparse, or reinterpret the Version +1 source as Version 2. A Version 1 source likewise cannot reference Version 2 +merely because it is reachable from a Version 2 entry. + +For example, `{price}` in a Version 1 component remains the +literal characters `{price}` when used from Version 2, and +`{{price}}` retains both pairs of braces. The same single-brace +form inside a Version 2 component is a binding and fails when `price` is not a +declared prop. + +A Version 2 use of a Version 1 component cannot contain arguments because the +target declares no props. A Version 2 source may itself declare components +with no props and use them statically. + +Unknown Version 2 syntax remains an error. Version 2 does not silently accept +future parameter kinds, value types, or render-valued inputs. + +## Grammar delta + +The Version 2 delta is: + +```text +component := component-start prop* existing-component-body component-end +prop := empty `prop` element +use := empty `use` element | use-start arg* use-end +arg := empty `arg` element +``` + +Comments and formatting whitespace do not affect the grammar. + +Every `prop` is a leading direct child of its `component`. All prop +declarations must precede the component's `fill`, `stroke`, and render +children. A `prop` is non-rendering and occupies no layout or painter +position. A component with no declarations omits `prop` entirely; there is no +`props` collection wrapper. + +Every `arg` is a direct child of `use`. Arguments are typed property children, +not render children, and occupy no layout or painter position. A use may still +be self-closing when it supplies no arguments. Non-whitespace character data, +render children, and every other structural child remain invalid inside +Version 2 `use`. + +The complete list of leading declarations is indexed before bindings on the +component's own start tag are validated. A component may therefore bind its +own box or visual attributes to a prop declared immediately inside it. + +## Prop names and scope + +Prop names use the same compact grammar as component IDs: + +```text +prop-name := [a-z][a-z0-9]*("-"[a-z0-9]+)* +``` + +Names are case-sensitive and unique within one component. These names are +reserved because they belong to component identity or the fixed relationship +between a use and its caller: + +```text +id href name x y flow grow align hidden +``` + +Other ordinary property names, including `width`, `height`, and `fill`, remain +available as prop names. Explicit `arg` elements keep those component-local +names separate from the fixed `use` attribute grammar. + +A prop's lexical scope is exactly one component definition: + +- the component's own scalar attributes; +- scalar attributes in its paint, stroke, text, and render descendants; +- direct `text` and `tspan` character content; and +- argument values on nested uses. + +The scope does not enter the referenced component definition. Values cross +that boundary only through explicit nested arguments. A prop declaration in +one component never shadows, captures, or implicitly supplies a same-named +prop in another component. + +Bindings are invalid in a scene root outside a component because no prop +environment exists there. + +## Scalar value types + +Each prop requires one explicit `type` from this closed initial set: + +| Type | Value domain and purpose | +| ---------- | ------------------------------------------------------------------------- | +| `string` | XML-decoded Unicode string; the empty string is valid | +| `boolean` | Exactly `true` or `false` | +| `number` | Finite Grida XML decimal number | +| `color` | One value from the existing Grida XML authored-color domain | +| `enum` | One member of the declaration's explicit `values` set | +| `resource` | Non-empty authored resource identifier carrying lexical source provenance | + +These are value categories, not parallel scene properties. The established +target property still owns applicability, range, defaults, contradictions, +and materialization. For example, `number` admits a finite value, while an +`opacity` binding additionally requires that value to be in `[0, 1]` and a +`font-size` binding requires it to be positive. + +The complete initial injection relation is: + +| Prop type | Admissible established scalar sink | +| ---------- | ----------------------------------------------------------- | +| `string` | Free-string attribute or direct text-character segment | +| `boolean` | Boolean-valued attribute | +| `number` | Numeric branch of a property grammar | +| `color` | Color-valued attribute, including compact one-solid `fill` | +| `enum` | Keyword branch of a property grammar accepting every member | +| `resource` | Resource-identifier attribute | + +This table defines category compatibility only. The ordinary target still +validates the supplied value and cross-property state. A `number` may, for +example, enter the numeric branch of `width` or `font-weight`; `width` then +applies its non-negative rule, while `font-weight` requires an integer from 1 +through 1000. + +An `enum values="auto"` prop may enter the keyword branch of `width`, and an +enum whose members are accepted position keywords may enter that branch of +`x` or `y`. The same prop still cannot represent both a keyword and a number; +Version 2 defines no number-or-keyword union prop. + +There are no implicit string conversions. A `string` cannot supply a number, +color, enum, resource, path, transform, paint stack, or list merely because +all XML source is textual. No cross-type conversion is defined. + +`enum` requires a non-empty `values` attribute containing unique +space-separated lowercase kebab-case tokens. Every declared enum member must +be valid at every binding target of that prop. Its default and every explicit +argument must be one declared member. + +“Valid at a binding target” means the member belongs to that target's lexical +keyword domain. Constraints involving other authored properties are checked +after specialization, when the complete ordinary target state is known. + +`resource` is distinct from `string` because its lexical source origin affects +resolution. It may bind only to an established resource-valued property such +as image `src`. + +Compound authored values such as position bindings, corner lists, insets, +dash arrays, path data, transform programs, ordered paints, and node subtrees +are not coerced through `string`. Later versions may add explicit types only +after their value and canonicalization contracts are defined. + +This initial type set cannot expose every ordinary property domain. For +example, `number` may supply the numeric branch of `width`, but it cannot also +supply `auto`; no initial prop type represents that union. Authors keep such a +property literal or use a higher-level runtime until a dedicated typed domain +is specified. A processor must diagnose that limit rather than recommend +`string` as an escape hatch. + +## Prop declarations + +`prop` is an empty element with this attribute contract: + +| Attribute | Requirement | Meaning | +| --------- | ----------- | ------------------------------------------- | +| `name` | Required | Unique component-local prop name | +| `type` | Required | One scalar type from the closed set above | +| `default` | Optional | Literal selected when a use omits this prop | +| `values` | `enum` only | Non-empty set of accepted enum tokens | + +A declaration without `default` is required at every use. A declaration with +`default`, including `default=""` for a string, may be omitted by a caller. +There is no separate `required` attribute and no implicit default. + +Defaults are literals owned by the definition source. They cannot reference +another prop. This removes declaration-order evaluation, default dependency, +and default-cycle semantics. + +The default is parsed according to the declared type and validated against +every known binding target. `values` is required for `enum` and invalid for +every other type. Unknown attributes are errors. + +Declaration order is not evaluation order, but it is retained for inspection, +documentation, and canonical argument ordering. Every declaration must have +at least one syntactic consumption site in its component: either a scalar +binding target or a nested `arg value` forwarding binding. A declaration +default, an argument supplied by an external caller, and the declaration's +mere presence do not count. Consumption under a hidden node still counts +because visibility does not change source validity. A prop with no consumption +site is a source error; accepting it would admit no-op arguments that are +especially difficult to detect in directly authored or generated source. + +## Requiredness, omission, and nullability + +Version 2 deliberately has no universal `null` value. + +Each effective value records two independent facts: + +| Fact at this use | States | +| --------------------- | ------------------------------------------------------ | +| Selection | `CalleeDefault` or `Supplied(argument site)` | +| Ultimate value source | Literal argument or declaration default, plus forwards | + +The selection rule is: + +```text +omitted argument + declared default → CalleeDefault +explicit literal or forwarded arg → Supplied(argument site) +omitted argument + no default → error +``` + +A forwarded outer default is therefore `Supplied` at the callee while its +ultimate value source remains the outer declaration default. It does not begin +tracking the callee's default merely because its original literal came from a +default. + +Empty string, `0`, and `false` are ordinary explicit typed values. `auto` and +`none`, where admitted through an enum and accepted by a target, are domain +values rather than null. + +An explicit argument remains `Supplied` even when its value equals the current +component default. Removing it would change future behavior if the definition +default changes. A source-aware processor therefore retains both selection +and ultimate value source even when materialized scalar values compare equal. + +The string `null` is an ordinary string value for a `string` prop. It is +invalid for the other initial types unless it is explicitly declared as an +`enum` member; neither case has null semantics. + +Version 2 cannot parameterize the presence of an attribute. A binding supplies +the complete value of an authored scalar occurrence; it cannot remove that +occurrence. Future nullable props, if justified, must declare nullability and +use one structural spelling rather than a magic string sentinel. This RFD +selects no nullable syntax. + +## Arguments + +`arg` is an empty element with exactly two attributes: + +| Attribute | Requirement | Meaning | +| --------- | ----------- | --------------------------------------------------- | +| `name` | Required | One prop declared by the referenced component | +| `value` | Required | A literal or an exact outer-prop forwarding binding | + +Argument names are unique within one use. Argument order is not semantic. An +argument replaces the declaration default; values do not merge. + +After the referenced component is known, each literal argument is parsed as +the target prop's declared type. Unknown arguments, duplicate arguments, +missing required arguments, and invalid typed literals are errors. No +best-effort coercion occurs. + +At top-level scene scope, `value` must be literal. Inside a component, an exact +binding forwards an effective outer value. This complete fragment declares +both the callee and caller interfaces: + +```xml + + + + + {label} + + + + + + + + + + + +``` + +Forwarding resolves in the lexically containing component. It never captures +a callee prop of the same name. Source and target prop types must match. For +`enum`, every possible source member must belong to the target prop's `values` +set. The forwarded value retains its ultimate literal or default origin +through every hop. + +## Binding syntax + +After XML decoding, `{prop-name}` denotes a prop binding. The binding replaces +one complete scalar value: + +```xml + + + + + + {label} + +``` + +In an XML attribute, the complete decoded value is either literal or exactly +one binding. Attribute interpolation and concatenation are invalid: + +```xml + + + + + + + + +``` + +Direct `text` and `tspan` character content may contain literal character +segments and string-prop binding segments in document order: + +```xml + + + Hello, {person}. + +``` + +Only `string` props may bind into character content. Inserted strings become +characters; they are never reparsed as XML, `tspan`, entities, bindings, or +render nodes. An argument whose string value contains `{other}` therefore +cannot trigger second-order substitution. + +Literal braces use doubling after XML decoding: + +```xml + + + Use {{label}} literally; this value is {label}. + +``` + +In this example the first occurrence materializes as literal `{label}` and +the second is a binding. An unmatched single brace, an empty binding, or a +binding whose content is not a prop name is an error. Canonical writers double +literal braces where binding parsing is active. + +Brace-heavy text remains direct to author because each literal brace is +doubled independently: + +```xml + + + {label}: function demo() {{ return {{ ok: true }}; }} + +``` + +Given `label="Example"`, the text materializes as +`Example: function demo() { return { ok: true }; }`. + +Where binding parsing is active, scanning proceeds left to right with this +precedence: + +1. `{{` emits one literal `{`. +2. `}}` emits one literal `}`. +3. `{prop-name}` emits one binding token. +4. Every other single `{` or `}` is invalid. + +Substituted characters are appended after this scan and are never scanned +again. A canonical encoder is the inverse: it doubles every literal brace and +emits each binding once as `{prop-name}`. This makes adjacent escaped and bound +segments deterministic. + +Binding recognition is controlled exclusively by the declared version of the +source unit containing the lexical occurrence. The following rules apply only +inside Version 2 source units. In Version 0 and Version 1 source, braces retain +their ordinary Grida XML character meaning: `{name}` is not a binding, +doubled braces have no escape semantics, and a Version 2 linker or writer must +not scan, collapse, double, or otherwise reinterpret them. Substitution never +changes the grammar version applied to copied component content. + +Within a Version 2 source unit, binding recognition is contextual: + +| Version 2 source position | Brace behavior | +| ------------------------------------------------------- | ---------------------------------------------------------------- | +| Scalar attribute in a component body or start tag | Exact `{name}` binds; a binding-shaped literal doubles braces | +| `text` or `tspan` character content in a component | String bindings and doubled literal braces are recognized | +| Nested `arg value` inside a component | Exact `{name}` forwards; a binding-shaped literal doubles braces | +| Top-level `arg value` with no enclosing prop scope | Exact `{name}` is an invalid forwarding attempt | +| Prop `default` and all binding-forbidden metadata | Braces are literal and use only the position's ordinary grammar | +| Ordinary scene content outside any component definition | Braces are literal | + +For example, `default="{label}"` on a `string` prop is the literal string +`{label}` because defaults never bind. A top-level string argument that must +literally contain `{label}` uses `value="{{label}}"`; the single-brace form is +rejected as a forwarding reference with no enclosing prop scope. + +Binding recognition applies to parsed XML source, not to already-decoded +literal values supplied by an authoring operation. When plain text or an +existing literal scene value is inserted into a binding-active component +position, the authoring operation must encode every literal `{` as `{{` and +every literal `}` as `}}` before writing source. It must not infer a prop +binding from literal characters. + +Extracting an ordinary scene subtree into a component follows the same rule +and preserves its decoded literal values. Copying structured source that +already contains prop bindings is different: the operation must preserve a +compatible component scope, explicitly rebind the references, or specialize +them to concrete literals. Raw XML paste remains source and is parsed +normally. + +Consequently, moving literal `{status}` into a Version 2 +component canonically writes `{{status}}`, while deliberately +authoring `{status}` inside that component creates a binding. + +Binding is permitted only at an established scalar value sink whose value +category accepts the prop type. It may customize ordinary attributes and +scalar leaves inside structured paint, stroke, gradient, and text properties. +It cannot replace an element name, property subtree, ordered list, or render +subtree. + +Bindings are forbidden in: + +- `grida version`; +- component `id`; +- prop `name`, `type`, `values`, or `default`; +- argument `name`; +- use `href`; +- element and attribute names; and +- any unrecognized or non-scalar value position. + +A prop may bind several compatible sinks. A sink may have either its ordinary +literal attribute/value or one binding, never both representations at once. +After substitution, every sink undergoes its ordinary target-specific +validation. A value that makes another authored property inapplicable is an +error; the materializer does not drop or repair the conflicting state. + +If string substitution makes a `tspan` empty, ordinary `tspan` validation +fails with argument and binding provenance. An empty `text` remains valid +according to the ordinary text contract. + +## Evaluation and materialization + +Parameter evaluation is deterministic and staged: + +1. Parse every loaded source unit under its declared exact version. +2. Index prop declarations and validate names, types, literal defaults, + binding references, and static sink compatibility. +3. Resolve each reachable component reference under the module-linking rules. +4. For one use, reject duplicate and unknown arguments. +5. Resolve every explicit literal or forwarded argument to its declared type. +6. Select the declaration default for each omitted optional prop and report + each omitted required prop. +7. Substitute every binding with its concrete effective typed value. +8. Validate the resulting ordinary authored properties and their + cross-property constraints. +9. Materialize the existing ordinary container subtree plus provenance and + resource manifest. +10. Resolve layout, text, paths, paints, and resources through the ordinary + scene contract. + +There is no prop evaluation order because defaults are literals and sibling +props cannot reference one another. The complete effective environment is +immutable for one specialization. + +Hidden uses and bindings under hidden nodes are still linked, typed, and +validated. Visibility does not suppress component API, resource-type, +lexical-origin, or cycle errors. Actual resource lookup and decode follow the +ordinary render-resource preflight contract; this RFD does not create a +different visibility rule for resources. + +## Resource-valued props + +Every resource value retains the origin of the literal that created it: + +| Value source | Lexical origin | +| ------------------------- | ------------------------------------- | +| Declaration default | Component-definition source | +| Explicit literal argument | Source containing that use | +| Forwarded resource value | Original supplying literal, unchanged | + +Two identical relative strings from different origins may resolve to +different resources. Forwarding through an intermediate component never +rebases the value to that component's source. + +For the primary example, `./images/noise.png` resolves relative to +`presentation.grida.xml` because the caller authored the argument. Had +`texture` declared a relative default, that default would resolve relative to +`components/card.grida.xml`. + +Materialization retains `(source origin, authored identifier)` until resource +resolution or lowers it to an equivalent collision-free runtime key while +preserving the authored string. Only materialized resource occurrences enter +the resource manifest; a value that is merely forwarded has no additional +resource occurrence at the forwarding site. Resource I/O does not occur in +the painter. + +## Provenance and editing + +The retained source program distinguishes: + +- prop declaration and declared type; +- declaration default, when present; +- `CalleeDefault` versus `Supplied` selection at each use; +- argument site, when supplied; +- ultimate literal or declaration-default value source; +- forwarding chain, when forwarded; +- literal lexical origin; +- every binding target; +- containing component and ordered use chain; and +- materialized node occurrence, when applicable. + +Materialization may erase those distinctions from the ordinary render tree, +but a component-aware model and canonical source writer must not. Changing a +declaration default affects uses that omit the argument. An explicit argument +equal to the former default remains pinned and unchanged. + +This RFD adds no durable source-node, use-occurrence, or descendant identity. +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 +add no edges to the Version 1 component-expansion graph and do not alter its +cycle rule. A cycle is rejected before materialized node allocation even when +the uses or bindings involved are hidden. + +Scalar forwarding may increase specialization work but cannot make an +acyclic definition graph recursive. Processing environments may extend their +declared expansion limits to include argument bytes, prop count, binding +count, and specialization work. Exceeding a limit is an explicit resource +failure, never permission to truncate or drop arguments. + +## Source preservation and canonical writing + +A source-preserving writer writes props, args, and bindings rather than their +materialized values or expanded node copies. + +A canonical writer for a Version 2 source unit is defined only after every +`use` whose arguments it normalizes has a successfully linked target +interface. The following rules apply to that Version 2 unit only; they do not +upgrade or rewrite linked Version 0 or Version 1 units: + +- emits `grida version="2"` for that Version 2 source unit; +- writes direct leading `prop` elements with attribute order `name`, `type`, + `values`, then `default` where applicable; +- preserves prop declaration order; +- emits `enum values` members in ascending ASCII lexical order; a + source-preserving same-location writeback may retain authored member order; +- writes explicit args in the linked target declaration order; if that target + interface is outside the available link closure, canonical writing fails, + while source-preserving write-back retains authored argument order; +- does not emit an argument for a `CalleeDefault`-selected prop; +- preserves an explicit argument even when it equals the declaration default; +- preserves forwarding bindings rather than replacing them with effective + literals; +- emits canonical lexical forms for typed literals; +- emits exact `{name}` bindings and doubles literal braces where binding + parsing is active; +- XML-escapes strings normally and uses character references when tabs or + line breaks must survive XML attribute normalization; +- preserves authored resource identifiers and their source association; and +- writes the component/use source program, never materialized clone trees. + +A canonical multi-file writer applies the appropriate version-specific writer +to each source unit. It requires the complete library-validation closure for +the program being written. In a mixed program, every Version 2 source and +Version 1 dependency retains its declared version. A partial-link operation +may perform source-preserving write-back, but it must not claim complete +canonicalization. + +Canonical print, parse, link, and materialize must be a semantic fixpoint for +selection, ultimate value source, forwarding, binding targets, resource +origins, and the resulting ordinary scene. + +A conversion to a target without live component source must flatten with +declared loss or reject, following the module RFD. A concrete materialized +scene without the retained source program cannot reconstruct prop declarations +or determine the selection and ultimate value source of effective values. + +## Validation and diagnostics + +Parameter failures are source, link, or specialization errors rather than +render fallbacks. Useful diagnostics include: + +- `duplicate prop "accent" in component "button"`; +- `prop name "x" is reserved by the use instance boundary; choose a component-local API name`; +- ` declarations must precede fill, stroke, and render children`; +- `prop "accent" in component "button" has no binding or forwarding site`; +- `prop "fit" type="enum" requires a non-empty values attribute`; +- `default "purple" is not a valid color for prop "accent"`; +- `binding "accent" is not declared by component "button"`; +- `binding "lable" is not declared by component "button"; available: label`; +- `prop "label" has type string and cannot bind to opacity`; +- `argument "label" is required by component "button"`; +- `unknown argument "tone" for component "button"; available: accent, label`; +- `duplicate argument "label" for component "button"`; +- `enum prop "fit" cannot forward to "image-fit"; source member "fill" is not accepted by the target`; +- `argument "opacity" is 1.4; bound target opacity requires a value in [0, 1]`; +- `href is not parameterizable; component references must remain statically linkable`; +- `attribute bindings must replace the complete value; interpolation in src is invalid`; +- `unescaped "{" in component text; write "{{" for a literal brace or "{prop-name}" for a binding`; +- `prop binding "label" has no enclosing component scope`; +- `"{label}" in top-level arg value cannot forward without an enclosing component scope; write "{{label}}" for literal text`; +- `version 1 use cannot contain arg; component parameters require version 2`; +- `version 2 use accepts only arg children; render assignments require version 3`; +- `resource "./noise.png" was supplied at presentation.grida.xml:18 and could not be resolved`. + +A nested failure identifies the declaration, argument or default that supplied +the value, binding target, immediate use, referenced definition, and transitive +use chain. It must not be reported only against a transient materialized node. + +## Version 3 named slot projection + +The [Grida XML component slots](./grida-xml-component-slots) RFD proposes the +next exact language delta. Version 3 declares empty named insertion markers as +`` and supplies direct render roots under `use` with the +contextual `slot="…"` relationship. Matching roots splice directly at the +marker while retaining caller lexical ownership. That RFD owns layout, +painter order, clipping, resource origin, cycles, provenance, and +source-writing behavior for projection. + +Version 2 `use` children nevertheless remain exactly `arg*`. A Version 2 +reader rejects slot declarations, assignment attributes, and render children; +it never reparses a scalar string as XML or render content. Version 3 inherits +the scalar contract in this RFD rather than changing its types, binding syntax, +or evaluation rules. + +## Runtime boundary + +The following remain appropriate for a separate runtime or higher-level +language: + +- computed and derived values; +- conditions and repetition; +- state and event handling; +- asynchronous data and resource discovery; +- functions and user code; and +- runtime-changing component selection. + +Such a runtime may produce versioned Grida XML source or a concrete ordinary +scene. The Grida renderer continues to accept the latter without requiring +component support. Conversely, static props and slots do not require that +runtime merely to render or edit an authored `.grida.xml` program. + +## Current model compatibility + +The existing ordinary scene and renderer need no component, prop, or argument +node variants to materialize this proposal. Each use still produces distinct +ordinary node occurrences and the renderer remains component-blind. + +Component-aware inspection and editing require the retained linked source, +use boundary, prop provenance, and materialized occurrence map. The packed +archive cannot claim live parameterized-component round-trip until it gains an +explicit source-program or definition/instance layer. Until then, a converter +must flatten with declared loss or reject; it must not store parameter source +in an XML-only side table while claiming complete archive support. + +An implementation may cache typed defaults, linked definitions, or equivalent +specializations. Caching is conforming only when distinct uses retain distinct +observable occurrence identity, source provenance, resource resolution, and +diagnostics. + +## Considered alternatives + +### External runtime owns all component parameters — rejected as canonical + +External producers remain supported, but requiring one for static defaults and +arguments would make Grida XML unable to preserve or edit its own component +API. Basic deterministic parameterization belongs to the source linker. + +### Component, prop, or use nodes in the render scene — rejected + +The renderer needs concrete geometry, text, layout, and paint values. Adding +unresolved source constructs to that scene would spread component identity and +evaluation through layout, querying, caching, and painting without changing +the final visual model. + +### Arbitrary attributes directly on `use` — rejected + +```xml + +``` + +Component-dependent attributes collide with fixed instance relationship +attributes and make the grammar impossible to validate without resolving the +component. Explicit `arg` children keep the fixed `use` vocabulary stable. + +### Untyped lexical props — rejected + +Treating every prop as a string would create a parallel coercion layer and +weaken diagnostics, especially for colors, numbers, booleans, enums, and +resource origins. The small type set identifies value categories while +ordinary target properties retain their existing constraints. + +### `` and `` collection wrappers — rejected + +Direct leading `prop` and direct `arg` children already have unambiguous +context, match the language's existing leading typed-property topology, and +avoid collection-only tags. + +### Generic `` elements — rejected + +Binding elements are verbose, interfere with established property-child and +render-child order, and cannot naturally represent text segments. Exact brace +bindings use one reference form across scalar attributes, text, and nested +argument forwarding. + +### General interpolation or expression attributes — rejected + +Attribute bindings replace one complete value. Functions, paths, arithmetic, +fallback operators, and concatenated attributes would create an expression +runtime and make static target validation weaker. Text alone permits ordered +literal and string-binding segments because its authored value is already a +character sequence. + +### Defaults that reference other props — rejected + +Dependent defaults introduce evaluation order, a second dependency graph, and +cycles without being necessary for initial static customization. Higher-level +runtimes may compute related values explicitly. + +### Nullable props in Version 2 — deferred + +Requiredness and defaults cover the initial authoring need without confusing +omission, null, empty, and target defaults. A later nullable design must prove +that its target model has a real absent state and define structural syntax; +`value="null"` will not become a universal sentinel. + +### Scalar props and render-valued slots in one version — deferred + +Scalar substitution does not change tree topology. Render-valued input does, +and therefore requires independent decisions about layout, painter order, +fallback, identity, ownership, cycles, and canonical writing. Combining them +would make the small scalar contract depend on the independent structural +policy now owned by the Version 3 component-slot RFD. + +## Proposed conformance requirements + +A conforming implementation of this proposal: + +1. **MUST** implement Version 2 only as the Version 1 module model plus the + parameterization delta defined here. +2. **MUST** require leading direct prop declarations with unique valid names + and one declared scalar type. +3. **MUST** retain `CalleeDefault` versus `Supplied` selection independently + from ultimate value source, including forwarded defaults and an explicit + argument equal to the callee default. +4. **MUST** reject unknown, duplicate, missing, mistyped, and target-invalid + arguments with component and use provenance. +5. **MUST** permit bindings only in compatible established scalar sinks and + validate the resulting ordinary target properties after substitution. +6. **MUST NOT** interpret brace bindings as expressions, rescan substituted + strings, or parse strings as XML or render content. +7. **MUST** resolve forwarding in the lexical outer component and preserve the + original value origin through every hop. +8. **MUST** resolve relative resource values from the source of their supplying + literal, not the binding target or final entry source. +9. **MUST** materialize a fully concrete ordinary scene before layout, text, + path, paint, or render resolution. +10. **MUST NOT** add component, prop, argument, binding, or null variants to + the observable render-scene contract merely to support this source syntax. +11. **MUST** preserve prop, argument, binding, forwarding, and use provenance + in the linked source program and canonical writer. +12. **MUST NOT** remove an explicit argument because it currently equals a + declaration default. +13. **MUST** keep Version 2 use content restricted to typed args and reject + render children or Version 3 slot syntax. +14. **MUST** retain the Version 1 cycle, expansion, instance-independence, and + source-preservation contracts. +15. **MUST** accept Version 1 components as empty-interface static definitions + during a Version 2 operation, but **MUST NOT** permit arguments on such + uses. +16. **MUST** select brace and binding grammar from the containing source unit's + declared version. A Version 2 operation that links a Version 1 component + **MUST** preserve Version 1 braces literally and **MUST NOT** manufacture + bindings or Version 2 escaping inside that source. +17. **MUST** preserve authored argument order and declared source versions + during source-preserving write-back when target interfaces are outside the + link closure. +18. **MUST NOT** claim canonical multi-file output without completing the full + library-validation closure required for target-dependent normalization. +19. **MUST** canonicalize each source unit under its own declared version and + **MUST NOT** upgrade a Version 1 dependency because it was reached from + Version 2. +20. **MUST** reject a prop declaration that has no scalar binding or argument + forwarding site in its component. +21. **MAY** cache immutable declarations or specializations when observable + instance behavior, provenance, and diagnostics remain unchanged. diff --git a/docs/wg/format/grida-xml-component-slots.md b/docs/wg/format/grida-xml-component-slots.md new file mode 100644 index 0000000000..9d96c3b43c --- /dev/null +++ b/docs/wg/format/grida-xml-component-slots.md @@ -0,0 +1,590 @@ +--- +title: "Grida XML component slots" +description: "Open RFD proposing exact Version 3 named render-slot projection for Grida XML components while preserving caller lexical ownership and an ordinary materialized scene." +keywords: + - grida xml + - component slots + - named slots + - content projection + - static materialization +tags: + - internal + - wg + - format-schema + - canvas + - authoring + - architecture + - resources + - scene-graph +format: md +--- + +# Grida XML component slots + +**Status:** Open RFD — selected Version 3 named direct-slot projection delta +with a proving implementation of exact source parsing, linking, direct +projection, projection provenance, ordinary-scene materialization, and +local-file rendering. Canonical source writers and complete-library validation +remain unimplemented. None of the slot vocabulary introduced by this RFD is +valid Version 0, Version 1, or Version 2 syntax. + +**Companion specifications:** [Grida XML](./grida-xml) owns ordinary authored +scene values. [Grida XML modules and static component +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. [Grida XML durable +addressing](./grida-xml-addressing) owns the later Version 4 member/use +identity and occurrence-path delta. + +## Decision summary + +Version 3 components declare named insertion points inline. A Version 3 use +supplies zero or more direct render roots to those names: + +```xml + + + + {label} + + + + + + + + + + {title} + + + + + + + + + + + + + + + + + + + + + +``` + +The proposal makes these choices: + +| Question | Version 3 answer | +| ------------------------ | -------------------------------------------------- | +| Declaration | Empty inline `` | +| Assignment | Direct render root under `use` with `slot="media"` | +| Names | Required, case-sensitive lowercase kebab-case | +| Cardinality | Zero, one, or many roots per declared slot | +| Absence | Zero roots erase the marker | +| Topology | Direct splicing; no implicit wrapper | +| Default slot | None | +| Fallback or requiredness | None | +| Explicit empty | None; omission is the empty projection | +| Lexical ownership | Caller source | +| Structural placement | Slot declaration site | +| Rendering representation | Existing ordinary scene nodes only | + +“Static” means the target component, declared slot interface, supplied roots, +and resulting topology are determined during linking and materialization. +Version 3 slots do not add runtime-changing selection, conditions, or +reactivity. + +## Why Grida owns projection above rendering + +Scalar props specialize values but cannot carry typed render trees. Encoding a +subtree as a string would require a second parse, erase node typing and source +locations, and make resource and binding ownership ambiguous. Copying the +component shell at every call site would instead discard the reusable +definition. + +Slot projection is therefore retained component-source intent. The linked +source program preserves declarations, assignments, lexical owners, and use +boundaries. Materialization removes that vocabulary and produces the ordinary +scene already consumed by layout and rendering. A slot is not a new geometry, +box, paint, or runtime node kind. + +## Goals + +Version 3 projection must: + +- accept caller-authored typed render roots without parsing strings as XML; +- make every destination explicit and named; +- preserve caller resource bases, scalar scope, nested references, and source + provenance; +- give projected roots the exact parent layout, local coordinate space, + clipping ancestry, and painter position selected by the definition; +- preserve one ordinary materialized scene with no slot vocabulary; +- reject unknown destinations and invalid projected relationships before + ordinary scene resolution; +- include assigned content in finite-expansion and cycle validation; and +- remain predictable for direct human and language-model authoring. + +## Non-goals + +Version 3 does not define: + +- unnamed or default slots; +- fallback children, required slots, or an explicit-empty sentinel; +- a slot assignment wrapper or boxed slot node; +- render nodes as scalar prop values; +- deep descendant overrides or patches; +- conditions, loops, reactive content, or dynamic component references; +- durable use, assignment-root, or projected-descendant identity; +- component variants or inheritance; or +- live-component persistence in the packed archive. + +An author may deliberately assign an ordinary `group` or `container`, but that +node retains its ordinary scene meaning. It is not an implicit slot wrapper. + +## Vocabulary + +| Term | Meaning | +| ------------------------- | ------------------------------------------------------------------------------------------ | +| **Slot declaration** | One named, non-rendering insertion marker in a component body | +| **Assignment root** | One caller-authored direct render child of `use` carrying `slot` | +| **Projection** | Replacement of one slot marker by its matching assignment roots | +| **Slot identity** | Qualified component identity plus component-local slot name | +| **Projection occurrence** | One declared slot as observed through one concrete use occurrence | +| **Lexical owner** | Source context that owns parsing, bindings, resources, references, and authored provenance | +| **Structural parent** | Definition-owned ordinary node that receives projected roots | + +Component identity, use occurrence, slot identity, assignment root, +materialized root occurrence, and runtime node handle are different facts. +This RFD does not substitute one for another. + +## Version relationship + +Version 3 inherits the complete Version 2 component-parameter model and adds +only the syntax and semantics in this RFD. + +| Caller source | Version 1 target | Version 2 target | Version 3 target | +| ------------- | ---------------- | ---------------- | -------------------------------------- | +| Version 1 | Static use | Rejected | Rejected | +| Version 2 | Static use | Scalar arguments | Rejected | +| Version 3 | Static use | Scalar arguments | Scalar arguments and named assignments | + +Direct render assignments are valid only on a Version 3 caller-to-Version 3 +target edge. Older callers reject a Version 3 target even when that target +happens to declare no props or slots. A Version 3 caller may link a Version 1 +component only as an empty-interface static definition and a Version 2 +component only through its declared scalar interface; neither older target +may receive render assignments. + +Version semantics are selected per source unit and reference edge. Reachability +from a Version 3 entry does not upgrade, reparse, or reinterpret an older +source. A Version 2 component that references a Version 3 component still +fails on that Version 2 edge. + +## Grammar delta + +The Version 3 delta is: + +```text +component-render-item := existing-render-root | slot +slot := empty `slot` element with exactly `name` +use := empty-use | use-start arg* assignment-root* use-end +assignment-root := render-root carrying contextual `slot` +slot-name := [a-z][a-z0-9]*("-"[a-z0-9]+)* +``` + +Comments and formatting whitespace do not affect the grammar. An existing +render root includes an ordinary render element or a nested component `use`. +A slot declaration is a source marker, not a render root, and therefore cannot +itself be supplied as an assignment root. + +Every `arg` remains the Version 2 typed property child. All arguments precede +all assignment roots. Once the first assignment root begins, a later `arg` is +invalid. Non-whitespace character data and every other structural child remain +invalid inside `use`. + +## Slot declarations + +`slot` is an empty element with exactly one attribute: + +| Attribute | Requirement | Meaning | +| --------- | ----------- | -------------------------------- | +| `name` | Required | Component-local destination name | + +The name is case-sensitive and uses the same lowercase kebab-case lexical +grammar as component IDs and prop names. Slot names are unique across one +complete lexical component body. The scan does not enter a referenced +component definition. Slot names and prop names occupy distinct namespaces; +the same spelling does not alias one to the other. + +A slot declaration is valid only when: + +- its containing source declares `version="3"`; +- it is lexically enclosed by one component definition; and +- it occupies a position where an ordinary render child would be valid. + +It may therefore appear directly in the component's container-like child list +or under a render descendant that accepts children. It is invalid in a scene +root, `text`, `tspan`, `fill`, `stroke`, `gradient`, or any other typed property +subtree. It is also invalid as a direct child of `use`, whose render roots are +assignments rather than declaration sites. + +The declaration accepts no fallback children, character data, relationship +attributes, visual properties, or `required` state. Declaring a slot does not +make supplying it mandatory. + +## Assignment roots + +Every direct render root under a Version 3 `use` requires one `slot` attribute: + +```xml + + + + +``` + +The `slot` attribute is contextual source relationship metadata. It selects a +declaration on the linked target. It is not an ordinary render property and is +invalid on a node merely because that node appears elsewhere. A nested node +may carry `slot` only when it is itself a direct assignment root of a nested +Version 3 `use`. + +One root assigns to exactly one name. Repeated roots naming the same slot are +valid and are not duplicate assignments. Their direct-child order is their +projection order. A missing, empty, malformed, or misplaced `slot` attribute +is a source error. A syntactically valid name not declared by the linked target +is a link error. + +A use may supply no roots even when its target declares slots. Omission is the +only empty-projection spelling. A hidden assigned root is still an assignment: +it is retained, linked, validated, and materialized under the ordinary hidden +contract rather than treated as absence. + +## Projection semantics + +For a slot named `s`, take the subsequence of the use's direct assignment roots +whose `slot` value is `s`, preserving caller order. Replace the declaration at +its exact position in the definition parent's ordered child list: + +| Matching roots | Replacement at the marker | +| -------------- | --------------------------------------------------- | +| Zero | Nothing; the marker disappears | +| One | That ordinary root | +| Many | All matching roots as consecutive ordinary siblings | + +The marker contributes no box, layout position, flex position, gap, clipping +boundary, painter operation, or materialized node. The assignment `slot` +attribute is consumed by projection and does not appear on the ordinary root. + +Each projected root participates independently in the destination parent's +ordinary child contract. Under flex layout, multiple roots consume multiple +flex positions and zero roots consume none. In free placement, each root's +bindings resolve in the destination parent's local box. The slot does not +reserve space when empty and does not group several roots into one layout +child. + +Definition order determines order across distinct slot declarations. Caller +order determines order among roots assigned to one slot. Assignment roots for +different names have no cross-slot painter ordering effect because the +definition owns their destination positions. Their authored direct-child order +is nevertheless retained for inspection and source writing. + +The destination parent and declaration position determine: + +- the local coordinate space; +- flex or free-position relationship rules; +- ordinary ancestor transforms, opacity, and clipping; and +- painter position relative to definition-owned siblings. + +Parent-dependent relationship validation occurs after the target and slot +parent are known. For example, a projected root carrying `grow` requires an +in-flow position under a flex destination parent; a root projected elsewhere +cannot retain `grow` as dormant state. + +## Lexical ownership + +Projection changes structural parenthood without changing lexical ownership: + +| Fact | Owner after projection | +| ------------------------------------------------------- | -------------------------------------------- | +| Element grammar and source version | Caller source unit | +| Scalar binding scope | Lexically enclosing caller component, if any | +| Resource base | Source containing the supplying literal | +| Nested `use href` base | Caller source unit | +| Authored node provenance | Caller assignment site | +| Destination parent and insertion index | Slot declaration | +| Parent layout, local coordinates, and clipping ancestry | Definition structure | +| Callee prop environment | Definition-owned content only | + +Assigned content inside an outer component binds to that outer component's +props. It never captures a same-named prop declared by the slot-owning callee. +At top-level scene scope it has no prop environment. A scalar string remains a +scalar string and is never reparsed as assigned XML. + +The assignment root and every literal in its subtree retain the source unit in +which they were authored. Projection never rebases a relative image or nested +component reference to the callee source. A nested use resolves its `href` +against the caller source exactly as it did before projection. + +## Linking, cycles, and finite expansion + +Version 3 extends component-expansion validation to the assigned subtrees that +become reachable through projection. Expansion follows: + +- every definition-owned nested use in the ordinary component body; and +- every nested use in each supplied assignment subtree at its projected + position. + +Cycle ownership follows lexical component definitions, not the temporary +structural parent created by projection. A nested use in assigned content +contributes an edge from the component definition that lexically owns that +assignment, when one exists. If that edge returns to a component already being +expanded through the caller's definition chain, the program is recursive and +invalid. + +A top-level scene is not a component definition. It may therefore explicitly +nest a second use of the same component in an outer use's assignment when the +authored tree is finite—for example, an outer card whose assigned content is +one inner card with no further assignment. By contrast, component `outer` +cannot use `card` and assign a use of `outer` into `card`; that assignment is +lexically owned by `outer` and closes a recursive definition cycle. A useful +diagnostic reports the lexical owner, component/use chain, and projection edge +through which recursion was reached. + +Hidden definitions, uses, slot parents, and assignment roots remain in link +and cycle validation. Visibility cannot make an otherwise recursive source +finite. Unused exported components remain governed by the module RFD's entry +closure versus complete-library-validation distinction. + +Acyclic projected content may still expand rapidly. Declared processing limits +may account for assignment roots, projected nodes, reference depth, and total +expansion work. Exceeding a limit is an explicit resource-limit failure, never +permission to truncate a slot or keep only some assigned roots. + +## Provenance and editing + +For every declared slot observed through every materialized component use, the +retained component-aware product records: + +- the target component identity and containing use chain; +- the slot name and declaration source location; +- the receiving use occurrence; +- every assignment root and its caller source location in order; and +- the resulting ordinary root occurrence for each assignment. + +This projection occurrence exists even when the ordered assignment and +materialized-root lists are empty. Absence of ordinary roots cannot reconstruct +which empty slot declaration was observed or where it came from. + +The ordinary scene contains neither slot declarations nor assignment +attributes, so it cannot reconstruct this map by itself. Component-aware +inspection and editing require the retained source and provenance. Editing a +slot declaration changes definition topology for every use; editing an +assignment root changes only that caller's supplied content. + +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 +than their projected ordinary copies. It preserves each source unit's declared +version, lexical resource association, slot declaration position, use +boundary, and assignment-root order. + +A canonical writer for a Version 3 source unit requires every target interface +needed to validate and normalize that unit's uses. It: + +- emits `grida version="3"` for that source unit; +- writes each empty declaration as `` at its definition-tree + position; +- writes Version 2 `arg` children before assignment roots and retains the + established target-declaration ordering for args; +- writes every assignment root directly under its use with exactly one `slot` + relationship attribute; +- preserves the complete authored order of assignment roots, including roots + naming different slots; +- represents an empty projection only by omitting assignment roots for that + name; +- preserves caller-authored references and resources with their caller source + association; and +- writes component/use source, never the materialized clone tree. + +If a target interface is outside the available link closure, source-preserving +same-location write-back may retain the unresolved use, but complete canonical +writing fails. It must not guess slot names, drop assignments, or insert a +wrapper. A mixed-version writer canonicalizes each unit under its own declared +version and never upgrades an older dependency because it was reached from +Version 3. + +Flattening into Draft 0 or another ordinary-only target is an explicit lossy +conversion. It removes declarations, assignment relationships, component +editing propagation, and projection provenance. Such a converter must complete +the ordinary tree and correctly preserve or rebase caller-owned resources, or +reject the target. + +## Validation and diagnostics + +Slot failures remain distinct from ordinary rendering failures: + +| Failure | Category | +| --------------------------------------------------------- | ---------------------------- | +| Malformed, misplaced, or duplicate declaration | Source validation | +| Missing or misplaced assignment `slot` attribute | Source validation | +| `arg` after an assignment root | Source validation | +| Unknown slot name | Link validation | +| Assignment supplied to a Version 1 or Version 2 target | Link validation | +| Invalid projected parent relationship | Projection validation | +| Recursive lexical component edge through assigned content | Link validation | +| Ordinary geometry, layout, paint, or resource failure | Existing downstream category | + +Useful diagnostics include: + +- ` requires exactly name="…" using lowercase kebab-case`; +- `duplicate slot "media" in component "card"`; +- ` is valid only at a render-child position inside a Version 3 component`; +- `direct render child of requires slot="…"`; +- `arg must precede every render assignment inside `; +- `slot attribute is contextual to a direct Version 3 use assignment; it is not an ordinary rect property`; +- `slot "thumbnail" is not declared by component "card"; available: actions, media`; +- `render assignments require a Version 3 target; component "card" declares version 2`; +- `projected root uses grow, but slot "media" is not under an in-flow flex position`; and +- `component cycle through slot "body": a#outer → b#card → a#outer`. + +A projected descendant failure identifies the caller assignment root, slot +declaration, receiving use, referenced component, and transitive use chain. It +must not be reported only against a transient materialized node. + +## Current model compatibility + +The ordinary scene and renderer require no component, slot, or assignment node +variant. Projection produces ordinary child sequences before layout, text, +path, paint, and resource resolution. The renderer remains component-blind. + +The retained source program and projection provenance are nevertheless +required for canonical source writing, linked editing, lexical resource +attribution, and an observable empty projection. A packed target without a +live source-program or definition/instance layer must flatten with declared +loss or reject; an XML-only side table cannot make copied ordinary nodes a +lossless live-component round-trip. + +## Considered alternatives + +### Direct named assignment — accepted + +`` makes the destination visible at the definition site, +while `slot="media"` keeps each supplied root self-describing. Direct roots +preserve their actual node kinds and avoid a collection-only envelope. + +### Scalar XML or string props — rejected + +Strings cannot carry typed render trees without reparsing. Reparsing would +erase source boundaries and make resource bases, scalar scope, and diagnostics +ambiguous. + +### A `` assignment wrapper — rejected + +A wrapper would add structure with no independent scene meaning and obscure +the actual assigned root. Authors who need one real grouping node can assign an +ordinary `group` or `container` explicitly. + +### Unnamed or default children — rejected for Version 3 + +Implicit children create an implicit destination and a second assignment +spelling once named slots exist. Version 3 requires every marker and every root +to name the relationship. + +### Fallback, required, or explicit-empty slots — deferred + +These are absence-policy features independent from the initial topology. +Version 3 has one rule: omission yields an empty projection. + +### A boxed slot node in the ordinary scene — rejected + +A box would change layout, coordinate, clipping, and painter semantics. Direct +splicing lets the existing destination parent own those rules without adding a +renderer concept. + +### Callee-scoped assigned content — rejected + +Capturing callee props or rebasing resources to the definition would change +the meaning of caller-authored content merely because it was projected. +Lexical ownership remains with the caller. + +## Proposed conformance requirements + +A conforming implementation of this proposal: + +1. **MUST** implement Version 3 only as Version 2 plus the named-slot delta in + this RFD. +2. **MUST** require empty, uniquely named slot declarations at valid + render-child positions inside Version 3 components. +3. **MUST** require Version 3 `use` content to be leading `arg*` followed by + direct assignment roots, each carrying exactly one contextual `slot`. +4. **MUST** treat the assignment `slot` attribute as a source relationship and + **MUST NOT** accept it as an ordinary render property. +5. **MUST** reject unknown slot names and render assignments to Version 1 or + Version 2 targets. +6. **MUST** permit zero, one, or many assignments per declared slot. +7. **MUST** replace each marker by its matching roots in caller order and + preserve definition order across markers. +8. **MUST NOT** create a wrapper, default slot, fallback subtree, requiredness + rule, or explicit-empty representation. +9. **MUST** validate every projected root against the ordinary relationship + rules of the slot's actual parent. +10. **MUST** preserve caller source version, scalar scope, resource origin, + nested-reference base, and authored provenance. +11. **MUST NOT** expose callee props implicitly to caller-authored assigned + content. +12. **MUST** attribute nested uses in assigned content to their lexical caller + component for cycle validation, including hidden content; it **MUST** allow + finite same-component nesting authored at top-level scene scope and + **MUST** reject a projected edge that returns to an active lexical caller + definition. +13. **MUST** materialize an ordinary scene containing no slot declarations or + assignment attributes. +14. **MUST** retain one projection-provenance occurrence per declared slot and + component use, including empty projections. +15. **MUST** preserve slot declarations, assignment roots, and assignment order + during source-preserving writing. +16. **MUST NOT** serialize expanded ordinary copies as canonical Version 3 + component source. +17. **MUST** preserve exact Version 0, Version 1, and Version 2 parsing and link + behavior and reject links from older callers to Version 3 definitions. +18. **MUST** distinguish source, link, projection, scene, and resource failures + with both caller and definition provenance. +19. **MUST NOT** parse a scalar string as XML or render content. +20. **MAY** impose declared processing limits, but exceeding them must fail + explicitly rather than truncate a projection. diff --git a/docs/wg/format/grida-xml-modules.md b/docs/wg/format/grida-xml-modules.md new file mode 100644 index 0000000000..fb3f80f0ca --- /dev/null +++ b/docs/wg/format/grida-xml-modules.md @@ -0,0 +1,852 @@ +--- +title: "Grida XML modules and static component reuse" +description: "Open RFD proposing multi-file Grida XML linking through boxed component definitions and SVG-like use references while preserving ordinary Grida scene semantics." +keywords: + - grida xml + - components + - multi-file rendering + - modules + - component reuse + - source linking +tags: + - internal + - wg + - format-schema + - canvas + - authoring + - architecture + - resources + - scene-graph +format: md +--- + +# Grida XML modules and static component reuse + +**Status:** Open RFD — selected proposal with a proving implementation of the +static Version 1 entry-rendering kernel: source units, local and external +component references, cycle-safe linking, ordinary-scene materialization, +provenance, resource origins, and local-file rendering. Explicit export roots, +complete-library validation, canonical source writers, and a complete +processing-limit policy remain unimplemented. None of the module/component +vocabulary introduced by this RFD is valid Draft 0 syntax. + +“Static” is deliberate: Version 1 links and repeats exact component definitions. +Typed scalar props are proposed separately as a Version 2 delta in [Grida XML +component parameters](./grida-xml-component-parameters). Named direct render +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), [Grida XML component +slots](./grida-xml-component-slots), and [Grida XML durable +addressing](./grida-xml-addressing). + +## Decision summary + +This RFD proposes one direct model: + +```text +source unit +├─ named boxed component definition* +└─ render root? + +component reference = canonical source identity + component ID +use = one authored instance of that reference +materialization = ordinary Grida container tree +``` + +The corresponding source shape is: + +```xml + + + + + OK + + + +``` + +```xml + + + + + + + +``` + +The proposal makes these choices: + +| Question | Proposed answer | +| ----------------------- | ---------------------------------------------------------------- | +| Reference syntax | `` | +| External import aliases | None in the first version | +| Component exports | Explicit named exports; no default export | +| Definitions per file | Zero or more | +| Scene root per file | Zero or one; required only for a render entry | +| Component shape | Boxed definition with ordinary `container` semantics | +| Runtime shape | Equivalent to an independent ordinary container subtree per use | +| Layout participation | One child position, using the component's declared box | +| Resource base | The source unit where each resource-valued token was authored | +| Recursive instantiation | Invalid | +| Scalar props | Proposed Version 2 delta; invalid in static Version 1 | +| Render-valued inputs | Proposed Version 3 named-slot delta; invalid in static Version 1 | +| Deep patches | Deferred until durable component-local identity exists | + +## Why this belongs above rendering + +Component reuse is source intent. A definition exists once, a use names it, +and editing the definition affects every use. A renderer ultimately needs a +concrete scene, but copied render nodes alone cannot recover which file or use +produced them. + +The system therefore has two distinct products: + +1. a linked source program that retains files, definitions, uses, authored + resource strings, and provenance; +2. a materialized scene containing ordinary Grida nodes for layout and + rendering. + +The second is derived from the first. It is not a replacement for it and is +not the canonical source form. + +This boundary lets component bodies continue to use the existing Grida +primitives, paints, text, and layout. The proposal does not introduce a +parallel widget renderer, a second layout system, or a new paint abstraction. + +## Goals + +The first multi-file contract must: + +- keep every dependency and instance visible in source; +- use the same reference spelling for local and external components; +- preserve one ordinary scene tree after materialization; +- make every use one predictable boxed position in parent layout and painter + order; +- resolve relative component and image locations against the file where they + were authored, never the process working directory; +- reject missing components, recursive expansion, and invalid materialized + geometry before rendering; +- retain enough provenance to report both the use site and definition site; +- permit implementations to share immutable definition data without changing + observable instance semantics; and +- preserve component/use source when writing rather than serializing copied + descendants as if they had been authored inline. + +## Non-goals of the first slice + +The first slice does not define: + +- an expression language or general data binding; +- arbitrary attributes whose names are invented by each component; +- scalar props or named/default slots; +- deep descendant overrides; +- component variants, inheritance, private exports, or re-exports; +- dynamic component references; +- recursive components; +- package names, versions, remote-fetch policy, or dependency lockfiles; +- durable node or instance identifiers; +- component-aware animation or event state; or +- a packed archive representation that preserves live instances. + +These are not prohibited forever. They require a later exact source version so +multi-file loading and static reuse can become correct without prematurely +choosing their identity, typing, or evaluation models. + +The scalar subset now has a focused proposal in [Grida XML component +parameters](./grida-xml-component-parameters). That proposal does not alter +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 | +| ------------------------ | ---------------------------------------------------------------------- | +| **Source unit** | One independently located `.grida.xml` source | +| **Source identity** | The canonical identity supplied by the source-resolution environment | +| **Component definition** | A named, non-painting source definition with `container` semantics | +| **Component identity** | The pair `(canonical source identity, component ID)` | +| **Use** | One authored instance site referring to a component identity | +| **Link** | Resolve references and validate the transitive component graph | +| **Materialize** | Produce the concrete ordinary scene observed by layout and rendering | +| **Provenance** | The source and component/use chain from which materialized intent came | + +A future local component alias, runtime node handle, human node label, and +component identity are different facts. This RFD does not use one as a +substitute for another. + +## Source-unit grammar + +The proposed Version 1 envelope contains zero or more component definitions, +followed by at most one scene root: + +```text +source-unit := component* container? +``` + +Comments and formatting whitespace may appear between those elements. +Component definitions must precede the scene root. No component definition +may appear inside a render tree or another component. + +Definition order is not painter order and does not control lookup. A component +may reference a component declared later in the same source unit; source order +is retained only for stable inspection and writing. + +A source unit may serve one or both roles: + +| Contents | Role | +| ------------------------------- | ------------------------------------------------------ | +| Components and one `container` | Renderable entry that also exports reusable components | +| One or more components, no root | Component library | +| One `container`, no components | Ordinary single-file scene | +| Neither | Invalid; the source unit exports and renders nothing | + +A host asked to render a source unit requires its one scene root. Referencing +a component-library source is valid even though that source has no scene root. +A scene root is never an implicit component export. + +Version 0 retains its exact one-container-child contract and rejects every +element introduced here. A Version 1 linker must not reinterpret a Version 0 +scene root as an unnamed component. Apart from the Version 1 envelope, +component, and use vocabulary proposed here, ordinary render nodes and their +properties retain the Grida XML contract they already have. Version 1 remains +strict current-version syntax: unknown elements, attributes, and structural +children—including Version 2 parameter syntax—are errors rather than inert +extension data. + +## Component definitions + +A component definition has one required `id` and otherwise has the same +authored box, paint, stroke, clipping, layout, and child grammar as +`container`: + +```xml + + Continue + +``` + +At its definition site, `component` is non-rendering: it has no painter +position and contributes no scene bounds. At each use, it materializes as one +ordinary `container` carrying the definition's authored properties and +children. A component may contain nested `use` children wherever an ordinary +container may contain render children. + +This boxed boundary is deliberate. It gives every instance the same known +layout kind, including well-defined flex growth and cross-axis alignment, and +avoids making use-site validity depend on choosing among the incompatible root +contracts of path, text, line, group, or lens. Reusable primitive content +remains straightforward: place the primitive inside the component box. + +The definition has no visual parent, so `x`, `y`, `flow`, `grow`, and `align` +are invalid on `component`. A use supplies that caller relationship when the +definition is instantiated. All other attributes and structural children are +validated exactly as they would be on `container`; component does not acquire +a second layout or paint grammar. + +Every top-level component is public to references that can resolve its source +unit. Version 1 has no private export, re-export, or component alias construct. + +### Component IDs + +`component id` is an exported symbol in one source unit. It is separate from +the human-readable `name` inherited from `container`, from future durable +render-node identity, and from materialized runtime handles. + +The export `id` is definition metadata and is not copied into the materialized +container as a render-node ID. Repeated uses receive distinct runtime handles; +a future durable instance/root identity requires its own authored contract. + +The proposed first grammar is a lowercase kebab-case identifier: + +```text +component-id := [a-z][a-z0-9]*("-"[a-z0-9]+)* +``` + +IDs are case-sensitive and unique within one source unit. The same ID may +exist in unrelated source units because its qualified component identity also +contains the canonical source identity. + +## Uses and references + +`use` is valid wherever one render-node child is valid. It is invalid inside +`text`, `tspan`, `fill`, `stroke`, `gradient`, or any other typed property +subtree. + +Every use requires one `href` whose fragment is a component ID: + +```xml + + +``` + +The fragment is always required. These are invalid: + +```xml + + + +``` + +Version 1 `use` is empty. Character data, render children, `arg`, `content`, +`fill`, `stroke`, and every other structural child are invalid. + +Requiring an explicit component-ID fragment avoids an implicit default export and prevents a +file from gaining a second canonical reference merely because it happens to +contain one component today. + +After XML entity decoding, the Version 1 lexical form is: + +```text +href := "#" component-id | location "#" component-id +``` + +`location` is a non-empty URI reference without a literal fragment or ASCII +whitespace. A literal `#` in the location must be percent-encoded if the source +environment supports it; a backslash is invalid. A query may remain part of +`location` and is interpreted only by the source environment. The component +fragment itself is not percent-decoded: it must already match the exact ASCII +`component-id` grammar. Additional literal `#` characters are invalid. + +### Reference resolution + +For `href="location#id"`: + +1. an empty `location` selects the source unit containing the use; +2. a non-empty relative location resolves against that source unit's base + location; +3. the source-resolution environment returns one immutable link snapshot: + bytes, a canonical source identity, and a canonical base location; +4. the selected source must declare exactly `version="1"` for a Version 1 link; +5. `id` is looked up only in that source's component-export namespace. + +The parser itself performs no file, package, network, or decode operation. +Allowed schemes, filesystem roots, symlink policy, network access, source-size +limits, and caching belong to the explicit source-resolution environment. The +syntax grants none of those capabilities by itself. + +Within one link operation, one canonical source identity must always denote +the same bytes and canonical base. Two lexical locations that map to that +identity therefore share one component namespace for caching and cycle +detection, and their nested relative references resolve from the same base. +An environment that cannot provide that stable tuple must report a source +resolution failure rather than reuse an ambiguous cache entry. The authored +`href` remains unchanged in source. + +Future language versions may define explicit backward-link rules. Version 1 +does not transitively acquire syntax from a newer component source and does +not treat a version-0 scene as a component library. + +## Instance boundary + +A use is a source-level instance boundary. It contributes one materialized +component container to the caller's child list but introduces no additional +painted wrapper around that container. + +The first slice accepts only properties that belong to the relationship +between that component and its caller: + +| Attribute | Meaning | +| --------- | -------------------------------------------------------------- | +| `href` | Required qualified component reference | +| `name` | Optional human-readable label for this use | +| `x`, `y` | Non-span free-position binding of the component container | +| `flow` | Flex participation of the component container | +| `grow` | Main-axis growth of the component container | +| `align` | Cross-axis override of the component container | +| `hidden` | Removes this one instance from layout and painting when `true` | + +Applicability remains contextual: `flow`, `grow`, and `align` are valid only +when the use occupies the corresponding position under a flex container, and +an in-flow use omits `x` and `y` exactly like an ordinary in-flow node. The +`x` and `y` values accept start, end, and center pins but not `span`, because +the component definition owns authored size intent. Flex growth and stretch +may still assign a different resolved box through the ordinary container +layout contract without rewriting that intent. + +The component definition owns its width, height, constraints, paints, strokes, +layout, opacity, rotation, flips, clipping, and all other internal intent. +Those values are not silently overwritten by same-named attributes on `use`. +Changing them requires editing the component or, after parameterization is +specified in a later version, using its declared API. Authors can wrap a use +in an ordinary `group`, `container`, or `lens` when they need additional +composition or visual transformation without changing the component. + +During materialization, `x`, `y`, `flow`, `grow`, and `align` replace the +component definition's forbidden parent relationship with the values authored +on the use or their ordinary defaults. They do not replace any definition-owned +box or visual property. + +`hidden="true"` is an instance gate. It suppresses the use even if the +definition container is active. Omission or `false` does not force a +definition container that is itself hidden to become visible. + +Equivalently, materialized activity is definition activity logically AND the +inverse of use `hidden`. The relationship fields above are validated after +linking against the component's known container semantics, and diagnostics +identify both the use and definition when their combination is invalid. + +`name` labels the retained use occurrence. It neither renames the component +definition nor replaces the materialized container's definition-owned node +label. A component-aware inspector reads the use label from retained +provenance; a rendering-only ordinary tree need not carry it in its one node +label field. + +## Layout and painter order + +Materialization places the referenced component container at the use's exact +position in its caller's ordered child list. + +- The caller observes one layout child, not the component's children as + independent siblings. +- Under flex layout, the use consumes one flex slot. Its `grow`, `align`, and + `flow` values apply to that slot. +- Free `x` and `y` bindings place the component container in the caller's + local box. +- The materialized container's box is the instance's effective box. +- The top-level definition paints nothing at its declaration site. +- The materialized container paints at the use's sibling painter position. +- Internal children preserve their component-source order and ordinary local + coordinate semantics. + +An implementation may physically clone each subtree, retain immutable shared +definition data, or cache a component-specific display result. It is +conforming only when the observable result is equivalent to each use owning +an independent ordinary subtree. State, visibility, layout assignment, and +resource resolution from one use must not leak into another. + +The use occurrence remains an observable component boundary even when +rendering consumes an expanded ordinary tree. Materialized descendants retain +their containing use in provenance; flattening them without that map is a +rendering projection, not a complete component-aware model. Selection, +snapping, hit inspection, isolation, and definition-editing policy belong to +the consuming editor, which may expose either the instance or its descendants +without erasing that boundary. + +## Linking and materialization + +Multi-file rendering requires an explicit stage between parsing and ordinary +scene resolution: + +```text +entry source + source environment + → parse source units + → resolve component references + → validate the component-expansion graph + → materialize one ordinary scene + provenance + resource manifest + → resolve layout, text, paths, and paints + → render +``` + +Every operation begins with the entry source as the same immutable snapshot +required for dependencies: bytes, canonical source identity, and canonical +base location. Local component identity and entry-relative `href` and `src` +values use that entry snapshot rather than an ambient working directory. + +Parsing one source unit preserves its component definitions, uses, raw +resource strings, and local source locations. It does not immediately erase +them into a concrete scene. + +Linking resolves the requested reference closure and validates the qualified +graph. Materialization then produces the ordinary tree consumed by scene +resolution. Every materialized node remains attributable to: + +- its authored source unit and source location; +- the component definition, if any, that contains it; and +- 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 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 + +Three closures remain distinct: + +1. **Source validation** checks the complete syntax and local invariants of + every source unit loaded for the operation. +2. **Link validation** starts from the requested scene root or explicitly + requested component exports and traverses every syntactically contained + `use`, including uses under hidden nodes or with `hidden="true"`. It resolves + references, validates placement, and rejects cycles. An entry render need + not resolve an unused export; a complete library-validation operation uses + every exported component in that library as a root. +3. **Render-resource preflight** inspects the materialized scene for the + requested render environment and applies the base Grida XML image-resource + contract. The module layer changes lexical origin and collision behavior; + it does not weaken which authored image paints a strict materializer must + resolve. + +These distinctions prevent hidden recursion from escaping link checks while +keeping module traversal separate from image-resource resolution. + +## Resource provenance + +Relative resources retain lexical origin across component expansion. + +```text +components/button.grida.xml owns +scenes/dashboard.grida.xml owns +``` + +The image resolves relative to `components/button.grida.xml`, not the +dashboard, not the current working directory, and not the location of a use. + +This rule is required even when two source units author the same string: + +```text +components/button.grida.xml → ./texture.png +cards/card.grida.xml → ./texture.png +``` + +Those references may name different resources. A materializer must therefore +retain the pair `(source origin, authored resource identifier)` until resource +resolution, or lower it to an equivalent collision-free runtime key while +preserving the authored string separately. Keying both resources by the raw +string alone is non-conforming. + +All image resources in the render-resource closure must be resolved or +reported before a strict render claims success. Linking a component must not +cause resource I/O during the painter's execution. + +Version 2 property arguments retain the origin of their supplying literal, +while definition-authored defaults retain the definition's origin, as defined +by the [component-parameter RFD](./grida-xml-component-parameters). Version 3 +assignment roots retain their caller source origin as defined by the +[component-slot RFD](./grida-xml-component-slots). Substitution and projection +must never make a relative resource silently change bases. + +## Cycles and finite expansion + +The semantic dependency graph is the component-expansion graph. Its vertices +are qualified component identities, and an edge exists when one component's +container subtree syntactically contains a use of another component in the +link-validation closure. Hidden state does not remove this edge. + +Direct and indirect cycles are invalid: + +```text +a.grida.xml#button + → b.grida.xml#icon + → a.grida.xml#button +``` + +A diagnostic reports the complete qualified chain and the use location for +each edge. A mere source-reference loop that creates no component-expansion +cycle need not be rejected, although a host may still limit the number of +loaded source units. + +Acyclic graphs can still expand rapidly when definitions contain many uses. +A processing environment may impose declared limits on source count, link +depth, materialized nodes, text bytes, image bytes, or total expansion work. +Exceeding a limit is a typed resource-limit failure. It must not produce a +silently truncated scene. + +## Identity and editing + +The proposal distinguishes four identities: + +| Identity | Scope and purpose | +| ---------------------------- | --------------------------------------------------------- | +| Component ID | Unique export symbol inside one source unit | +| Qualified component identity | Canonical source identity plus component ID | +| Use occurrence | One authored instance position in a caller's source tree | +| Materialized runtime handle | Temporary identity used by one concrete scene realization | + +Repeated uses of one component are distinct instances. Their materialized +descendants must not collide merely because they share definition content. + +Version 1 adds only the component-export ID defined above; it still has no +durable render-node or use-occurrence identity. This proposal therefore does +not pretend a `name`, child index, XML path, or runtime handle can support +stable deep overrides. If durable IDs are later admitted inside definitions, a +descendant instance identity can be formed from an instance path plus a +component-local durable ID. It must not be created by concatenating strings +that can collide after nesting or renaming. + +A component-aware editor must retain the observable use boundary and may +choose how instance selection, definition editing, or descendant isolation are +presented. Editing the definition changes every linked use; editing the use +changes only its instance-boundary intent. Detaching or flattening an instance +is an explicit conversion that severs the reference. + +## Source preservation and conversion + +Three writer domains are distinct: + +1. **Source-preserving same-location write-back** operates on parsed source + units and may follow only an entry-relative link. It preserves each unit's + declared version, component-definition order, authored references, + argument order, resource strings, canonical base association, and + unresolved uses outside the requested link closure. It performs no + normalization that requires an unresolved target interface. +2. **Canonical source-unit writing** is defined only when every target + interface needed for target-dependent normalization in that unit has + linked successfully. It applies the canonical rules belonging to that + unit's declared version. +3. **Canonical multi-file writing** requires the complete library-validation + closure: every scene root, export, and syntactically contained use in the + program being written must link and validate. It canonicalizes each source + unit independently under that unit's declared version. + +Every writer writes source units, component definitions, and uses rather than +the materialized clone tree. A partial-link write may be source-preserving, +but it must not claim complete canonicalization. Failure to obtain a target +interface required for canonical ordering is a canonical-write failure; it is +not permission to drop the use, guess its interface, or rewrite it under +another version. + +Writing the program to a different source/base mapping is relocation, not +ordinary canonical write-back. A relocation operation must either rebase every +affected component and resource reference while preserving its target or keep +the original base mapping available. It must report any reference it cannot +preserve. Full library validation remains a separate operation that starts +from every export and therefore resolves references that an entry-only link +may leave untouched. + +Flattening a linked program into a single Draft 0 scene or into a target that +has no component/instance model is a conversion with declared loss: + +- component definitions become copied descendants; +- uses cease to be linked; +- definition edits no longer propagate; +- instance and definition provenance may be discarded; and +- source-relative resources must be rebased or packaged without changing the + referenced bytes. + +A converter must either complete that flattening correctly and report that +reuse intent was lost, or reject the target. It must not emit an archive that +appears to preserve live instances when it stores only copies. + +## Validation and diagnostics + +Failures are separated by phase: + +| Phase | Representative failures | +| ------------------- | ------------------------------------------------------------------------- | +| Source parsing | malformed XML, duplicate component ID, invalid top-level order | +| Source resolution | missing file, disallowed scheme, unreadable source, unsupported version | +| Linking | missing component fragment, unknown component, expansion cycle | +| Materialization | illegal use placement, invalid component/use relationship, limits | +| Resource resolution | missing or undecodable image with its lexical source origin | +| Scene resolution | ordinary layout, text, path, or numeric failure with component provenance | + +Useful diagnostics include: + +- `component reference requires an ID fragment; use ./button.grida.xml#button`; +- `component "button" is not defined in ./controls.grida.xml; available: icon-button, menu-button`; +- `duplicate component "button" in the same source unit`; +- ` requires id="…" using lowercase kebab-case`; +- ` cannot declare x; place the instance with x on `; +- `x="span 0 0" is invalid on ; component definitions own authored size`; +- `unknown attribute width on ; component sizing is not an implicit root override`; +- `static Version 1 cannot contain children; arg requires Version 2 and render assignments require Version 3`; +- `unknown attribute label on ; Version 1 components do not define dynamic attributes`; +- `component cycle: a.grida.xml#button → b.grida.xml#icon → a.grida.xml#button`; +- `image "./texture.png" authored in components/button.grida.xml resolved to … and could not be decoded`. + +A cross-file diagnostic should identify the immediate use, the referenced +definition when relevant, and the transitive reference chain. A failure in a +materialized descendant should not be reported only with a transient runtime +handle. + +## Current model compatibility + +The current canonical scene and packed archive contracts do not define live +component or instance values. Their scene shape is an ordinary single-parent +tree. The proposal does not conceal that incompatibility. + +Initial layout and raster rendering can conform by retaining the linked source +program and materializing each use into an ordinary container subtree before +scene resolution. This does not require components to become a new geometry, +paint, or layout primitive. Component-aware querying and editing additionally +require the retained atomic use/provenance boundary and cannot be reconstructed +from the ordinary rendering tree alone. + +The ordinary materialized scene alone is insufficient for: + +- canonical multi-file source writing; +- linked definition editing; +- durable instance or descendant identity; +- deep overrides; +- preserving instances through the packed archive; or +- attributing relative resources and diagnostics after source provenance is + discarded. + +Those capabilities require the retained source program described here or a +later explicit definition/instance model in the canonical scene and archive. +An unsupported target must flatten explicitly or reject; it must not store +component syntax in an XML-only side table while claiming full scene-model +round-trip. + +## Later exact component versions + +### Version 2 scalar parameterization + +Static reuse is intentionally the complete Version 1 language boundary. +Strict exact-version parsing means parameterization cannot be added silently +to `version="1"`. + +The [Grida XML component parameters](./grida-xml-component-parameters) RFD +proposes the Version 2 delta: leading typed `prop` declarations, explicit +`arg` children, exact scalar bindings, literal defaults, forwarding, lexical +resource origins, and source-preserving materialization. Version 1 continues +to reject all of that syntax. + +That later RFD also defines the one backward-link exception: a Version 2 +operation accepts a Version 1 component as an empty-interface static +definition. It does not permit a Version 1 operation to link a Version 2 +source, so this RFD's exact-Version-1 rule remains unchanged for Version 1 +operations. + +### Version 3 named slot projection + +The [Grida XML component slots](./grida-xml-component-slots) RFD proposes the +Version 3 delta: empty inline `` declarations and direct render +roots under `use` carrying the contextual `slot="…"` assignment relationship. +Zero roots erase the marker; one or more roots splice directly in caller order. +Version 3 defines no wrapper, unnamed/default slot, fallback, requiredness, or +explicit-empty form. Version 1 and Version 2 continue to reject that syntax. + +Arbitrary deep patches remain deferred until durable component-local identity +and stale-target behavior exist. Dynamic `href`, element-name substitution, +loops, conditions, and a general expression language remain outside static +parameterization and projection. + +## Considered alternatives + +### Direct `href` on `use` — accepted + +`` is self-contained, familiar from SVG, +copy/pasteable with its dependency visible, and uses one spelling for local +and external references. Switching a component is one surgical attribute +edit. + +### Import aliases — deferred + +```xml + + +``` + +Aliases shorten heavily repeated paths, but add an alias namespace, an import +declaration that must move with copied content, and a second layer to diagnose. +Supporting both alias and direct forms would create two canonical references. +Aliases should be reconsidered only if real authored files demonstrate that +reference repetition is materially harmful. + +### One unnamed/default component per file — rejected for the first version + +An implicit default makes a one-component file short, but it conflicts with an +optional scene root, cannot be referenced locally by ID, and becomes +ambiguous when a second component is added. Requiring a fragment keeps every +reference explicit and stable. + +### Custom element names — rejected + +```xml + + +``` + +Custom tags make unknown built-in syntax indistinguishable from a missing +component binding, introduce namespace and case rules, and make a component's +source location invisible at the use site. The fixed `use` element produces +better diagnostics and more predictable LLM output. + +### Textual include — rejected + +An include copies bytes or nodes but does not define component identity, +instance ownership, editing propagation, cycle behavior, or caller-versus- +definition resource provenance. It solves file concatenation, not reuse. + +### Engine-native shared subgraphs as the source model — deferred + +Sharing one definition subtree internally can be an optimization, but the +observable scene remains equivalent to independent instances. Requiring a +shared render graph in the source contract would force a scene-model and +archive change before basic multi-file rendering needs one. + +### Arbitrary render-root definitions — rejected for Version 1 + +Wrapping any one path, text, line, group, lens, or container as a component is +compact, but makes caller-layout behavior depend on a hidden root kind. Span +bindings, flex growth, cross-axis stretch, and derived-box geometry do not +share one applicability contract across those kinds. Version 1 gives every +component an explicit container box instead; primitive content remains a +child of that box. + +### Arbitrary component-property overrides on `use` — rejected from the core + +Letting every component property appear on `use` couples callers to definition +internals and creates replacement-versus-composition questions for paint, +opacity, transforms, clipping, and layout. The core keeps only the caller +relationship on `use`; the [component-parameter +RFD](./grida-xml-component-parameters) proposes intentional customization +through declared props and explicit arguments in Version 2. + +## Proposed conformance requirements + +A conforming implementation of this proposal: + +1. **MUST** preserve every loaded source unit, component definition, use, + authored reference, and lexical resource origin in its source program. +2. **MUST** require explicit component-ID fragments and reject missing or + duplicate component IDs. +3. **MUST** resolve relative component references against the containing + source unit through an explicit source environment whose link snapshot + supplies stable bytes, canonical identity, and canonical base. +4. **MUST** reject direct or indirect component-expansion cycles before scene + resolution, including cycles through hidden uses. +5. **MUST** require every linked Version 1 component source to declare exactly + `version="1"` unless a later specification defines cross-version linking. +6. **MUST** make each use observably equivalent to an independent ordinary + container subtree at the use's one caller position. +7. **MUST** preserve ordinary layout, painter order, local coordinates, + fills, strokes, text, and resource semantics inside the component. +8. **MUST** distinguish a source/link failure from an ordinary scene or + resource-resolution failure and report cross-file provenance. +9. **MUST NOT** resolve relative images against the entry source merely + because the materialized scene is flat. +10. **MUST NOT** serialize materialized copies as canonical component/use + source. +11. **MUST** preserve each source unit's declared version and every unresolved + use outside the available link closure during source-preserving + same-location write-back. +12. **MUST NOT** claim canonical multi-file output without completing the full + library-validation closure required by target-dependent normalization. +13. **MUST NOT** claim live-instance archive round-trip when the target stores + only flattened nodes. +14. **MUST** retain each use as an observable component/provenance boundary, + even when rendering consumes an expanded tree; interaction policy remains + a consumer concern. +15. **MAY** cache, intern, or share immutable definition data when that does + not change instance behavior or diagnostics. +16. **MAY** expose declared processing limits, but exceeding them must fail + explicitly rather than truncate the scene. diff --git a/docs/wg/format/grida-xml-properties.md b/docs/wg/format/grida-xml-properties.md new file mode 100644 index 0000000000..f4a8baf2d9 --- /dev/null +++ b/docs/wg/format/grida-xml-properties.md @@ -0,0 +1,714 @@ +--- +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). + +Later-version reuse vocabulary is specified by the [modules and static +component reuse](./grida-xml-modules), [component +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** +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. + +For paths, the existing contracts do not present one uniform model: one form +stores raw coordinates and derives intrinsic geometry, while another defines a +canonical shape mapped into a layout box. Draft 0 selects the latter and fixes +its reference rectangle to `0 0 1 1`. This is an explicit compatibility choice, +not an attempt to average the two forms or hide a raw-path conversion behind +XML-only state. + +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 | +| **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 | + +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. “Assigned” means Version 3 +direct render roots projected to a component declaration rather than ordinary +children owned by `use`. `tspan` and `slot` are listed explicitly as contextual +source constructs even though neither is a scene node. + +| XML element | Production concept | Children | Status | Source and use | +| ----------------- | ------------------------------------- | --------- | --------------- | --------------------------------------------------------------------------- | +| `grida` | Document envelope | One root | **Draft 0** | XML; carries only `version` | +| `component` | Boxed source definition | Yes | **Design** | Top-level, non-painting definition with `container` semantics | +| `use` | Source component instance | Assigned | **Design** | Versioned component reference; Version 3 may carry direct assignment roots | +| `prop` | Component scalar declaration | No | **Design** | Leading contextual child of a Version 2 `component`; never renders | +| `arg` | Component scalar argument | No | **Design** | Contextual property child of a Version 2 `use`; never a render child | +| `slot` | Component insertion marker | No | **Design** | Empty named Version 3 declaration at a render-child position; never renders | +| `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 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` | Box-mapped unit-space path | Yes | **Draft 0** | Full SVG path-data grammar in the fixed `0 0 1 1` reference rectangle | +| `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 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 + +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` | Draft 0 render elements; omitted for in-flow flex children | SVG/CSS | Start/end/center/span binding; root target is the viewport | +| `width` | `container`, `rect`, `ellipse`, `line`, `path`, `text` | CSS/SVG | Authored width or element-specific `auto` | +| `height` | `container`, `rect`, `ellipse`, `path`, `text`; never `line` | CSS/SVG | Authored height or element-specific `auto` | +| `min-width`, `max-width` | `container`, `rect`, `ellipse`, `line`, `path`, `text` | CSS | Width constraints | +| `min-height`, `max-height` | `container`, `rect`, `ellipse`, `path`, `text`; never `line` | CSS | Height constraints | +| `aspect-ratio` | `rect`, `ellipse`, `path` | 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`, `path`, `text`; contextual `tspan` | SVG | One-solid node fill or explicit run-fill override | +| `d` | `path` | SVG | Complete path-data grammar in the fixed unit rectangle | +| `fill-rule` | `path` | SVG/CSS | `nonzero` or `evenodd`; defaults to `nonzero` | +| `clips` | `container` | Flutter/design tools | Clips descendant content to the container shape | +| `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 | +| `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 | + +`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. + +For `path`, `d` is required and uses the complete SVG path-data grammar in a +fixed `0 0 1 1` reference rectangle. Validation is against exact tight curve +geometry, which must stay within the closed unit box. The geometry maps +nonuniformly to the resolved declared box before fill and stroke construction; +it is never measured or tight-fitted. `fill-rule` defaults to `nonzero` and +also accepts `evenodd`. Paints use the full resolved box, children remain +box-local and unclipped, and the default fill is black. Path strokes accept +only scalar width; center is the default and is required whenever any contour +is open. Resolution maps the command stream once; bounds and all paint channels +consume that exact mapped geometry. Numeric unrepresentability is a resolution +failure, never permission to clamp, substitute the declared box, or retain +partial path ink. + +## Proposed later-version module vocabulary + +The [module and component RFD](./grida-xml-modules) proposes this source-only +vocabulary. These rows are not operative Draft 0 grammar. + +| Element or attribute | Valid on | Proposed contract | +| ----------------------- | ----------------------- | ------------------------------------------------------------------------------- | +| `component` | Direct child of `grida` | Non-painting boxed definition with the ordinary `container` grammar | +| `id` | `component` | Unique lowercase kebab-case export symbol in that source unit | +| `use` | Render-child position | One source instance materialized as the referenced ordinary container subtree | +| `href` | `use` | Required local or source-relative reference with a component-ID fragment | +| `name` | `use` | Optional human-readable instance label; not definition or durable node identity | +| `x`, `y` | `use` in free placement | Non-span parent-relative binding of the component container | +| `flow`, `grow`, `align` | `use` under flex layout | Flex participation of the component container | +| `hidden` | `use` | Instance gate that removes that use from layout and paint when true | + +Component definitions precede an optional one-container scene root. A source +may therefore be a component library, a render entry, or both. Every component +reference requires an explicit fragment, including local references such as +`href="#button"`; there is no default component export or implicit scene-root +export in the proposal. + +`use` intentionally does not accept width, height, paints, opacity, transforms, +or arbitrary component properties in the static Draft 1 proposal. The boxed +definition owns those values. The [component-parameter +RFD](./grida-xml-component-parameters) proposes intentional scalar +customization through `prop`/`arg` in Version 2; Version 1 continues to reject +them. + +### Proposed Version 2 scalar parameter vocabulary + +These rows summarize the [component-parameter +RFD](./grida-xml-component-parameters). They remain non-operative design +syntax until accepted and implemented. + +| Element or form | Valid on or in | Proposed contract | +| -------------------- | --------------------------------------------- | ------------------------------------------------------------------- | +| `prop` | Leading direct child of Version 2 `component` | Empty typed scalar declaration; no layout or painter position | +| `name` | `prop`, `arg` | Lowercase kebab-case component-local API member name | +| `type` | `prop` | Required closed scalar category | +| `default` | `prop` | Optional definition-owned literal; omission makes the prop required | +| `values` | `prop type="enum"` | Required non-empty set of accepted enum tokens | +| `arg` | Direct child of Version 2 `use` | Empty explicit argument; still not a render child | +| `value` | `arg` | Required literal or exact outer-prop forwarding binding | +| `{prop-name}` | Compatible established scalar value position | Complete-value binding; attributes do not support interpolation | +| Text binding segment | Direct `text` or `tspan` character content | Inserts one string value as characters without reparsing | + +Version 2 defines no universal null, expression language, or parameterized +attribute presence. A use may contain `arg` property children but still may +not contain render children. Version 3 projection is specified separately and +does not backport slot declarations, assignment attributes, or render children +into Version 2. + +### Proposed Version 3 named slot vocabulary + +These rows summarize the [component-slot RFD](./grida-xml-component-slots). +They remain non-operative design syntax until accepted as a stable language +version. + +| Element or form | Valid on or in | Proposed contract | +| ------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------- | +| `` | Render-child position in a Version 3 component | Empty named projection marker; no layout or painter position | +| `name` | `` | Required unique component-local lowercase kebab-case slot name | +| Assignment root | Direct render child of Version 3 `use` after `arg*` | Caller-authored ordinary root projected at the named marker | +| `slot` relationship | Direct assignment root only | Required declared target name; consumed by projection and never materialized | + +The `slot` assignment relationship is contextual source metadata, not an +ordinary render property. It is invalid on a node outside the direct +assignment position, is absent from the Draft 0 attribute registry, and never +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 +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 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** | + +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. +Versions 0 through 3 continue to reject `id` on render nodes and `use`. + +### 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** | +| `view-box` | `path` | Optional stable non-unit reference rectangle | SVG | **Design** | +| `fill-rule` | Future 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. Draft 0 +already uses SVG's established `d` name for box-mapped path commands rather +than exposing a storage-oriented `data` name. + +`view-box` remains deferred. If introduced, it would let imported coordinates +retain a stable non-unit reference rectangle, but that rectangle must be +authored intent with positive extents and must never be re-derived from path +tight bounds after an edit. Its omission, canonical writer behavior, and +interaction with the fixed unit form need one versioned rule before the +attribute can be accepted. + +### 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`, `path`, `text`, `tspan` | **Draft 0** | Expanded ordered `Paints`; singular and literal-first when under `tspan` | +| `stroke` | `container`, `rect`, `ellipse`, `line`, `path`, `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` | Direct child of a later-version `grida` envelope | **Design** | Named, non-painting boxed definition with ordinary `container` semantics | +| `use` | Render-child position in a later-version source | **Design** | Source-relative component-ID instance; no independent paint wrapper | +| `prop`, `arg` | Version 2 component definition and use | **Design** | Proposed typed scalar API; complete contract lives in the parameter RFD | +| `slot` | Render-child position inside a Version 3 component | **Design** | Empty named projection marker; complete contract lives in the component-slot RFD | +| 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. + +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 +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` | 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`; open geometry must be `center` | Placement relative to the original outline | +| `cap` | `butt`, `round`, or `square` | `line`, `path` | Open-contour endpoint shape | +| `join` | `miter`, `round`, or `bevel` | `container`, `rect`, `path` | Joined-contour corner shape | +| `miter-limit` | positive finite number | `container`, `rect`, `path` | Miter-to-bevel cutoff ratio | +| `dash-array` | space-separated non-negative finite values | `container`, `rect`, `ellipse`, `line`, `path` | 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. + +### 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, path, 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 | +| ---------------------- | ------------------------------ | -------------------------------------------- | --------------- | +| 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. + +This registry inventories authored XML properties only. The [Universal Shaped +Text Layout](../feat-paragraph/text-layout) RFD owns their eventual shaping, +font resolution, line construction, UTF-8 mapping, metrics, and resolved +bounds; adding a property here must project into that one resolution contract, +not create a second measurement or rendering path. + +| 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 +`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 | +| Non-unit path reference box | Stable authored coordinate rectangle | Optional syntax; positive extents; normalization; canonical writer behavior | +| Variable-width stroke | Ordered `(u, half-width)` samples | Nested stops versus compact list; interpolation; endpoints; base-width interaction | +| 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 | +| 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 | 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 | +| 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` +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 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 +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..6917fa64f9 --- /dev/null +++ b/docs/wg/format/grida-xml.md @@ -0,0 +1,1722 @@ +--- +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 + - component reuse + - multi-file rendering +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, per-side box strokes, rounded box geometry, text boxes with +flat attributed runs, unit-space paths, 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. The +selected multi-file direction is developed separately in the [Grida XML +modules and static component reuse](./grida-xml-modules) RFD; none of that +proposal is Draft 0 syntax. The proposed typed scalar API for those components +is a further Version 2 delta in [Grida XML component +parameters](./grida-xml-component-parameters). Exact named render projection is +a Version 3 delta in [Grida XML component +slots](./grida-xml-component-slots). + +## Design requirements + +1. **One obvious tree.** Outside explicitly typed property subtrees such as + `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 + `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. +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 + +`.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. + +At resolution, the render root is the sole child of a definite, non-painting +initial viewport container. Its `x` and `y` therefore use the ordinary binding +grammar against that viewport. The explicit full-viewport form is +`x="span 0 0" y="span 0 0"`; as with every span, the corresponding +`width`/`height` and min/max constraints must be omitted. The same materialized +scene may then resolve at different viewport extents without rewriting source +intent. + +Omitted root bindings remain start-pinned at zero, and omitted or explicit +`auto` sizes retain ordinary container-hug semantics. A fixed root extent is +not scaled to the viewport; it may be smaller than or overflow that +environment. `flow`, `grow`, and `align` remain invalid on the render root +because the initial viewport container does not own flex layout. + +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 | 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 | +| `path` | Declared | Yes | SVG path commands in a fixed unit reference box | +| `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 +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 except where a property defines a normalized reference + space; path data uses the fixed unit rectangle from `(0, 0)` through + `(1, 1)`. +- 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 an element may appear on that element. + +| 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 | +| `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 | +| `d` | SVG path-data string | required | Defines a `path` in the fixed unit reference rectangle | +| `fill-rule` | `nonzero` or `evenodd` | `nonzero` | Selects path interior construction | +| `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 | +| `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. On a render node it cannot coexist with the structured `` +channel. Node `fill` is valid on `container`, `rect`, `ellipse`, `path`, 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 + +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. + +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 + + + + + + + + + + + + + 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`, +`path`, and `text` accept fills and strokes. `line` accepts strokes only. +`group` and `lens` accept neither. The contextual `tspan` exception accepts one +`fill` as its literal first child and no `stroke`. + +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`, `path`, `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. 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 + `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 Draft 0 stroke geometry is: + +| 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` and `path` | every `stroke`; open geometry must be `center` | +| `cap` | `butt`, `round`, or `square` | `butt` | `line`, `path` | +| `join` | `miter`, `round`, or `bevel` | `miter` | `container`, `rect`, `path` | +| `miter-limit` | positive finite number | `4` | `container`, `rect`, `path` | +| `dash-array` | space-separated non-negative finite values | absent, meaning solid | `container`, `rect`, `ellipse`, `line`, `path` | + +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. A `path` has the same centered default; `inside` or `outside` +is valid only when every contour is closed. 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 + + + + + + + +``` + +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. 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 +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 | +| `path` | box-mapped path fill region | box-mapped path contours | 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, mathematically invertible 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. + +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. 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: + +| 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, +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, 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 + +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. + +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 +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`, `path`, 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`, `ellipse`, and `path`. +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. + +### 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 + +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; 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 + +Draft 0 recognizes the direct primitive elements `rect`, `ellipse`, `line`, +and `path`. They do not accept a `kind` attribute. + +- `rect`, `ellipse`, and `path` 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)` + 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. + +#### Path geometry + +`path` is a declared-box shape, not intrinsically measured SVG content. Its +required `d` attribute uses the complete SVG path-data grammar: absolute and +relative commands, lines, cubic and quadratic curves, elliptical arcs, +multiple contours, and close-path commands are all available. The grammar is +borrowed; SVG's surrounding viewport model is not. + +Path commands are authored in one fixed reference rectangle: `0 0 1 1`. The +exact tight geometry of every drawable contour must remain within that closed +unit rectangle. Validation uses the realized curve extrema, not merely +endpoints or control-point coordinates. A curve whose control point leaves the +unit rectangle is therefore valid if the curve itself stays inside; a curve +that overshoots is invalid even when every endpoint is inside. + +For a resolved box of width `W` and height `H`, each reference-space point +`(u, v)` realizes as `(u × W, v × H)`. The two axes scale independently. This +geometry mapping happens before fill and stroke coverage are constructed, so +layout resize changes the outline without rewriting `d`, while stroke widths, +dash lengths, and other style lengths remain logical-pixel values. The same +rule applies when span bindings, aspect-ratio resolution, flex growth, or +cross-axis stretching determine the final box. + +The fill rule is `nonzero` by default and may be changed with +`fill-rule="evenodd"`. Omitted fill produces the ordinary primitive default of +one opaque black solid. Repeated strokes use the same realized outline and may +use scalar width, cap, join, miter limit, and dash array. A path stroke defaults +to center alignment. Inside or outside alignment requires every contour to be +closed; a path containing any open contour must use center. Per-side stroke +width is invalid because a path has no four box sides to which those widths +could attach. + +Every path paint, including gradients and images, evaluates against the full +resolved rectangular box rather than the path's tight ink bounds; fill and +stroke coverage still follows the realized outline. The path does not +establish a descendant clip. Children use the same resolved box-local +coordinate space as children of `rect` and `ellipse`; they do not inherit the +unit path coordinate space. The realized path's tight geometry seeds visual +bounds, effective strokes are included conservatively, and damage covers the +result, while the declared box remains the path's layout contribution. Hit +testing and editor selection policy are consumer concerns; Grida XML defines +no separate authored hit region. + +```xml + + + + + A + +``` + +#### Considered path coordinate models + +The fixed unit rectangle is accepted because it keeps path geometry on the +same side of the layout boundary as every other primitive: the box negotiates +with layout, and the path is a pure function of that box. Resize never rewrites +the command stream, `x` and `y` always place the box's top-left origin, and a +small edit to one curve cannot silently redefine the coordinate system for +all other contours. + +Raw intrinsic coordinates with tight-bounds measurement are rejected as the +canonical model. [SVG path data](https://www.w3.org/TR/SVG2/paths.html#PathData) +uses the current user coordinate system, while +[`viewBox`](https://www.w3.org/TR/SVG2/coords.html#ViewBoxAttribute) defines a +mapping on a viewport; a raw path has no intrinsic viewport of its own. +Treating its tight bounds as a Grida box +would make path edits change layout contribution, permit a nonzero content +origin unlike other boxed primitives, and leave flex-assigned sizes with no +defined relationship to the painted outline. Tight-fitting raw geometry into +the assigned box is also rejected: intentional padding disappears, zero-width +or zero-height geometry has no scale, and changing one curve extremum remaps +every untouched contour. Those failures are structural, not parser details. + +An optional `view-box="min-x min-y width height"` is a plausible later +ergonomic extension for lossless copying of arbitrary SVG coordinate values. +It is deferred because the unit form already expresses the geometry without a +second reference rectangle, and admitting it requires stable-reference, +positive-extent, validation, and canonical-writer rules. If introduced, the +reference rectangle must be authored intent and must never be continuously +re-derived from tight bounds. `view-box` is unknown syntax in Draft 0. + +#### Lessons retained + +- The declared box is the sole surface that negotiates with layout; path + geometry realizes into it and never becomes a competing measurement. +- A reference space is source intent. Whether the fixed unit rectangle or a + future explicit rectangle, it must not be recomputed from changing tight + bounds. +- Geometry maps before coverage is constructed, so fills retain their + box-relative coordinates and stroke widths and dash lengths remain + pixel-stable. +- One resolved path artifact must supply painting, tight geometry bounds, and + damage. Independent reparsing or geometry reconstruction may not produce + divergent answers. +- Reference-to-box mapping is a resolution operation and happens once in the + resolver's numeric domain. Visual bounds must conservatively enclose that + exact mapped command stream; independently scaling source-space bounds is + not equivalent at finite precision. +- A numeric environment that cannot represent a valid mapped command stream + must report a resolution failure. It must not clamp controls, retain + non-finite geometry, substitute box bounds, or paint only a surviving subset. +- Source equality and visual equality are different contracts. The source tier + retains authored `d`; a resolved visual comparison may ignore relative versus + absolute spelling only after both forms produce the same command stream, + fill rule, closure state, and conservative bounds. +- Raw intrinsic paths and box-mapped paths are different models. A boundary + must convert explicitly or reject; it must not relabel one as the other. +- Fill rule is part of path material identity. It survives parsing, + resolution, rendering, inspection, and rewriting with the path geometry. + +### Text + +`text` is the scene node. It owns the text box, paragraph properties, default +text style, node fill stack, and repeated node stroke geometries. + +The [Universal Shaped Text Layout](../feat-paragraph/text-layout) RFD owns the +shaping, font resolution, line construction, UTF-8 mapping, metrics, and +resolved bounds produced from that source. This section defines only the XML +text intent and its mapping into that contract. Grida XML never serializes the +resolved text-layout artifact. + +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: + +| 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. Text shaping and +geometry follow the single-result contract in [Universal Shaped Text +Layout](../feat-paragraph/text-layout). 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`; +- `flow`, `grow`, or `align` on the render root; +- 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 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; +- 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, + 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 + 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; +- a `path` without `d`, malformed SVG path data, path geometry whose exact + tight bounds leave the closed unit rectangle, an unknown `fill-rule`, a + four-value path stroke width, or non-center stroke alignment on a path with + any open contour; +- non-finite numbers, negative dimensions, or malformed colors; +- 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]`; +- 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 +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; 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`. Useful path diagnostics identify the failing +command or number and distinguish grammar from geometry, for example `path d: +invalid arc flag at byte 19`, `path geometry exceeds the unit box on the right: +max-x is 1.08`, and `outside path stroke requires every contour to be closed`. + +## 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, + per-character resolved text style and run-fill override state, and empty + strokes with non-default geometry. It must preserve path commands, + contour closure, and fill rule without replacing them with tight-fit output. +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`, `path`, `tspan`, `width`, `height`, + `d`, 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. 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** 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. +7. **MUST** encode XML text and attribute values correctly and emit only + finite, valid values. It must emit path data in the authored unit reference + space without tight-fitting it to observed ink, omit the default + `fill-rule="nonzero"`, and emit `fill-rule="evenodd"` when selected. +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 or materialized path points, 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 + +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** map path geometry from the fixed unit rectangle nonuniformly into + the final resolved box before constructing fill or stroke coverage. It must + preserve logical-pixel stroke and dash lengths, apply the declared fill + rule, seed visual bounds from realized tight geometry, conservatively + include effective strokes, and cover those bounds in damage. Mapping, + bounds, fill, and every stroke must consume one resolved command artifact; + an implementation must not independently rescale source bounds or reparse + `d`. If its numeric domain cannot represent that artifact, resolution must + fail explicitly without partial path ink. +4. **MUST** preserve source order in painting, with the node's fill first, + children in document order, and repeated parent strokes from first to last. +5. **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. +6. **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. +7. **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. +8. **MUST** composite every visible paint in its declared bottom-to-top order, + applying each paint's opacity and blend mode to that paint only. +9. **MUST** evaluate gradient geometry in the declared unit paint space and + map it to the resolved paint box without rewriting source coordinates. +10. **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. +11. **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. +12. **MUST** include every effective stroke in resolved visual bounds without + changing the node's layout box. +13. **MUST** apply rotation and native flips as visual-only transforms and + apply clipping and opacity to the declared subtree. +14. **MUST NOT** mutate the source document as a side effect of resolving or + rendering it. +15. **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 editable vector networks, non-unit path reference +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. 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 +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. +The [Grida XML modules and static component reuse](./grida-xml-modules) RFD +proposes top-level boxed definitions and self-contained +`` references, with source linking and materialization kept +above ordinary scene resolution. It also records identity, lexical resource +origin, cycles, and source-writing requirements. The [Grida XML component +parameters](./grida-xml-component-parameters) RFD separately proposes typed +scalar `prop` declarations, explicit `arg` children, and exact bindings for +Version 2. The [Grida XML component slots](./grida-xml-component-slots) RFD +proposes Version 3 empty named `` declarations and direct render roots +under `use` carrying the contextual `slot` assignment relationship. + +Those proposals remain later-version contracts. Draft 0 defines neither +`component`, `use`, `prop`, `arg`, nor any component binding syntax, and a +current-version reader must reject them rather than interpreting the proposed +syntax early. Draft 0 also grants no meaning to the Version 3 `` +declaration or contextual `slot` assignment attribute, nor to invented +`content` or `children` elements. Brace characters remain ordinary Draft 0 +character data or attribute characters wherever that position's existing +value grammar admits them; Draft 0 never interprets braces as bindings. + +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 beyond Draft 0 + +- 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 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 + file identity? diff --git a/docs/wg/format/index.md b/docs/wg/format/index.md index 3445321295..d865655243 100644 --- a/docs/wg/format/index.md +++ b/docs/wg/format/index.md @@ -1,5 +1,6 @@ --- title: Format & Import Mapping +description: Specifications for Grida's authored formats and trackers for importing external formats into the Grida IR. format: md tags: - internal @@ -11,26 +12,40 @@ tags: Tracking docs for the Grida IR schema and how external formats map into it. -## Pages +## Specifications and RFDs -| 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 | +| [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 + +| Page | Description | +| -------------- | ------------------------------------------------ | +| [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 -- **Status key:** ✅ mapped | ⚠️ partial | 🔧 IR exists, not wired | ❌ IR missing | 🚫 out of scope -- Each page tracks what is implemented, what is partially done, and what is blocked by missing IR fields. -- The **IR Gaps** section in each page identifies schema changes that would unblock further progress. -- For the on-disk `.grida` file format (FlatBuffers schema), see `format/grida.fbs`. +The CSS, HTML, and SVG trackers use this status key: ✅ mapped | ⚠️ partial | +🔧 IR exists, not wired | ❌ IR missing | 🚫 out of scope. Their **IR Gaps** +sections identify schema changes that would unblock further progress. + +For the on-disk `.grida` file format, see the [FlatBuffers +schema](https://github.com/gridaco/grida/blob/main/format/grida.fbs). ## Related -- **FlatBuffers schema:** `format/grida.fbs` — the canonical on-disk file format -- **Rust runtime model:** `crates/grida/src/node/schema.rs` -- **TypeScript model:** `packages/grida-canvas-schema/grida.ts` -- **HTML import pipeline:** `crates/grida/src/import/html/` -- **SVG import pipeline:** `crates/grida/src/import/svg/` +- **FlatBuffers schema:** [canonical on-disk format](https://github.com/gridaco/grida/blob/main/format/grida.fbs) +- **Rust runtime model:** [node schema](https://github.com/gridaco/grida/blob/main/crates/grida/src/node/schema.rs) +- **TypeScript model:** [canvas schema](https://github.com/gridaco/grida/blob/main/packages/grida-canvas-schema/grida.ts) +- **HTML import pipeline:** [HTML importer](https://github.com/gridaco/grida/tree/main/crates/grida/src/import/html) +- **SVG import pipeline:** [SVG importer](https://github.com/gridaco/grida/tree/main/crates/grida/src/import/svg) 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/.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..fb8f27b0d7 --- /dev/null +++ b/model-v2/README.md @@ -0,0 +1,130 @@ +# 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 — 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 + +```sh +# 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) +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); 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: + +- **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 phase-4 engine layer programs with day-1 contracts + ([`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, + 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..8aab5da8e3 --- /dev/null +++ b/model-v2/a/DECISIONS.md @@ -0,0 +1,330 @@ +# 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. + +**Current proof.** With a host font, the engine now gives resolver, drawlist, +and paint one Skia Paragraph-produced immutable glyph layout; paint replays its +glyph IDs and exact list-owned fonts without reshaping. Fontless probes use the +explicitly named stub and emit no glyphs. This closes the architectural +measurement/paint mismatch, not DEC-4: the host-font bridge claims no +cross-platform determinism, deliberately disables fallback, fixes paragraph +direction to LTR, and does not yet provide complete caret mapping. + +| 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/ENGINE.md b/model-v2/a/ENGINE.md new file mode 100644 index 0000000000..97a8fc34f3 --- /dev/null +++ b/model-v2/a/ENGINE.md @@ -0,0 +1,470 @@ +# 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 + 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 + 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]` — 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 + 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. 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.** + +- **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 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 +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 text seam is concrete: the resolver accepts an +explicit `TextLayoutOracle`; the host-font frame path uses Skia Paragraph to +produce one immutable final-width glyph layout; the drawlist gives that same +artifact to the fill and every stroke; and paint replays its glyph IDs and +exact list-owned fonts without reshaping `[MEASURED]`. The artifact records its +oracle/environment, input constraint, assigned box, semantic font identity, +line topology, and logical/ink bounds; bounds and damage consume it directly +`[MEASURED]`. Fontless probes use the explicit `stub@lab-0`, which emits line +metrics and no glyph runs +`[MEASURED]`. This proves shared consumption for the implemented sizing, +painting, bounds, and damage paths—not cross-platform determinism or universal +conformance. DEC-4 remains open; the current bridge deliberately disables font +fallback and fixes paragraph direction to LTR `[MEASURED]`. Pick remains +box-based, while editing/caret geometry and faithful export are not +implemented. B-1 (wrap decisions are discrete; no ε absorbs a line-height +jump) and B-5 (bool needs pathops inside measure) remain 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 · determinism posture is explicit** `[CONTRACT]` — oracle + identity names the comparison domain. DEC-4 decides whether that domain is + cross-platform bit-exact or platform-scoped; until then, the Skia Paragraph + bridge claims neither. ENG-1.1 and ENG-5.2 use bit-equality only within the + declared oracle environment. +- **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, environment, 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.** explicit fontless stub + host-font Skia Paragraph bridge +(today, measured) → the pinned or platform-scoped production oracle chosen by +DEC-4 → 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]`. 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.** + +- **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]` — + 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 + 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]`). + +## 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 with skia) ← hosts (engine + with winit/egui/GL). Raster semantics stay in `paint.rs`; shaping stays in + `text_layout.rs`; frame/cache/host code may own or route Skia surfaces and + resources without duplicating either semantic stage. 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 + 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/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..e1c92c0e55 --- /dev/null +++ b/model-v2/a/LIMITS.md @@ -0,0 +1,148 @@ +# 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 + free children | ✅ | ✅ (F-1/2/4 + 🎬) | ✅ | ✅ | — | rect/ellipse/line only in lab | +| text | ✅ | ✅ flowed (oracle-backed) | ✅ (🎬) | 📜 mirrors uniformly (DEC-9 sub) | ✅ | n/a | — | Skia Paragraph path; B-1 open | +| 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. The + engine now proves one shared measurement/draw artifact with a host-font Skia + Paragraph path, but that bridge is not cross-platform deterministic and + records only a process-local font environment. It deliberately has fallback + disabled, accepts only a width constraint, fixes direction to LTR, and + reports unresolved glyphs without yet returning the RFD's typed resolution + failure. It closes the architecture mismatch, not B-1 or DEC-4. +- **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, +`max_lines`/`ellipsis` fields, percent pins, anchor-to-node (`wire`), +incremental invalidator, a pinned production text oracle, general font +fallback/bidi, complete source↔cluster/caret mapping, 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..b08d0cecff --- /dev/null +++ b/model-v2/a/MODEL.md @@ -0,0 +1,181 @@ +# 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), 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 | +| `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** | + +`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 + + 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. + +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 + 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: + [`dec0-visual-only.md`](./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..4af4cc9697 --- /dev/null +++ b/model-v2/a/README.md @@ -0,0 +1,124 @@ +# `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 and E10 are complete with +> 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)**. + +## 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: 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) + +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 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"). + +## 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) | +| **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` (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 + +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). +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: 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. 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..6e2721a34b --- /dev/null +++ b/model-v2/a/e3-text-ir/grammar.md @@ -0,0 +1,153 @@ +# 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 +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 non-newline character counts + (spaces too) +- line height = `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))`; 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.) + +### 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..e8cb375c58 --- /dev/null +++ b/model-v2/a/lab/Cargo.lock @@ -0,0 +1,200 @@ +# 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 = [ + "kurbo", + "quick-xml", + "serde", + "svgtypes", + "taffy", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[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 = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[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 = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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 = "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" +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..1c7ad1a968 --- /dev/null +++ b/model-v2/a/lab/Cargo.toml @@ -0,0 +1,26 @@ +[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] + +[features] +# `serde` (default OFF) makes `Op` / `ResizeDrag` / `Axis` serializable — the +# op-log wire for engine replay. Kept optional so serialization remains an +# explicit dependency choice for this standalone proving crate. +serde = ["dep:serde"] + +[dependencies] +taffy = "0.9.2" +quick-xml = "0.37" +kurbo = "0.13" +serde = { version = "1.0", optional = true, features = ["derive"] } +svgtypes = "0.16" + +[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..6827e4f950 --- /dev/null +++ b/model-v2/a/lab/README.md @@ -0,0 +1,255 @@ +# anchor-lab + +The standalone proving crate for the `anchor` model +([`../../models/a.md`](../../models/a.md)) — lab subset: `frame`, `shape` +(rect/ellipse/line/path), `text` (oracle-backed, with a deterministic 0.6/1.2 +stub), `group`, `lens` (2D ops). **Not** a member of the repo workspace; +promotion into `crates/` happens at phase 4 only. + +```sh +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 +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); stores one final-width text-layout artifact per text node +- `src/text_layout.rs` — backend-independent line/glyph artifact and + `TextLayoutOracle`; the compatibility `resolve` path uses the explicitly + named glyphless stub, while an engine injects its shaping implementation +- `src/path.rs` — strict SVG path-data analysis, bounded rational-conic arc + lowering, tight unit bounds, and one resolved box-mapped path artifact +- `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/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 + +## 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 unit-reference `` 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. + +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 stub 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 +``, ``, 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. A path's complete SVG +`d` grammar is validated against fixed `0 0 1 1` realized geometry, then +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/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` +references through a host-supplied pure provider, rejects expansion cycles, +implements Version 2's closed typed scalar prop/argument model, and adds +Version 3 named static render-slot projection. Assignments splice at their +definition-owned marker in caller order; absent assignments erase the marker. +There is no default slot, fallback, requiredness, wrapper, or render-model slot +node. + +It materializes one ordinary component-blind `Document`. The outcome also +contains per-node source/use provenance, specialization selection/origin +provenance, one slot-projection provenance record per declared slot per use +(including empty projections), and a resource manifest whose opaque runtime +RIDs distinguish equal relative image strings from different source origins. +Assigned trees retain their caller source, scalar scope, resource base, and +authored-node provenance. Projection failures retain the innermost slot and +assignment sites in addition to the complete use chain. The module performs no +file or image I/O. + +Version 3 may link Version 1, 2, or 3 components, but render assignments are +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 +the same origin-aware runtime image rekeying; direct `grida_xml::parse` and +`print` retain their exact Draft 0 contract. + +The current operation links only the closure reachable from an entry scene. +It does not yet accept an explicitly requested component export as a root or +perform complete-library validation from every export. Source-local validation +searches at most 4,096 declared-domain witness states per parameterized +component and fails explicitly at that bound. Retaining exact source snapshots +provides source-preserving access, but this module does not yet provide +same-location, canonical source-unit, or canonical multi-file writer modes. It +therefore makes no canonical-writing claim. Draft 0 parse errors are currently +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 +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..3eb8999262 --- /dev/null +++ b/model-v2/a/lab/src/bin/e1.rs @@ -0,0 +1,354 @@ +//! 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).fills = Paints::solid((*color).into()); + 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, + }, + ) + .expect("E1 snapshot uses only empty or singleton-solid paint stacks"); + 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..5bceb03dce --- /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, ResolveOptions, Resolved, 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..ca94a8385b --- /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..06d6504b6d --- /dev/null +++ b/model-v2/a/lab/src/bin/e5scan.rs @@ -0,0 +1,356 @@ +//! 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..4aafb3e40c --- /dev/null +++ b/model-v2/a/lab/src/bin/edge.rs @@ -0,0 +1,451 @@ +//! 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..b27676cad0 --- /dev/null +++ b/model-v2/a/lab/src/bin/fork.rs @@ -0,0 +1,381 @@ +//! 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/grida_xml.rs b/model-v2/a/lab/src/grida_xml.rs new file mode 100644 index 0000000000..119fad5e3c --- /dev/null +++ b/model-v2/a/lab/src/grida_xml.rs @@ -0,0 +1,269 @@ +//! 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::renderability; +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| { + stroke.width = stroke.width.normalized(); + if let Some(values) = &mut stroke.dash_array { + if values.len() % 2 == 1 { + let repeated = values.clone(); + values.extend(repeated); + } + } + stroke + }) + .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 + || !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() + { + 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 { + // Validate path artifacts/boxes and stroke scalars before the structural + // self-comparison below. IEEE NaN is not equal to itself, so derived + // equality would otherwise misreport invalid authored state as a corrupt + // scene tree. + for id in 0..doc.capacity() as NodeId { + let Some(node) = doc.get_opt(id) else { + continue; + }; + textir::validate_path_for_write(node).map_err(PrintError::InvalidDocument)?; + for stroke in &node.strokes { + renderability::validate_stroke(stroke, &node.payload, node.corner_smoothing) + .map_err(|error| PrintError::InvalidDocument(error.to_string()))?; + } + } + 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.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(); + 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/grida_xml_source.rs b/model-v2/a/lab/src/grida_xml_source.rs new file mode 100644 index 0000000000..0dedb916e0 --- /dev/null +++ b/model-v2/a/lab/src/grida_xml_source.rs @@ -0,0 +1,4597 @@ +//! Pure retained-source boundary for linked Grida XML programs. +//! +//! [`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, 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 +// would make the public boundary less direct to save only a small stack slot. +#![allow(clippy::result_large_err)] + +use crate::grida_xml; +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::{btree_map, BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::sync::Arc; + +/// One host-defined immutable source snapshot. +/// +/// `identity` and `base` are opaque to the language. The provider promises +/// that they are canonical within one link operation; the linker verifies +/// that repeated identities never change bytes or base. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceSnapshot { + source: Arc, + identity: Arc, + base: Arc, +} + +impl SourceSnapshot { + pub fn new( + identity: impl Into>, + base: impl Into>, + source: impl Into>, + ) -> Self { + Self { + source: source.into(), + identity: identity.into(), + base: base.into(), + } + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn identity(&self) -> &str { + &self.identity + } + + pub fn base(&self) -> &str { + &self.base + } +} + +/// Host-owned dependency resolution. The language supplies only the decoded +/// authored location and the immutable snapshot containing that reference. +pub trait SourceProvider { + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result; +} + +impl SourceProvider for F +where + F: FnMut(&SourceSnapshot, &str) -> Result, +{ + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result { + self(containing, location) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceVersion { + Draft0, + Draft1, + Version2, + Version3, + Version4, +} + +impl SourceVersion { + pub fn as_str(self) -> &'static str { + match self { + SourceVersion::Draft0 => "0", + 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 | SourceVersion::Version4 + ) +} + +fn has_static_slots(version: SourceVersion) -> bool { + matches!(version, SourceVersion::Version3 | SourceVersion::Version4) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SourceSpan { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorPhase { + Parse, + Resolve, + Link, + Projection, + Specialize, + Materialize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthoredUseSite { + pub source: String, + pub span: SourceSpan, + pub href: String, + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +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 + /// target identity could be formed. + pub authored_use: Option>, + /// Concrete value sources and lexical binding sinks present in the + /// failing specialization or projected subtree. The Draft 0 parser + /// currently exposes string-only errors, so this set is complete but not + /// necessarily a minimal causal subset. + pub specialization_sites: Vec, + /// The innermost named projection edge implicated by a failure in + /// caller-authored assigned content. `None` for definition-owned and + /// ordinary source failures. + pub slot_projection: Option>, + pub message: String, +} + +impl SourceError { + fn parse(source: &str, message: impl Into) -> Self { + Self { + phase: ErrorPhase::Parse, + source: source.into(), + span: None, + component: None, + use_chain: vec![], + authored_use: None, + specialization_sites: vec![], + slot_projection: None, + message: message.into(), + } + } + + 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, + component: Option<&str>, + use_chain: &[UseSite], + message: impl Into, + ) -> Self { + Self { + phase, + source: source.into(), + span: None, + component: component.map(str::to_owned), + use_chain: use_chain.to_vec(), + authored_use: None, + specialization_sites: vec![], + slot_projection: None, + message: message.into(), + } + } + + fn with_authored_use(mut self, authored_use: AuthoredUseSite) -> Self { + self.authored_use = Some(Box::new(authored_use)); + self + } + + fn with_specialization_sites(mut self, sites: Vec) -> Self { + self.specialization_sites = sites; + self + } + + fn with_slot_projection(mut self, site: SlotProjectionErrorSite) -> Self { + // Nested projections decorate the error from the inside out. Keep the + // first (innermost) edge; the complete use chain still records every + // enclosing component edge. + if self.slot_projection.is_none() { + self.slot_projection = Some(Box::new(site)); + } + self + } +} + +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}")?; + } + if !self.use_chain.is_empty() { + write!(f, " via")?; + for site in &self.use_chain { + write!(f, " {}:{} -> {}", site.source, site.span.start, site.href)?; + } + } + if let Some(site) = &self.authored_use { + write!( + f, + " at {}:{} -> {}", + site.source, site.span.start, site.href + )?; + } + if let Some(site) = &self.slot_projection { + write!( + f, + " through slot {}#{}:{} at {}:{} received by {}:{} and assigned from {}:{}", + site.definition.source, + site.definition.component.id, + site.definition.name, + site.definition.source, + site.definition.span.start, + site.receiving_use.source, + site.receiving_use.span.start, + site.assignment.source, + site.assignment.span.start, + )?; + } + write!(f, ": {}", self.message) + } +} + +impl std::error::Error for SourceError {} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ComponentIdentity { + pub source: String, + pub id: String, +} + +impl std::fmt::Display for ComponentIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}#{}", self.source, self.id) + } +} + +/// 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, + pub span: SourceSpan, + pub href: String, + pub target: ComponentIdentity, + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeProvenance { + pub source: String, + pub span: SourceSpan, + pub component: Option, + pub use_chain: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceManifestEntry { + /// Opaque key written into the materialized model's `ResourceRef::Rid`. + pub runtime_rid: String, + pub source: String, + pub base: String, + pub authored: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScalarType { + String, + Boolean, + Number, + Color, + Enum, + Resource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArgumentSite { + pub source: String, + pub span: SourceSpan, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValueOrigin { + pub source: String, + pub span: SourceSpan, + /// The XML-decoded literal at its ultimate declaration/default or argument + /// site, before Version 2–4 brace escapes are collapsed. + pub authored: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValueSelection { + CalleeDefault, + Supplied { argument: ArgumentSite }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindingTargetKind { + Attribute { name: String }, + Text { segment: usize }, + Argument { use_href: String, argument: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindingTarget { + pub source: String, + pub span: SourceSpan, + pub element: String, + pub kind: BindingTargetKind, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpecializationErrorSite { + pub prop: String, + pub selection: ValueSelection, + pub ultimate_origin: ValueOrigin, + pub binding: BindingTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PropValueProvenance { + pub name: String, + pub scalar_type: ScalarType, + pub value: String, + pub selection: ValueSelection, + pub ultimate_origin: ValueOrigin, + /// Argument sites crossed from the ultimate literal to this component. + pub forwarding: Vec, + /// Lexical scalar sinks belonging to this specialization. + pub binding_targets: Vec, + /// Concrete ordinary nodes produced for those lexical sinks. + pub materialized_occurrences: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpecializationProvenance { + pub component: ComponentIdentity, + pub use_chain: Vec, + pub props: Vec, +} + +/// The one authored declaration that fixes a projected slot's painter and +/// coordinate-space position. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlotDefinitionSite { + pub source: String, + pub component: ComponentIdentity, + pub span: SourceSpan, + pub name: String, +} + +/// One caller-authored direct render root assigned to a named slot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlotAssignmentSite { + pub source: String, + pub component: Option, + pub span: SourceSpan, + pub name: String, +} + +/// Structured source context for a failure reached through one named slot +/// projection. `receiving_use` identifies the exact component instance that +/// owns the projection; transitive failure edges remain in +/// `SourceError::use_chain`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlotProjectionErrorSite { + pub definition: SlotDefinitionSite, + pub receiving_use: UseSite, + pub assignment: SlotAssignmentSite, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterializedSlotAssignment { + pub site: SlotAssignmentSite, + /// Slot in the initial materialization snapshot; see + /// [`MaterializedBindingOccurrence::node`]. + pub node: NodeId, +} + +/// One declared slot projected for one concrete component use. Empty +/// `assignments` is an observable empty projection, not an omitted record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlotProjectionProvenance { + pub definition: SlotDefinitionSite, + pub use_chain: Vec, + pub assignments: Vec, +} + +/// A parsed source unit retained exactly as supplied by its snapshot. +/// Structural details stay private until a second non-linker consumer earns a +/// stable public syntax-tree contract. +#[derive(Debug, Clone)] +pub struct SourceUnit { + snapshot: SourceSnapshot, + version: SourceVersion, + components: Vec, + scene: Option, +} + +impl SourceUnit { + pub fn snapshot(&self) -> &SourceSnapshot { + &self.snapshot + } + + pub fn version(&self) -> SourceVersion { + self.version + } + + pub fn component_ids(&self) -> impl ExactSizeIterator { + self.components + .iter() + .map(|component| component.id.as_str()) + } + + pub fn has_scene(&self) -> bool { + self.scene.is_some() + } + + fn component(&self, id: &str) -> Option<&ComponentSource> { + self.components.iter().find(|component| component.id == id) + } +} + +#[derive(Debug, Clone)] +pub struct SourceProgram { + entry: String, + units: BTreeMap, +} + +impl SourceProgram { + pub fn entry(&self) -> &str { + &self.entry + } + + pub fn unit(&self, identity: &str) -> Option<&SourceUnit> { + self.units.get(identity) + } + + pub fn units(&self) -> impl ExactSizeIterator { + self.units.values() + } +} + +#[derive(Debug)] +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)] +struct ComponentSource { + id: String, + element: Element, + props: Vec, + slots: Vec, +} + +impl ComponentSource { + fn slot(&self, name: &str) -> Option<&SlotDeclaration> { + self.slots.iter().find(|slot| slot.name == name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SlotDeclaration { + name: String, + span: SourceSpan, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PropDeclaration { + name: String, + scalar_type: ScalarType, + values: Vec, + default: Option, + span: SourceSpan, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TypedLiteral { + value: String, + origin: ValueOrigin, +} + +#[derive(Debug, Clone)] +struct EffectiveValue { + scalar_type: ScalarType, + enum_values: Vec, + value: String, + selection: ValueSelection, + ultimate_origin: ValueOrigin, + forwarding: Vec, +} + +type PropEnvironment = BTreeMap; + +#[derive(Debug, Clone)] +struct Attribute { + name: String, + value: String, +} + +#[derive(Debug, Clone)] +enum Content { + Element(Element), + Text(String), +} + +#[derive(Debug, Clone)] +struct Element { + name: String, + attributes: Vec, + children: Vec, + span: SourceSpan, +} + +impl Element { + fn attribute(&self, name: &str) -> Option<&str> { + self.attributes + .iter() + .find(|attribute| attribute.name == name) + .map(|attribute| attribute.value.as_str()) + } + + fn element_children(&self) -> impl Iterator { + self.children.iter().filter_map(|child| match child { + Content::Element(element) => Some(element), + Content::Text(_) => None, + }) + } + + fn has_non_whitespace_text(&self) -> bool { + self.children.iter().any(|child| match child { + Content::Text(text) => !text.trim().is_empty(), + Content::Element(_) => false, + }) + } +} + +fn xml_name(raw: &[u8]) -> String { + String::from_utf8_lossy(raw).into_owned() +} + +fn attributes(el: &BytesStart<'_>) -> Result, String> { + let tag = xml_name(el.name().as_ref()); + let mut result = Vec::new(); + let mut seen = BTreeSet::new(); + for attribute in el.attributes() { + let attribute = attribute.map_err(|error| format!("attribute on <{tag}>: {error}"))?; + let name = xml_name(attribute.key.as_ref()); + if !seen.insert(name.clone()) { + return Err(format!("duplicate `{name}` on <{tag}>")); + } + let value = attribute + .unescape_value() + .map_err(|error| format!("attribute `{name}` on <{tag}>: {error}"))? + .into_owned(); + result.push(Attribute { name, value }); + } + Ok(result) +} + +fn validate_declaration(declaration: &BytesDecl<'_>) -> Result<(), String> { + let raw = std::str::from_utf8(declaration.as_ref()) + .map_err(|_| "XML declaration must be UTF-8".to_string())?; + let mut fields = vec![]; + for attribute in Attributes::new(raw, 3) { + let attribute = attribute.map_err(|error| format!("XML declaration: {error}"))?; + fields.push(( + xml_name(attribute.key.as_ref()), + attribute + .unescape_value() + .map_err(|error| format!("XML declaration: {error}"))? + .into_owned(), + )); + } + 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 { + Ok(()) + } else { + Err("XML declaration must be version=\"1.0\" with optional encoding=\"UTF-8\" only".into()) + } +} + +fn parse_xml(source: &str) -> Result { + let mut reader = Reader::from_str(source); + reader.config_mut().trim_text(false); + let mut stack: Vec = vec![]; + let mut root: Option = None; + let mut declaration_seen = false; + let mut content_before_declaration = false; + + loop { + let start = reader.buffer_position() as usize; + let event = reader + .read_event() + .map_err(|error| format!("xml: {error}"))?; + let end = reader.buffer_position() as usize; + match event { + Event::Eof => break, + Event::Start(el) => { + let element = Element { + name: xml_name(el.name().as_ref()), + attributes: attributes(&el)?, + children: vec![], + span: SourceSpan { start, end }, + }; + stack.push(element); + content_before_declaration = true; + } + Event::Empty(el) => { + let element = Element { + name: xml_name(el.name().as_ref()), + attributes: attributes(&el)?, + children: vec![], + span: SourceSpan { start, end }, + }; + attach_element(&mut stack, &mut root, element)?; + content_before_declaration = true; + } + Event::End(el) => { + let name = xml_name(el.name().as_ref()); + let mut element = stack.pop().ok_or_else(|| format!("unbalanced "))?; + if element.name != name { + return Err(format!("mismatched for <{}>", element.name)); + } + element.span.end = end; + attach_element(&mut stack, &mut root, element)?; + } + Event::Text(text) => { + let value = text + .unescape() + .map_err(|error| format!("text: {error}"))? + .into_owned(); + let nonempty_before_declaration = !declaration_seen && !value.is_empty(); + if let Some(parent) = stack.last_mut() { + parent.children.push(Content::Text(value)); + } else if !value.trim().is_empty() { + return Err("character content is not allowed outside ".into()); + } + if nonempty_before_declaration { + content_before_declaration = true; + } + } + Event::Decl(declaration) => { + if declaration_seen { + return Err("duplicate XML declaration".into()); + } + if content_before_declaration || root.is_some() || !stack.is_empty() { + return Err("XML declaration must be the first document event".into()); + } + validate_declaration(&declaration)?; + declaration_seen = true; + } + Event::Comment(_) => { + if !declaration_seen { + content_before_declaration = true; + } + } + Event::CData(_) => { + return Err("CDATA is not supported; use escaped text content".into()) + } + Event::PI(_) | Event::DocType(_) => { + return Err("processing instructions and doctypes are not supported".into()) + } + } + } + if !stack.is_empty() { + return Err("unclosed elements".into()); + } + root.ok_or_else(|| "empty document".into()) +} + +fn attach_element( + stack: &mut [Element], + root: &mut Option, + element: Element, +) -> Result<(), String> { + if let Some(parent) = stack.last_mut() { + parent.children.push(Content::Element(element)); + return Ok(()); + } + if root.replace(element).is_some() { + return Err("multiple document elements".into()); + } + Ok(()) +} + +fn valid_component_id(value: &str) -> bool { + let mut segments = value.split('-'); + let Some(first) = segments.next() else { + return false; + }; + if first.is_empty() + || !first.as_bytes()[0].is_ascii_lowercase() + || !first + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + return false; + } + segments.all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +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), + Binding(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AttributeExpression { + Literal(String), + Binding(String), +} + +fn scan_bindings(value: &str) -> Result, String> { + let characters = value.chars().collect::>(); + let mut segments = vec![]; + let mut literal = String::new(); + let mut index = 0; + while index < characters.len() { + match characters[index] { + '{' if characters.get(index + 1) == Some(&'{') => { + literal.push('{'); + index += 2; + } + '}' if characters.get(index + 1) == Some(&'}') => { + literal.push('}'); + index += 2; + } + '{' => { + if !literal.is_empty() { + segments.push(BindingSegment::Literal(std::mem::take(&mut literal))); + } + let start = index + 1; + let Some(relative_end) = characters[start..] + .iter() + .position(|character| *character == '}') + else { + return Err( + "unescaped `{`; write `{{` for a literal brace or `{prop-name}` for a binding" + .into(), + ); + }; + let end = start + relative_end; + if characters[start..end].contains(&'{') { + return Err("nested `{` is not valid inside a prop binding".into()); + } + let name = characters[start..end].iter().collect::(); + if !valid_component_id(&name) { + return Err(format!( + "binding `{name}` must contain one lowercase kebab-case prop name" + )); + } + segments.push(BindingSegment::Binding(name)); + index = end + 1; + } + '}' => { + return Err("unescaped `}`; write `}}` for a literal brace".into()); + } + character => { + literal.push(character); + index += 1; + } + } + } + if !literal.is_empty() || segments.is_empty() { + segments.push(BindingSegment::Literal(literal)); + } + Ok(segments) +} + +fn scan_attribute(value: &str) -> Result { + let segments = scan_bindings(value)?; + if let [BindingSegment::Binding(name)] = segments.as_slice() { + return Ok(AttributeExpression::Binding(name.clone())); + } + if segments + .iter() + .any(|segment| matches!(segment, BindingSegment::Binding(_))) + { + return Err( + "attribute bindings must replace the complete value; interpolation is invalid".into(), + ); + } + Ok(AttributeExpression::Literal( + segments + .into_iter() + .map(|segment| match segment { + BindingSegment::Literal(value) => value, + BindingSegment::Binding(_) => unreachable!(), + }) + .collect(), + )) +} + +fn scalar_type(value: &str) -> Option { + match value { + "string" => Some(ScalarType::String), + "boolean" => Some(ScalarType::Boolean), + "number" => Some(ScalarType::Number), + "color" => Some(ScalarType::Color), + "enum" => Some(ScalarType::Enum), + "resource" => Some(ScalarType::Resource), + _ => None, + } +} + +fn validate_typed_value( + declaration: &PropDeclaration, + value: &str, + source: &str, + span: SourceSpan, +) -> Result { + match declaration.scalar_type { + ScalarType::String => {} + ScalarType::Boolean if !matches!(value, "true" | "false") => { + return Err(format!( + "value `{value}` for boolean prop `{}` must be exactly `true` or `false`", + declaration.name + )); + } + ScalarType::Boolean => {} + ScalarType::Number => { + let number = value.trim().parse::().map_err(|_| { + format!( + "value `{value}` for number prop `{}` is not a number", + declaration.name + ) + })?; + if !number.is_finite() { + return Err(format!( + "value `{value}` for number prop `{}` must be finite", + declaration.name + )); + } + } + ScalarType::Color if Color::from_grida_hex(value).is_none() => { + return Err(format!( + "value `{value}` is not a valid color for prop `{}`", + declaration.name + )); + } + ScalarType::Color => {} + ScalarType::Enum if !declaration.values.iter().any(|member| member == value) => { + return Err(format!( + "value `{value}` is not a member of enum prop `{}`; expected one of {}", + declaration.name, + declaration.values.join(", ") + )); + } + ScalarType::Enum => {} + ScalarType::Resource if value.trim().is_empty() => { + return Err(format!( + "resource prop `{}` requires a non-empty identifier", + declaration.name + )); + } + ScalarType::Resource => {} + } + Ok(TypedLiteral { + value: value.into(), + origin: ValueOrigin { + source: source.into(), + span, + authored: value.into(), + }, + }) +} + +fn parse_prop(source: &str, element: &Element) -> Result { + if element.has_non_whitespace_text() || element.element_children().next().is_some() { + return Err(SourceError::parse(source, " must be empty")); + } + let mut attributes = element + .attributes + .iter() + .map(|attribute| (attribute.name.as_str(), attribute.value.as_str())) + .collect::>(); + let name = attributes + .remove("name") + .ok_or_else(|| SourceError::parse(source, " requires `name`"))?; + if !valid_component_id(name) { + return Err(SourceError::parse( + source, + format!("prop name `{name}` must be lowercase kebab-case"), + )); + } + if matches!( + name, + "id" | "href" | "name" | "x" | "y" | "flow" | "grow" | "align" | "hidden" + ) { + return Err(SourceError::parse( + source, + format!("prop name `{name}` is reserved by the use instance boundary"), + )); + } + let type_name = attributes + .remove("type") + .ok_or_else(|| SourceError::parse(source, " requires `type`"))?; + let scalar_type = scalar_type(type_name).ok_or_else(|| { + SourceError::parse( + source, + format!("unknown prop type `{type_name}` for `{name}`"), + ) + })?; + let values = match (scalar_type, attributes.remove("values")) { + (ScalarType::Enum, Some(values)) => { + let members = values + .split_whitespace() + .map(str::to_owned) + .collect::>(); + if members.is_empty() { + return Err(SourceError::parse( + source, + format!("enum prop `{name}` requires a non-empty values attribute"), + )); + } + let mut unique = BTreeSet::new(); + for member in &members { + if !valid_component_id(member) { + return Err(SourceError::parse( + source, + format!( + "enum member `{member}` for prop `{name}` must be lowercase kebab-case" + ), + )); + } + if !unique.insert(member.clone()) { + return Err(SourceError::parse( + source, + format!("duplicate enum member `{member}` for prop `{name}`"), + )); + } + } + members + } + (ScalarType::Enum, None) => { + return Err(SourceError::parse( + source, + format!("enum prop `{name}` requires a non-empty values attribute"), + )); + } + (_, Some(_)) => { + return Err(SourceError::parse( + source, + format!("`values` is valid only on enum prop `{name}`"), + )); + } + (_, None) => vec![], + }; + let default_source = attributes.remove("default").map(str::to_owned); + if let Some(attribute) = attributes.keys().next() { + return Err(SourceError::parse( + source, + format!("unknown attribute `{attribute}` on "), + )); + } + let mut declaration = PropDeclaration { + name: name.into(), + scalar_type, + values, + default: None, + span: element.span, + }; + declaration.default = default_source + .as_deref() + .map(|value| validate_typed_value(&declaration, value, source, element.span)) + .transpose() + .map_err(|message| SourceError::parse(source, message))?; + Ok(declaration) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SinkCategory { + String, + Boolean, + Number, + Color, + Keyword, + Resource, + NumberOrKeyword, +} + +fn sink_category(tag: &str, attribute: &str) -> Option { + let tag = if tag == "component" { "container" } else { tag }; + let render = matches!( + tag, + "container" | "rect" | "ellipse" | "line" | "path" | "text" | "group" | "lens" + ); + match (tag, attribute) { + (_, "name") if render || tag == "use" => Some(SinkCategory::String), + (_, "flip-x" | "flip-y" | "hidden") if render => Some(SinkCategory::Boolean), + ("use", "hidden") | ("container", "clips" | "wrap") => Some(SinkCategory::Boolean), + ("solid" | "gradient" | "image", "visible") => Some(SinkCategory::Boolean), + (_, "x" | "y") if render || tag == "use" => Some(SinkCategory::NumberOrKeyword), + (_, "width" | "height") if render => Some(SinkCategory::NumberOrKeyword), + ("stroke", "width") => Some(SinkCategory::Number), + (_, "min-width" | "max-width" | "min-height" | "max-height" | "rotation" | "opacity") + if render => + { + Some(SinkCategory::Number) + } + ("container" | "rect", "corner-radius" | "corner-smoothing") => Some(SinkCategory::Number), + ("use", "grow") => Some(SinkCategory::Number), + (_, "grow") if render => Some(SinkCategory::Number), + ("container", "gap" | "padding") => Some(SinkCategory::Number), + ("text" | "tspan", "font-size" | "font-weight") => Some(SinkCategory::Number), + ("solid" | "gradient" | "image" | "stop", "opacity") => Some(SinkCategory::Number), + ("stop", "offset") | ("stroke", "miter-limit") => Some(SinkCategory::Number), + (_, "fill") if render || tag == "tspan" => Some(SinkCategory::Color), + ("solid" | "stop", "color") => Some(SinkCategory::Color), + ("image", "src") => Some(SinkCategory::Resource), + (_, "flow" | "align") if render || tag == "use" => Some(SinkCategory::Keyword), + ("container", "layout" | "direction" | "main" | "cross") => Some(SinkCategory::Keyword), + ("text" | "tspan", "font-style") | ("path", "fill-rule") => Some(SinkCategory::Keyword), + ("gradient", "kind" | "tile-mode") | ("image", "fit") => Some(SinkCategory::Keyword), + ("solid" | "gradient" | "image", "blend-mode") => Some(SinkCategory::Keyword), + ("stroke", "align" | "cap" | "join") => Some(SinkCategory::Keyword), + _ => None, + } +} + +fn keyword_valid(tag: &str, attribute: &str, value: &str) -> bool { + let tag = if tag == "component" { "container" } else { tag }; + match (tag, attribute) { + (_, "width" | "height") => value == "auto", + (_, "x" | "y") => value == "center", + (_, "flow") => matches!(value, "absolute" | "in"), + ("stroke", "align") => matches!(value, "inside" | "center" | "outside"), + (_, "align") => matches!(value, "start" | "center" | "end" | "stretch"), + ("container", "layout") => matches!(value, "none" | "flex"), + ("container", "direction") => matches!(value, "row" | "column"), + ("container", "main") => matches!( + value, + "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly" + ), + ("container", "cross") => matches!(value, "start" | "center" | "end" | "stretch"), + ("text" | "tspan", "font-style") => matches!(value, "normal" | "italic"), + ("path", "fill-rule") => matches!(value, "nonzero" | "evenodd"), + ("gradient", "kind") => matches!(value, "linear" | "radial" | "sweep" | "diamond"), + ("image", "fit") => matches!(value, "contain" | "cover" | "fill" | "none"), + ("gradient", "tile-mode") => matches!(value, "clamp" | "repeated" | "mirror" | "decal"), + ("stroke", "cap") => matches!(value, "butt" | "round" | "square"), + ("stroke", "join") => matches!(value, "miter" | "round" | "bevel"), + ("solid" | "gradient" | "image", "blend-mode") => matches!( + value, + "normal" + | "multiply" + | "screen" + | "overlay" + | "darken" + | "lighten" + | "color-dodge" + | "color-burn" + | "hard-light" + | "soft-light" + | "difference" + | "exclusion" + | "hue" + | "saturation" + | "color" + | "luminosity" + ), + _ => false, + } +} + +fn validate_binding_target( + declaration: &PropDeclaration, + tag: &str, + attribute: &str, +) -> Result<(), String> { + let Some(category) = sink_category(tag, attribute) else { + return Err(format!( + "prop `{}` cannot bind to non-scalar or unknown {} on <{}>", + declaration.name, attribute, tag + )); + }; + let compatible = matches!( + (declaration.scalar_type, category), + (ScalarType::String, SinkCategory::String) + | (ScalarType::Boolean, SinkCategory::Boolean) + | ( + ScalarType::Number, + SinkCategory::Number | SinkCategory::NumberOrKeyword + ) + | (ScalarType::Color, SinkCategory::Color) + | ( + ScalarType::Enum, + SinkCategory::Keyword | SinkCategory::NumberOrKeyword + ) + | (ScalarType::Resource, SinkCategory::Resource) + ); + if !compatible { + return Err(format!( + "prop `{}` has type {:?} and cannot bind to {} on <{}>", + declaration.name, declaration.scalar_type, attribute, tag + )); + } + if declaration.scalar_type == ScalarType::Enum { + if let Some(member) = declaration + .values + .iter() + .find(|member| !keyword_valid(tag, attribute, member)) + { + return Err(format!( + "enum prop `{}` member `{member}` is not accepted by {} on <{}>", + declaration.name, attribute, tag + )); + } + } + if let Some(default) = &declaration.default { + validate_concrete_binding_value(declaration, tag, attribute, &default.value) + .map_err(|message| format!("default for prop `{}` {message}", declaration.name))?; + } + Ok(()) +} + +fn validate_concrete_binding_value( + declaration: &PropDeclaration, + tag: &str, + attribute: &str, + value: &str, +) -> Result<(), String> { + if declaration.scalar_type != ScalarType::Number { + return Ok(()); + } + let number = value + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .ok_or_else(|| format!("does not fit the finite numeric target {attribute} on <{tag}>"))?; + let invalid = |requirement: &str| { + Err(format!( + "value `{value}` is invalid for {attribute} on <{tag}>: {requirement}" + )) + }; + match (tag, attribute) { + (_, "opacity") if !(0.0..=1.0).contains(&number) => { + invalid("opacity must be between 0 and 1 inclusive") + } + ("container" | "rect" | "component", "corner-smoothing") + if !(0.0..=1.0).contains(&number) => + { + invalid("corner-smoothing must be between 0 and 1 inclusive") + } + ("stop", "offset") if !(0.0..=1.0).contains(&number) => { + invalid("offset must be between 0 and 1 inclusive") + } + (_, "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height") + | ("container" | "rect" | "component", "corner-radius") + | (_, "grow") + | ("container" | "component", "gap" | "padding") + | ("stroke", "width") + if number < 0.0 => + { + invalid("the value must be non-negative") + } + ("text" | "tspan", "font-size") | ("stroke", "miter-limit") if number <= 0.0 => { + invalid("the value must be greater than zero") + } + ("text" | "tspan", "font-weight") + if value + .trim() + .parse::() + .ok() + .filter(|value| (1..=1000).contains(value)) + .is_none() => + { + invalid("font-weight must be an integer from 1 through 1000") + } + _ => Ok(()), + } +} + +/// Parse one immutable source unit without resolving any dependency. +pub fn parse_source(snapshot: SourceSnapshot) -> Result { + if snapshot.identity().is_empty() { + return Err(SourceError::parse( + "", + "canonical source identity must not be empty", + )); + } + if snapshot.base().is_empty() { + return Err(SourceError::parse( + snapshot.identity(), + "canonical source base must not be empty", + )); + } + let root = parse_xml(snapshot.source()) + .map_err(|message| SourceError::parse(snapshot.identity(), message))?; + if root.name != "grida" { + return Err(SourceError::parse( + snapshot.identity(), + format!("document element must be , found <{}>", root.name), + )); + } + if root.attributes.len() != 1 || root.attributes[0].name != "version" { + return Err(SourceError::parse( + snapshot.identity(), + " requires exactly one `version` attribute", + )); + } + let version = match root.attributes[0].value.as_str() { + "0" => SourceVersion::Draft0, + "1" => SourceVersion::Draft1, + "2" => SourceVersion::Version2, + "3" => SourceVersion::Version3, + "4" => SourceVersion::Version4, + version => { + return Err(SourceError::parse( + snapshot.identity(), + format!("unsupported version `{version}`"), + )) + } + }; + + if version == SourceVersion::Draft0 { + grida_xml::parse(snapshot.source()) + .map_err(|error| SourceError::parse(snapshot.identity(), error.to_string()))?; + let scene = root.element_children().next().cloned(); + return Ok(SourceUnit { + snapshot, + version, + components: vec![], + scene, + }); + } + + let mut components = vec![]; + let mut ids = BTreeSet::new(); + let mut scene = None; + for child in &root.children { + match child { + Content::Text(text) if text.trim().is_empty() => {} + Content::Text(_) => { + return Err(SourceError::parse( + snapshot.identity(), + "character content is not allowed directly in ", + )) + } + Content::Element(element) if element.name == "component" => { + if scene.is_some() { + return Err(SourceError::parse( + snapshot.identity(), + " definitions must precede the scene root", + )); + } + let id = element.attribute("id").ok_or_else(|| { + SourceError::parse(snapshot.identity(), " requires `id`") + })?; + if !valid_component_id(id) { + return Err(SourceError::parse( + snapshot.identity(), + format!("component id `{id}` must be lowercase kebab-case"), + )); + } + if !ids.insert(id.to_owned()) { + return Err(SourceError::parse( + snapshot.identity(), + 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" => { + if scene.replace(element.clone()).is_some() { + return Err(SourceError::parse( + snapshot.identity(), + "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) => { + return Err(SourceError::parse( + snapshot.identity(), + format!( + "Version {} may contain only leading definitions and one optional , found <{}>", + version.as_str(), element.name + ), + )) + } + } + } + if components.is_empty() && scene.is_none() { + return Err(SourceError::parse( + snapshot.identity(), + format!( + "Version {} source exports and renders nothing", + version.as_str() + ), + )); + } + + let unit = SourceUnit { + snapshot, + version, + components, + scene, + }; + validate_unit_as_draft0_templates(&unit)?; + Ok(unit) +} + +fn parse_component( + source: &str, + version: SourceVersion, + component: &Element, +) -> Result { + let allowed = [ + "id", + "name", + "width", + "height", + "min-width", + "max-width", + "min-height", + "max-height", + "aspect-ratio", + "corner-radius", + "corner-smoothing", + "rotation", + "flip-x", + "flip-y", + "opacity", + "hidden", + "layout", + "direction", + "wrap", + "main", + "cross", + "gap", + "padding", + "clips", + "fill", + ]; + for attribute in &component.attributes { + if matches!( + attribute.name.as_str(), + "x" | "y" | "flow" | "grow" | "align" + ) { + return Err(SourceError::parse( + source, + format!( + " cannot declare {}; place the instance with {} on ", + attribute.name, attribute.name + ), + )); + } + if !allowed.contains(&attribute.name.as_str()) { + return Err(SourceError::parse( + source, + format!("unknown attribute `{}` on ", attribute.name), + )); + } + } + let id = component + .attribute("id") + .expect("caller validates component id") + .to_owned(); + let mut props = vec![]; + let mut prop_names = BTreeSet::new(); + let mut body_started = false; + for child in &component.children { + match child { + Content::Text(text) if text.trim().is_empty() => {} + Content::Element(element) if element.name == "prop" => { + if !has_scalar_specialization(version) { + return Err(SourceError::parse( + source, + " requires Grida XML Version 2, 3, or 4", + )); + } + if body_started { + return Err(SourceError::parse( + source, + " declarations must precede fill, stroke, and render children", + )); + } + let declaration = parse_prop(source, element)?; + if !prop_names.insert(declaration.name.clone()) { + return Err(SourceError::parse( + source, + format!("duplicate prop `{}` in component `{id}`", declaration.name), + )); + } + props.push(declaration); + } + _ => body_started = true, + } + } + validate_render_children(source, version, component, true)?; + let slots = collect_slot_declarations(source, component)?; + if has_scalar_specialization(version) { + validate_component_bindings(source, component, &props)?; + } + Ok(ComponentSource { + id, + element: component.clone(), + props, + slots, + }) +} + +fn validate_render_tree( + source: &str, + version: SourceVersion, + element: &Element, + inside_component: bool, + assignment_root: bool, +) -> Result<(), SourceError> { + if element.name == "component" { + return Err(SourceError::parse( + source, + " is valid only as a leading direct child of ", + )); + } + validate_slot_assignment_attribute(source, version, element, assignment_root)?; + validate_render_children(source, version, element, inside_component) +} + +fn accepts_render_children(element: &str) -> bool { + matches!( + element, + "component" | "container" | "rect" | "ellipse" | "line" | "path" | "group" | "lens" + ) +} + +fn validate_slot_declaration( + source: &str, + version: SourceVersion, + parent: &Element, + element: &Element, + inside_component: bool, +) -> Result<(), SourceError> { + if !has_static_slots(version) { + return Err(SourceError::parse( + source, + " requires Grida XML Version 3 or 4", + )); + } + if !inside_component { + return Err(SourceError::parse( + source, + format!( + " is valid only in a Version {} component render body", + version.as_str() + ), + )); + } + if !accepts_render_children(&parent.name) { + return Err(SourceError::parse( + source, + format!(" is not valid inside <{}>", parent.name), + )); + } + if element.has_non_whitespace_text() || element.element_children().next().is_some() { + return Err(SourceError::parse(source, " must be empty")); + } + if element.attributes.len() != 1 || element.attribute("name").is_none() { + return Err(SourceError::parse(source, " requires exactly `name`")); + } + let name = element.attribute("name").expect("checked"); + if !valid_component_id(name) { + return Err(SourceError::parse( + source, + format!("slot name `{name}` must be lowercase kebab-case"), + )); + } + Ok(()) +} + +fn validate_slot_assignment_attribute( + source: &str, + version: SourceVersion, + element: &Element, + assignment_root: bool, +) -> Result<(), SourceError> { + let slot = element.attribute("slot"); + if !assignment_root { + if slot.is_some() { + return Err(SourceError::parse( + source, + format!( + "`slot` is valid only on a direct Version {} render assignment", + if version == SourceVersion::Version4 { + "4" + } else { + "3" + } + ), + )); + } + return Ok(()); + } + if !has_static_slots(version) { + return Err(SourceError::parse( + source, + "render slot assignments require Grida XML Version 3 or 4", + )); + } + let name = slot.ok_or_else(|| { + SourceError::parse( + source, + format!( + "each direct Version {} render assignment requires `slot`", + version.as_str() + ), + ) + })?; + if !valid_component_id(name) { + return Err(SourceError::parse( + source, + format!("slot assignment name `{name}` must be lowercase kebab-case"), + )); + } + Ok(()) +} + +fn collect_slot_declarations( + source: &str, + component: &Element, +) -> Result, SourceError> { + let mut slots = vec![]; + let mut seen = BTreeSet::new(); + fn collect( + source: &str, + component_id: &str, + element: &Element, + seen: &mut BTreeSet, + slots: &mut Vec, + ) -> Result<(), SourceError> { + for child in element.element_children() { + if child.name == "slot" { + let name = child.attribute("name").expect("slot validated").to_owned(); + if !seen.insert(name.clone()) { + return Err(SourceError::parse( + source, + format!("duplicate slot `{name}` in component `{component_id}`"), + )); + } + slots.push(SlotDeclaration { + name, + span: child.span, + }); + } else { + collect(source, component_id, child, seen, slots)?; + } + } + Ok(()) + } + collect( + source, + component.attribute("id").expect("component id validated"), + component, + &mut seen, + &mut slots, + )?; + Ok(slots) +} + +fn validate_render_children( + source: &str, + version: SourceVersion, + element: &Element, + inside_component: bool, +) -> Result<(), SourceError> { + for child in element.element_children() { + if child.name == "component" { + return Err(SourceError::parse( + source, + " cannot be nested in a render tree", + )); + } + if child.name == "prop" && element.name == "component" && has_scalar_specialization(version) + { + continue; + } + if child.name == "slot" { + validate_slot_declaration(source, version, element, child, inside_component)?; + continue; + } + if child.name == "prop" || child.name == "arg" { + return Err(SourceError::parse( + source, + format!("<{}> is not valid in this source position", child.name), + )); + } + if child.name == "use" { + if matches!( + element.name.as_str(), + "text" | "tspan" | "fill" | "stroke" | "gradient" | "solid" | "image" | "stop" + ) { + return Err(SourceError::parse( + source, + format!(" is not valid inside <{}>", element.name), + )); + } + validate_use(source, version, child, inside_component, false)?; + } else { + validate_render_tree(source, version, child, inside_component, false)?; + } + } + Ok(()) +} + +fn validate_use( + source: &str, + version: SourceVersion, + element: &Element, + inside_component: bool, + assignment_root: bool, +) -> Result<(), SourceError> { + validate_slot_assignment_attribute(source, version, element, assignment_root)?; + let allowed = ["href", "name", "x", "y", "flow", "grow", "align", "hidden"]; + for attribute in &element.attributes { + 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, + format!("unknown attribute `{}` on ", attribute.name), + )); + } + } + let href = element + .attribute("href") + .ok_or_else(|| SourceError::parse(source, " requires `href`"))?; + parse_href(href).map_err(|message| SourceError::parse(source, message))?; + match version { + SourceVersion::Draft1 => { + if !element.children.is_empty() { + return Err(SourceError::parse( + source, + "static Version 1 cannot contain character data or child elements", + )); + } + } + SourceVersion::Version2 => { + let mut names = BTreeSet::new(); + for child in &element.children { + match child { + Content::Text(text) if text.trim().is_empty() => {} + Content::Text(_) => { + return Err(SourceError::parse( + source, + "Version 2 accepts only direct children", + )); + } + Content::Element(argument) if argument.name == "arg" => { + validate_arg(source, argument)?; + let name = argument.attribute("name").expect("arg name validated"); + if !names.insert(name.to_owned()) { + return Err(SourceError::parse( + source, + format!("duplicate argument `{name}` on one "), + )); + } + if !inside_component { + match scan_attribute( + argument.attribute("value").expect("arg value validated"), + ) + .map_err(|message| SourceError::parse(source, message))? + { + AttributeExpression::Binding(name) => { + return Err(SourceError::parse( + source, + format!( + "`{{{name}}}` in top-level arg value cannot forward without an enclosing component scope" + ), + )); + } + AttributeExpression::Literal(_) => {} + } + } + } + Content::Element(_) => { + return Err(SourceError::parse( + source, + "Version 2 accepts only direct children; render-valued inputs are not specified", + )); + } + } + } + } + SourceVersion::Version3 | SourceVersion::Version4 => { + let mut names = BTreeSet::new(); + let mut assignments_started = false; + for child in &element.children { + match child { + Content::Text(text) if text.trim().is_empty() => {} + Content::Text(_) => { + return Err(SourceError::parse( + source, + format!( + "Version {} accepts leading children followed by direct render assignments", + version.as_str() + ), + )); + } + Content::Element(argument) + if argument.name == "arg" && !assignments_started => + { + validate_arg(source, argument)?; + let name = argument.attribute("name").expect("arg name validated"); + if !names.insert(name.to_owned()) { + return Err(SourceError::parse( + source, + format!("duplicate argument `{name}` on one "), + )); + } + if !inside_component { + match scan_attribute( + argument.attribute("value").expect("arg value validated"), + ) + .map_err(|message| SourceError::parse(source, message))? + { + AttributeExpression::Binding(name) => { + return Err(SourceError::parse( + source, + format!( + "`{{{name}}}` in top-level arg value cannot forward without an enclosing component scope" + ), + )); + } + AttributeExpression::Literal(_) => {} + } + } + } + Content::Element(argument) if argument.name == "arg" => { + return Err(SourceError::parse( + source, + format!( + "Version {} children must precede every render assignment", + version.as_str() + ), + )); + } + Content::Element(assignment) => { + assignments_started = true; + if assignment.name == "use" { + validate_use(source, version, assignment, inside_component, true)?; + } else if is_render_element(&assignment.name) { + validate_render_tree( + source, + version, + assignment, + inside_component, + true, + )?; + } else { + return Err(SourceError::parse( + source, + format!( + "Version {} render assignments must be direct render roots, found <{}>", + version.as_str(), assignment.name + ), + )); + } + } + } + } + } + SourceVersion::Draft0 => unreachable!("Draft 0 parser rejects use"), + } + for axis in ["x", "y"] { + if let Some(value) = element.attribute(axis) { + let value = if has_scalar_specialization(version) && inside_component { + match scan_attribute(value) + .map_err(|message| SourceError::parse(source, message))? + { + AttributeExpression::Binding(_) => continue, + AttributeExpression::Literal(value) => value, + } + } else { + value.to_owned() + }; + validate_use_axis(&value, axis) + .map_err(|message| SourceError::parse(source, message))?; + } + } + validate_literal_use_relationships(source, version, inside_component, element)?; + Ok(()) +} + +fn validate_arg(source: &str, element: &Element) -> Result<(), SourceError> { + if element.has_non_whitespace_text() || element.element_children().next().is_some() { + return Err(SourceError::parse(source, " must be empty")); + } + if element.attributes.len() != 2 + || element.attribute("name").is_none() + || element.attribute("value").is_none() + { + return Err(SourceError::parse( + source, + " requires exactly `name` and `value`", + )); + } + let name = element.attribute("name").expect("checked"); + if !valid_component_id(name) { + return Err(SourceError::parse( + source, + format!("argument name `{name}` must be lowercase kebab-case"), + )); + } + Ok(()) +} + +fn validate_literal_use_relationships( + source: &str, + version: SourceVersion, + inside_component: bool, + element: &Element, +) -> Result<(), SourceError> { + let literal = |attribute: &str| -> Result, SourceError> { + let Some(value) = element.attribute(attribute) else { + return Ok(None); + }; + if has_scalar_specialization(version) && inside_component { + return match scan_attribute(value) + .map_err(|message| SourceError::parse(source, message))? + { + AttributeExpression::Binding(_) => Ok(None), + AttributeExpression::Literal(value) => Ok(Some(value)), + }; + } + Ok(Some(value.into())) + }; + if let Some(value) = literal("flow")? { + if !matches!(value.as_str(), "absolute" | "in") { + return Err(SourceError::parse( + source, + format!("bad flow `{value}` on "), + )); + } + } + if let Some(value) = literal("grow")? { + let number = value + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite() && *value >= 0.0); + if number.is_none() { + return Err(SourceError::parse( + source, + format!("grow on must be a non-negative finite number, found `{value}`"), + )); + } + } + if let Some(value) = literal("align")? { + if !matches!(value.as_str(), "start" | "center" | "end" | "stretch") { + return Err(SourceError::parse( + source, + format!("bad align `{value}` on "), + )); + } + } + if let Some(value) = literal("hidden")? { + if !matches!(value.as_str(), "true" | "false") { + return Err(SourceError::parse( + source, + format!("hidden on must be exactly `true` or `false`, found `{value}`"), + )); + } + } + Ok(()) +} + +fn validate_use_axis(value: &str, axis: &str) -> Result<(), String> { + let parts: Vec<_> = value.split_whitespace().collect(); + let number = |value: &str| value.parse::().ok().filter(|value| value.is_finite()); + let valid = match parts.as_slice() { + [value] => *value == "center" || number(value).is_some(), + ["start" | "end" | "center", value] => number(value).is_some(), + ["span", ..] => { + return Err(format!( + "{axis}=\"{value}\" is invalid on ; component definitions own authored size" + )) + } + _ => false, + }; + if valid { + Ok(()) + } else { + Err(format!("bad {axis} binding `{value}` on ")) + } +} + +fn validate_component_bindings( + source: &str, + component: &Element, + props: &[PropDeclaration], +) -> Result<(), SourceError> { + let declarations = props + .iter() + .map(|declaration| (declaration.name.as_str(), declaration)) + .collect::>(); + let mut consumed = BTreeSet::new(); + validate_binding_element(source, component, &declarations, &mut consumed)?; + if let Some(unused) = props + .iter() + .find(|declaration| !consumed.contains(&declaration.name)) + { + return Err(SourceError::parse( + source, + format!( + "prop `{}` in component `{}` has no binding or forwarding site", + unused.name, + component.attribute("id").expect("component id validated") + ), + )); + } + Ok(()) +} + +fn declared_binding<'a>( + source: &str, + declarations: &'a BTreeMap<&str, &'a PropDeclaration>, + name: &str, +) -> Result<&'a PropDeclaration, SourceError> { + declarations.get(name).copied().ok_or_else(|| { + SourceError::parse( + source, + format!( + "binding `{name}` is not declared; available: {}", + declarations.keys().copied().collect::>().join(", ") + ), + ) + }) +} + +fn validate_binding_element( + source: &str, + element: &Element, + declarations: &BTreeMap<&str, &PropDeclaration>, + consumed: &mut BTreeSet, +) -> Result<(), SourceError> { + if matches!(element.name.as_str(), "prop" | "slot") { + return Ok(()); + } + if element.name == "use" { + for attribute in &element.attributes { + if matches!(attribute.name.as_str(), "href" | "slot") { + continue; + } + if let AttributeExpression::Binding(name) = scan_attribute(&attribute.value) + .map_err(|message| SourceError::parse(source, message))? + { + let declaration = declared_binding(source, declarations, &name)?; + validate_binding_target(declaration, "use", &attribute.name) + .map_err(|message| SourceError::parse(source, message))?; + consumed.insert(name); + } + } + for argument in element + .element_children() + .filter(|element| element.name == "arg") + { + let value = argument.attribute("value").expect("argument validated"); + if let AttributeExpression::Binding(name) = + scan_attribute(value).map_err(|message| SourceError::parse(source, message))? + { + declared_binding(source, declarations, &name)?; + consumed.insert(name); + } + } + for assignment in element + .element_children() + .filter(|element| element.name != "arg") + { + validate_binding_element(source, assignment, declarations, consumed)?; + } + return Ok(()); + } + + for attribute in &element.attributes { + if attribute.name == "slot" { + continue; + } + if element.name == "component" && attribute.name == "id" { + continue; + } + if sink_category(&element.name, &attribute.name).is_some() { + if let AttributeExpression::Binding(name) = scan_attribute(&attribute.value) + .map_err(|message| SourceError::parse(source, message))? + { + let declaration = declared_binding(source, declarations, &name)?; + validate_binding_target(declaration, &element.name, &attribute.name) + .map_err(|message| SourceError::parse(source, message))?; + consumed.insert(name); + } + } else if attribute.value.starts_with('{') && attribute.value.ends_with('}') { + if let Ok(AttributeExpression::Binding(name)) = scan_attribute(&attribute.value) { + let declaration = declared_binding(source, declarations, &name)?; + validate_binding_target(declaration, &element.name, &attribute.name) + .map_err(|message| SourceError::parse(source, message))?; + } + } + } + + if matches!(element.name.as_str(), "text" | "tspan") { + for child in &element.children { + if let Content::Text(text) = child { + for segment in + scan_bindings(text).map_err(|message| SourceError::parse(source, message))? + { + if let BindingSegment::Binding(name) = segment { + let declaration = declared_binding(source, declarations, &name)?; + if declaration.scalar_type != ScalarType::String { + return Err(SourceError::parse( + source, + format!( + "prop `{name}` has type {:?} and cannot bind to text content", + declaration.scalar_type + ), + )); + } + consumed.insert(name); + } + } + } + } + } + for child in element.element_children() { + validate_binding_element(source, child, declarations, consumed)?; + } + Ok(()) +} + +fn parse_href(href: &str) -> Result<(&str, &str), String> { + if href.chars().any(char::is_whitespace) || href.contains('\\') { + return Err(format!( + "component reference `{href}` must not contain whitespace or backslashes" + )); + } + let mut parts = href.split('#'); + let location = parts.next().unwrap_or_default(); + let id = parts + .next() + .ok_or_else(|| format!("component reference requires an ID fragment; found `{href}`"))?; + if parts.next().is_some() { + return Err(format!( + "component reference `{href}` contains more than one literal `#`" + )); + } + if !valid_component_id(id) { + return Err(format!( + "component fragment `{id}` must be lowercase kebab-case" + )); + } + Ok((location, id)) +} + +fn validate_unit_as_draft0_templates(unit: &SourceUnit) -> Result<(), SourceError> { + if let Some(scene) = &unit.scene { + validate_template( + unit.snapshot.identity(), + template_element( + scene, + false, + unit.version == SourceVersion::Version4, + &BTreeMap::new(), + ), + )?; + validate_assignment_templates( + unit.snapshot.identity(), + unit.version, + scene, + &BTreeMap::new(), + )?; + } + for component in &unit.components { + validate_component_templates(unit, component)?; + } + Ok(()) +} + +fn validate_assignment_templates( + source: &str, + version: SourceVersion, + element: &Element, + witnesses: &BTreeMap, +) -> Result<(), SourceError> { + 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, + version == SourceVersion::Version4, + witnesses, + ); + projected.attributes.retain(|attribute| { + !matches!(attribute.name.as_str(), "slot" | "flow" | "grow" | "align") + }); + validate_template( + source, + Element { + name: "container".into(), + attributes: vec![], + children: vec![Content::Element(projected)], + span: assignment.span, + }, + )?; + } + } + for child in element.element_children() { + if !matches!(child.name.as_str(), "prop" | "slot" | "arg") { + validate_assignment_templates(source, version, child, witnesses)?; + } + } + Ok(()) +} + +fn validate_template(source: &str, root: Element) -> Result<(), SourceError> { + let mut xml = String::from(""); + write_element(&root, &mut xml); + xml.push_str(""); + grida_xml::parse(&xml) + .map(|_| ()) + .map_err(|error| SourceError::parse(source, error.to_string())) +} + +const MAX_TEMPLATE_VALIDATION_STATES: usize = 4096; + +fn prop_validation_witnesses(declaration: &PropDeclaration, component: &Element) -> Vec { + match declaration.scalar_type { + ScalarType::String => vec!["x".into()], + ScalarType::Boolean => vec!["false".into()], + ScalarType::Number => { + fn collect_numbers(element: &Element, out: &mut Vec) { + for attribute in &element.attributes { + out.extend( + attribute + .value + .split(|character: char| { + character.is_whitespace() || matches!(character, ',' | '/') + }) + .filter_map(|token| token.parse::().ok()) + .filter(|value| value.is_finite()), + ); + } + for child in element.element_children() { + collect_numbers(child, out); + } + } + + let mut numbers = vec![0.0, 1.0]; + collect_numbers(component, &mut numbers); + numbers.sort_by(f64::total_cmp); + numbers.dedup(); + let midpoints = numbers + .windows(2) + .map(|pair| pair[0] + (pair[1] - pair[0]) / 2.0) + .filter(|value| value.is_finite()) + .collect::>(); + numbers.extend(midpoints); + numbers.sort_by(f64::total_cmp); + numbers.dedup(); + numbers.into_iter().map(|value| value.to_string()).collect() + } + ScalarType::Color => vec!["#000000".into()], + ScalarType::Resource => vec!["__resource__".into()], + ScalarType::Enum => { + let mut values = declaration.values.clone(); + values.sort_unstable(); + values + } + } +} + +fn validate_component_templates( + unit: &SourceUnit, + component: &ComponentSource, +) -> Result<(), SourceError> { + fn search( + unit: &SourceUnit, + component: &ComponentSource, + index: usize, + witnesses: &mut BTreeMap, + first_error: &mut Option, + states: &mut usize, + limit_exceeded: &mut bool, + ) -> bool { + if *limit_exceeded { + return false; + } + if index == component.props.len() { + if *states == MAX_TEMPLATE_VALIDATION_STATES { + *limit_exceeded = true; + return false; + } + *states += 1; + let result = validate_template( + unit.snapshot.identity(), + template_element( + &component.element, + true, + unit.version == SourceVersion::Version4, + witnesses, + ), + ) + .and_then(|_| { + validate_assignment_templates( + unit.snapshot.identity(), + unit.version, + &component.element, + witnesses, + ) + }); + match result { + Ok(()) => return true, + Err(error) if first_error.is_none() => *first_error = Some(error), + Err(_) => {} + } + return false; + } + + let declaration = &component.props[index]; + for value in prop_validation_witnesses(declaration, &component.element) { + witnesses.insert(declaration.name.clone(), value); + if search( + unit, + component, + index + 1, + witnesses, + first_error, + states, + limit_exceeded, + ) { + return true; + } + if *limit_exceeded { + return false; + } + } + witnesses.remove(&declaration.name); + false + } + + let mut witnesses = BTreeMap::new(); + let mut first_error = None; + let mut states = 0; + let mut limit_exceeded = false; + if search( + unit, + component, + 0, + &mut witnesses, + &mut first_error, + &mut states, + &mut limit_exceeded, + ) { + Ok(()) + } else if limit_exceeded { + Err(SourceError::parse( + unit.snapshot.identity(), + format!( + "component `{}` exceeds the {MAX_TEMPLATE_VALIDATION_STATES}-state source-validation limit", + component.id + ), + )) + } else { + Err(first_error.expect("every prop declaration has a validation witness")) + } +} + +fn template_scalar_value(attribute: &Attribute, witnesses: &BTreeMap) -> String { + match scan_attribute(&attribute.value) + .expect("Version 2–4 bindings validated before template lowering") + { + AttributeExpression::Literal(value) => value, + AttributeExpression::Binding(name) => witnesses + .get(&name) + .expect("component bindings reference witnessed props") + .clone(), + } +} + +fn template_text(value: &str, witnesses: &BTreeMap) -> String { + scan_bindings(value) + .expect("Version 2–4 text bindings validated before template lowering") + .into_iter() + .map(|segment| match segment { + BindingSegment::Literal(value) => value, + BindingSegment::Binding(name) => witnesses + .get(&name) + .expect("text bindings reference witnessed props") + .clone(), + }) + .collect() +} + +fn template_element( + element: &Element, + component_root: bool, + strip_durable_ids: bool, + witnesses: &BTreeMap, +) -> Element { + if element.name == "use" { + // Relationship applicability belongs to linked materialization. A + // source template proves only that this position accepts one boxed + // render child; literal domains were checked above. + return Element { + name: "container".into(), + attributes: vec![], + children: vec![], + span: element.span, + }; + } + if element.name == "slot" { + // A slot has no ordinary scene representation, but it does occupy a + // render-child position in its definition. Keep that position visible + // to Draft 0 template validation so a later fill/stroke is rejected + // whether the concrete projection is empty or populated. + return Element { + name: "container".into(), + attributes: vec![], + children: vec![], + span: element.span, + }; + } + let name = if component_root { + "container".into() + } else { + element.name.clone() + }; + let attributes = element + .attributes + .iter() + .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() + || sink_category(&element.name, &attribute.name).is_none() + { + attribute.value.clone() + } else { + template_scalar_value(attribute, witnesses) + }, + }) + .collect(); + let children = element + .children + .iter() + .filter_map(|child| match child { + Content::Element(element) if element.name == "prop" => None, + Content::Text(text) + if !witnesses.is_empty() && matches!(element.name.as_str(), "text" | "tspan") => + { + Some(Content::Text(template_text(text, witnesses))) + } + Content::Text(text) => Some(Content::Text(text.clone())), + Content::Element(element) => Some(Content::Element(template_element( + element, + false, + strip_durable_ids, + witnesses, + ))), + }) + .collect(); + Element { + name, + attributes, + children, + span: element.span, + } +} + +#[derive(Debug, Clone)] +struct ExpandedElement { + name: String, + attributes: Vec, + children: Vec, + provenance: NodeProvenance, + address: Option, + bindings: Vec, + slot_assignments: Vec, +} + +#[derive(Debug, Clone)] +enum ExpandedContent { + Element(Box), + Text(String), +} + +#[derive(Debug, Clone)] +struct ExpandedBinding { + specialization: usize, + prop: String, + target: BindingTarget, + materialized: bool, +} + +#[derive(Debug, Clone)] +struct ExpandedSlotAssignment { + projection: usize, + assignment: usize, +} + +#[derive(Debug, Clone)] +struct ExpansionContext { + source: String, + version: SourceVersion, + component: Option, + environment: Option, + specialization: Option, + slots: Option>, +} + +#[derive(Debug, Clone)] +struct SlotInstantiation { + component: ComponentIdentity, + use_chain: Vec, + assignments: BTreeMap>, +} + +#[derive(Debug, Clone)] +struct SlotAssignmentSource { + element: Element, + context: ExpansionContext, +} + +#[derive(Debug)] +struct PendingSlotProjection { + definition: SlotDefinitionSite, + use_chain: Vec, + assignments: Vec, +} + +#[derive(Debug)] +struct PendingSlotAssignment { + site: SlotAssignmentSite, + node: Option, +} + +struct MaterializedScalar { + value: String, + origin: ValueOrigin, + binding: Option, +} + +fn slot_projection_error_site( + marker: &ExpandedSlotAssignment, + projections: &[PendingSlotProjection], +) -> SlotProjectionErrorSite { + let projection = projections + .get(marker.projection) + .expect("expanded slot marker references a retained projection"); + let assignment = projection + .assignments + .get(marker.assignment) + .expect("expanded slot marker references a retained assignment"); + SlotProjectionErrorSite { + definition: projection.definition.clone(), + receiving_use: projection + .use_chain + .last() + .expect("a slot projection belongs to one concrete component use") + .clone(), + assignment: assignment.site.clone(), + } +} + +fn validate_expanded_relationships( + root: &ExpandedElement, + projections: &[PendingSlotProjection], + specializations: &[SpecializationProvenance], +) -> Result<(), SourceError> { + fn attribute<'a>(element: &'a ExpandedElement, name: &str) -> Option<&'a str> { + element + .attributes + .iter() + .find(|attribute| attribute.name == name) + .map(|attribute| attribute.value.as_str()) + } + + fn fail( + element: &ExpandedElement, + projection: Option<&ExpandedSlotAssignment>, + projections: &[PendingSlotProjection], + specializations: &[SpecializationProvenance], + message: impl Into, + ) -> SourceError { + let message = message.into(); + let specialization_sites = projected_specialization_sites(element, specializations); + let error = SourceError::at_phase( + if projection.is_some() { + ErrorPhase::Projection + } else if !specialization_sites.is_empty() { + ErrorPhase::Specialize + } else { + ErrorPhase::Materialize + }, + &element.provenance.source, + element + .provenance + .component + .as_ref() + .map(|component| component.id.as_str()), + &element.provenance.use_chain, + message, + ); + let error = if let Some(marker) = projection { + error.with_slot_projection(slot_projection_error_site(marker, projections)) + } else { + error + }; + error.with_specialization_sites(specialization_sites) + } + + fn visit<'a>( + parent: &'a ExpandedElement, + projections: &[PendingSlotProjection], + specializations: &[SpecializationProvenance], + inherited_projection: Option<&'a ExpandedSlotAssignment>, + ) -> Result<(), SourceError> { + let parent_is_flex = attribute(parent, "layout") == Some("flex"); + for child in expanded_render_children(parent) { + // A directly projected root owns its relationship to this parent. + // Descendants inherit that projection until a nested projection + // provides the more specific edge. + let projection = child.slot_assignments.last().or(inherited_projection); + let flow = attribute(child, "flow"); + if flow.is_some() && !parent_is_flex { + return Err(fail( + child, + projection, + projections, + specializations, + "flow is only valid on a child of a flex container", + )); + } + let in_flow = parent_is_flex && flow != Some("absolute"); + if parent_is_flex + && in_flow + && (attribute(child, "x").is_some() || attribute(child, "y").is_some()) + { + return Err(fail( + child, + projection, + projections, + specializations, + format!( + "x/y are not valid on in-flow <{}> under a flex container", + child.name + ), + )); + } + if (!parent_is_flex || !in_flow) + && (attribute(child, "grow").is_some() || attribute(child, "align").is_some()) + { + return Err(fail( + child, + projection, + projections, + specializations, + format!( + "grow/align on <{}> require an in-flow child of a flex container", + child.name + ), + )); + } + visit(child, projections, specializations, projection)?; + + // Source-local validation cannot know an assignment root's linked + // parent, while callee specialization must not claim caller-owned + // projected content. Once the full projection exists, validate + // each direct assigned subtree through the ordinary Draft 0 + // contract before specialization and retain its exact slot edge. + if let Some(marker) = child.slot_assignments.last() { + let mut xml = if parent_is_flex { + String::from("") + } else { + String::from("") + }; + write_expanded_element(child, &mut xml); + xml.push_str(""); + grida_xml::parse(&xml).map_err(|error| { + let specialization_sites = + projected_specialization_sites(child, specializations); + SourceError::at_phase( + if specialization_sites.is_empty() { + ErrorPhase::Materialize + } else { + ErrorPhase::Specialize + }, + &child.provenance.source, + child + .provenance + .component + .as_ref() + .map(|component| component.id.as_str()), + &child.provenance.use_chain, + error.to_string(), + ) + .with_slot_projection(slot_projection_error_site(marker, projections)) + .with_specialization_sites(specialization_sites) + })?; + } + } + Ok(()) + } + + visit( + root, + projections, + specializations, + root.slot_assignments.last(), + ) +} + +fn specialization_error_site( + binding: &ExpandedBinding, + specializations: &[SpecializationProvenance], +) -> Option { + let prop = specializations + .get(binding.specialization)? + .props + .iter() + .find(|prop| prop.name == binding.prop)?; + Some(SpecializationErrorSite { + prop: prop.name.clone(), + selection: prop.selection.clone(), + ultimate_origin: prop.ultimate_origin.clone(), + binding: binding.target.clone(), + }) +} + +fn projected_specialization_sites( + element: &ExpandedElement, + specializations: &[SpecializationProvenance], +) -> Vec { + fn collect<'a>(element: &'a ExpandedElement, out: &mut Vec<&'a ExpandedBinding>) { + out.extend(&element.bindings); + for child in &element.children { + if let ExpandedContent::Element(child) = child { + collect(child, out); + } + } + } + + let mut bindings = vec![]; + collect(element, &mut bindings); + bindings + .into_iter() + .filter_map(|binding| specialization_error_site(binding, specializations)) + .collect() +} + +fn visit_expanded_bindings<'a>( + element: &'a ExpandedElement, + specialization: usize, + out: &mut Vec<&'a ExpandedBinding>, +) { + out.extend( + element + .bindings + .iter() + .filter(|binding| binding.specialization == specialization), + ); + for child in &element.children { + if let ExpandedContent::Element(child) = child { + visit_expanded_bindings(child, specialization, out); + } + } +} + +fn binding_target_label(target: &BindingTarget) -> String { + match &target.kind { + BindingTargetKind::Attribute { name } => { + format!("{name} on <{}>", target.element) + } + BindingTargetKind::Text { segment } => { + format!("text segment {segment} in <{}>", target.element) + } + BindingTargetKind::Argument { use_href, argument } => { + format!("argument `{argument}` on ") + } + } +} + +fn validate_forwarding(source: &EffectiveValue, target: &PropDeclaration) -> Result<(), String> { + if source.scalar_type != target.scalar_type { + return Err(format!( + "cannot forward {:?} value to {:?} prop `{}`", + source.scalar_type, target.scalar_type, target.name + )); + } + if source.scalar_type == ScalarType::Enum { + if let Some(member) = source + .enum_values + .iter() + .find(|member| !target.values.contains(member)) + { + return Err(format!( + "enum prop cannot forward to `{}`; source member `{member}` is not accepted by the target", + target.name + )); + } + } + Ok(()) +} + +struct Linker<'a, P> { + provider: &'a mut P, + entry: String, + units: BTreeMap, + 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, + slot_projections: Vec, +} + +impl<'a, P: SourceProvider> Linker<'a, P> { + fn new(entry: SourceUnit, provider: &'a mut P) -> Self { + let identity = entry.snapshot.identity().to_owned(); + let mut units = BTreeMap::new(); + units.insert(identity.clone(), entry); + Self { + provider, + entry: identity, + units, + resolutions: BTreeMap::new(), + component_stack: vec![], + use_chain: vec![], + durable_use_path: vec![], + resources: vec![], + resource_keys: BTreeMap::new(), + specializations: vec![], + slot_projections: vec![], + } + } + + fn materialize(mut self) -> Result { + let entry = self.units.get(&self.entry).expect("entry inserted"); + let scene = entry.scene.clone().ok_or_else(|| { + SourceError::at_phase( + ErrorPhase::Link, + &self.entry, + None, + &[], + "render entry has no scene root", + ) + })?; + let version = entry.version; + let root = self.expand_element( + &ExpansionContext { + source: self.entry.clone(), + version, + component: None, + environment: None, + specialization: None, + slots: None, + }, + &scene, + )?; + validate_expanded_relationships(&root, &self.slot_projections, &self.specializations)?; + + let mut xml = String::from(""); + write_expanded_element(&root, &mut xml); + xml.push_str(""); + let document = grida_xml::parse(&xml).map_err(|error| { + SourceError::at_phase( + ErrorPhase::Materialize, + &self.entry, + None, + &self.use_chain, + error.to_string(), + ) + })?; + + let authored_roots = &document.get(document.root).children; + if authored_roots.len() != 1 { + return Err(SourceError::at_phase( + ErrorPhase::Materialize, + &self.entry, + None, + &[], + "ordinary document did not retain exactly one authored render root", + )); + } + 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, + ) + .map_err(|message| { + SourceError::at_phase(ErrorPhase::Materialize, &self.entry, None, &[], message) + })?; + if provenance.len() + 1 != document.len() { + return Err(SourceError::at_phase( + ErrorPhase::Materialize, + &self.entry, + None, + &[], + "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() + .map(|projection| { + let assignments = projection + .assignments + .into_iter() + .map(|assignment| { + assignment + .node + .map(|node| MaterializedSlotAssignment { + site: assignment.site, + node, + }) + .ok_or_else(|| { + SourceError::at_phase( + ErrorPhase::Materialize, + &self.entry, + Some(&projection.definition.component.id), + &projection.use_chain, + format!( + "slot `{}` assignment did not produce one ordinary root", + projection.definition.name + ), + ) + }) + }) + .collect::, _>>()?; + Ok(SlotProjectionProvenance { + definition: projection.definition, + use_chain: projection.use_chain, + assignments, + }) + }) + .collect::, SourceError>>()?; + Ok(MaterializedProgram { + document, + program: SourceProgram { + entry: self.entry, + units: self.units, + }, + provenance, + 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, + }) + } + + fn expand_element( + &mut self, + context: &ExpansionContext, + element: &Element, + ) -> Result { + debug_assert_ne!(element.name, "use"); + let provenance = NodeProvenance { + source: context.source.clone(), + span: element.span, + 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, + element, + attribute, + context.environment.as_ref(), + )?; + if element.name == "image" && attribute.name == "src" { + scalar.value = self.resource_rid(&scalar.origin.source, &scalar.value)?; + } + if let (Some(specialization), Some(prop)) = (context.specialization, scalar.binding) { + bindings.push(ExpandedBinding { + specialization, + prop, + target: BindingTarget { + source: context.source.clone(), + span: element.span, + element: element.name.clone(), + kind: BindingTargetKind::Attribute { + name: attribute.name.clone(), + }, + }, + materialized: true, + }); + } + attributes.push(Attribute { + name: attribute.name.clone(), + value: scalar.value, + }); + } + let mut children = vec![]; + for child in &element.children { + match child { + Content::Text(text) => { + let (text, text_bindings) = self.materialize_text( + &context.source, + context.version, + element, + text, + context.environment.as_ref(), + )?; + if let Some(specialization) = context.specialization { + bindings.extend(text_bindings.into_iter().map(|(segment, prop)| { + ExpandedBinding { + specialization, + prop, + target: BindingTarget { + source: context.source.clone(), + span: element.span, + element: element.name.clone(), + kind: BindingTargetKind::Text { segment }, + }, + materialized: true, + } + })); + } + children.push(ExpandedContent::Text(text)); + } + Content::Element(child) if child.name == "prop" => {} + Content::Element(child) if child.name == "slot" => { + children.extend( + self.expand_slot(context, child)? + .into_iter() + .map(Box::new) + .map(ExpandedContent::Element), + ); + } + Content::Element(child) if child.name == "use" => { + children.push(ExpandedContent::Element(Box::new( + self.expand_use(context, child)?, + ))); + } + Content::Element(child) => children.push(ExpandedContent::Element(Box::new( + self.expand_element(context, child)?, + ))), + } + } + Ok(ExpandedElement { + name: element.name.clone(), + attributes, + children, + provenance, + address, + bindings, + slot_assignments: vec![], + }) + } + + fn expand_slot( + &mut self, + context: &ExpansionContext, + slot: &Element, + ) -> Result, SourceError> { + let name = slot.attribute("name").expect("slot declaration validated"); + let instantiation = context.slots.as_ref().ok_or_else(|| { + SourceError::at_phase( + ErrorPhase::Link, + &context.source, + context + .component + .as_ref() + .map(|component| component.id.as_str()), + &self.use_chain, + format!( + "slot `{name}` has no containing Version {} component use", + context.version.as_str() + ), + ) + })?; + if context.component.as_ref() != Some(&instantiation.component) { + return Err(SourceError::at_phase( + ErrorPhase::Link, + &context.source, + context + .component + .as_ref() + .map(|component| component.id.as_str()), + &self.use_chain, + format!("slot `{name}` escaped its declaring component instance"), + )); + } + let assignments = instantiation + .assignments + .get(name) + .cloned() + .unwrap_or_default(); + let projection = self.slot_projections.len(); + self.slot_projections.push(PendingSlotProjection { + definition: SlotDefinitionSite { + source: context.source.clone(), + component: instantiation.component.clone(), + span: slot.span, + name: name.into(), + }, + use_chain: instantiation.use_chain.clone(), + assignments: assignments + .iter() + .map(|assignment| PendingSlotAssignment { + site: SlotAssignmentSite { + source: assignment.context.source.clone(), + component: assignment.context.component.clone(), + span: assignment.element.span, + name: name.into(), + }, + node: None, + }) + .collect(), + }); + + let owner = instantiation.component.clone(); + let mut expanded = Vec::with_capacity(assignments.len()); + for (assignment_index, assignment) in assignments.into_iter().enumerate() { + let marker = ExpandedSlotAssignment { + projection, + assignment: assignment_index, + }; + let error_site = slot_projection_error_site(&marker, &self.slot_projections); + if self.component_stack.last() != Some(&owner) { + return Err(SourceError::at_phase( + ErrorPhase::Link, + &context.source, + Some(&owner.id), + &self.use_chain, + format!("slot `{name}` projection lost its caller/callee expansion boundary"), + )); + } + // Assigned render roots are caller-owned source. Remove only the + // active slot-owning callee while expanding them: caller lexical + // components remain cycle guards, and the callee's use stays in + // `use_chain` for provenance and diagnostics. + let current_callee = self.component_stack.pop().expect("checked"); + let result = if assignment.element.name == "use" { + self.expand_use(&assignment.context, &assignment.element) + } else { + self.expand_element(&assignment.context, &assignment.element) + }; + self.component_stack.push(current_callee); + let mut root = result.map_err(|error| error.with_slot_projection(error_site))?; + root.slot_assignments.push(marker); + expanded.push(root); + } + Ok(expanded) + } + + fn expand_use( + &mut self, + caller: &ExpansionContext, + use_element: &Element, + ) -> Result { + let source = caller.source.as_str(); + let caller_version = caller.version; + let outer_environment = caller.environment.as_ref(); + let href = use_element + .attribute("href") + .expect("source validation requires href"); + let (location, component_id) = parse_href(href).expect("source validation checks href"); + let authored_use = AuthoredUseSite { + source: source.into(), + span: use_element.span, + href: href.into(), + name: use_element.attribute("name").map(str::to_owned), + }; + let target_source = self + .resolve_source(source, location) + .map_err(|error| error.with_authored_use(authored_use.clone()))?; + let target_version = self + .units + .get(&target_source) + .expect("resolved unit inserted") + .version; + let compatible = matches!( + (caller_version, target_version), + (SourceVersion::Draft1, SourceVersion::Draft1) + | ( + SourceVersion::Version2, + SourceVersion::Draft1 | SourceVersion::Version2 + ) + | ( + SourceVersion::Version3, + SourceVersion::Draft1 | SourceVersion::Version2 | SourceVersion::Version3 + ) + | (SourceVersion::Version4, SourceVersion::Version4) + ); + if !compatible { + return Err(SourceError::at_phase( + ErrorPhase::Link, + source, + None, + &self.use_chain, + format!( + "Version {} source cannot link Version {} component source", + caller_version.as_str(), + target_version.as_str() + ), + ) + .with_authored_use(authored_use)); + } + let target = ComponentIdentity { + source: target_source.clone(), + id: component_id.into(), + }; + let name = use_element + .attribute("name") + .map(|value| { + self.materialize_use_value( + source, + caller_version, + "name", + value, + use_element.span, + outer_environment, + ) + .map(|value| value.value) + }) + .transpose()?; + let site = UseSite { + source: source.into(), + span: use_element.span, + href: href.into(), + 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) { + let mut cycle = self.component_stack[index..] + .iter() + .map(ToString::to_string) + .collect::>(); + cycle.push(target.to_string()); + return Err(SourceError::at_phase( + ErrorPhase::Link, + source, + Some(component_id), + ¤t_chain, + format!("component cycle: {}", cycle.join(" -> ")), + )); + } + let definition = self + .units + .get(&target_source) + .and_then(|unit| unit.component(component_id)) + .cloned() + .ok_or_else(|| { + let available = self + .units + .get(&target_source) + .expect("target source loaded") + .component_ids() + .collect::>() + .join(", "); + SourceError::at_phase( + ErrorPhase::Link, + source, + Some(component_id), + ¤t_chain, + format!( + "component `{component_id}` is not defined in {target_source}; available: {available}" + ), + ) + })?; + let assignment_elements = use_element + .element_children() + .filter(|element| element.name != "arg") + .cloned() + .collect::>(); + 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 {required_target} target; `{component_id}` is Version {}", + target_version.as_str() + ), + )); + } + let mut slot_assignments: BTreeMap> = BTreeMap::new(); + for assignment in assignment_elements { + let name = assignment + .attribute("slot") + .expect("slot assignment source validated"); + if definition.slot(name).is_none() { + return Err(SourceError::at_phase( + ErrorPhase::Link, + source, + Some(component_id), + ¤t_chain, + format!( + "unknown slot assignment `{name}` for component `{component_id}`; available: {}", + definition + .slots + .iter() + .map(|slot| slot.name.as_str()) + .collect::>() + .join(", ") + ), + )); + } + slot_assignments + .entry(name.into()) + .or_default() + .push(SlotAssignmentSource { + element: assignment, + context: caller.clone(), + }); + } + let slots = has_static_slots(target_version).then(|| { + Arc::new(SlotInstantiation { + component: target.clone(), + use_chain: current_chain.clone(), + assignments: slot_assignments, + }) + }); + 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, + outer_environment, + use_element, + target_version, + &definition, + &target, + ); + let result = environment.and_then(|(environment, specialization)| { + self.expand_component( + caller, + &target_source, + target_version, + &definition, + use_element, + target, + &environment, + specialization, + slots, + ) + }); + self.durable_use_path.pop(); + self.use_chain.pop(); + self.component_stack.pop(); + result + } + + fn expand_component( + &mut self, + caller: &ExpansionContext, + definition_source: &str, + definition_version: SourceVersion, + definition: &ComponentSource, + use_element: &Element, + identity: ComponentIdentity, + environment: &PropEnvironment, + specialization: Option, + slots: Option>, + ) -> Result { + let caller_source = caller.source.as_str(); + let caller_version = caller.version; + let outer_environment = caller.environment.as_ref(); + let outer_specialization = caller.specialization; + let mut root = self.expand_element( + &ExpansionContext { + source: definition_source.into(), + version: definition_version, + component: Some(identity.clone()), + environment: has_scalar_specialization(definition_version) + .then(|| environment.clone()), + specialization, + slots, + }, + &definition.element, + )?; + root.name = "container".into(); + root.attributes.retain(|attribute| attribute.name != "id"); + if let Some(outer_specialization) = outer_specialization { + let href = use_element + .attribute("href") + .expect("source validation requires href"); + for argument in use_element + .element_children() + .filter(|element| element.name == "arg") + { + let value = argument.attribute("value").expect("argument validated"); + if let AttributeExpression::Binding(prop) = scan_attribute(value) + .expect("Version 2–4 argument bindings validated before expansion") + { + root.bindings.push(ExpandedBinding { + specialization: outer_specialization, + prop, + target: BindingTarget { + source: caller_source.into(), + span: argument.span, + element: "arg".into(), + kind: BindingTargetKind::Argument { + use_href: href.into(), + argument: argument + .attribute("name") + .expect("argument validated") + .into(), + }, + }, + materialized: false, + }); + } + } + } + if let Some(value) = use_element.attribute("name") { + let scalar = self.materialize_use_value( + caller_source, + caller_version, + "name", + value, + use_element.span, + outer_environment, + )?; + if let (Some(specialization), Some(prop)) = (outer_specialization, scalar.binding) { + root.bindings.push(ExpandedBinding { + specialization, + prop, + target: BindingTarget { + source: caller_source.into(), + span: use_element.span, + element: "use".into(), + kind: BindingTargetKind::Attribute { + name: "name".into(), + }, + }, + materialized: false, + }); + } + } + for name in ["x", "y", "flow", "grow", "align"] { + if let Some(value) = use_element.attribute(name) { + let scalar = self.materialize_use_value( + caller_source, + caller_version, + name, + value, + use_element.span, + outer_environment, + )?; + if let (Some(specialization), Some(prop)) = (outer_specialization, scalar.binding) { + root.bindings.push(ExpandedBinding { + specialization, + prop, + target: BindingTarget { + source: caller_source.into(), + span: use_element.span, + element: "use".into(), + kind: BindingTargetKind::Attribute { name: name.into() }, + }, + materialized: true, + }); + } + root.attributes.retain(|attribute| attribute.name != name); + root.attributes.push(Attribute { + name: name.into(), + value: scalar.value, + }); + } + } + let use_hidden = use_element + .attribute("hidden") + .map(|value| { + self.materialize_use_value( + caller_source, + caller_version, + "hidden", + value, + use_element.span, + outer_environment, + ) + }) + .transpose()?; + if let (Some(specialization), Some(prop)) = ( + outer_specialization, + use_hidden.as_ref().and_then(|value| value.binding.clone()), + ) { + root.bindings.push(ExpandedBinding { + specialization, + prop, + target: BindingTarget { + source: caller_source.into(), + span: use_element.span, + element: "use".into(), + kind: BindingTargetKind::Attribute { + name: "hidden".into(), + }, + }, + materialized: true, + }); + } + let use_hidden = use_hidden.is_some_and(|value| value.value == "true"); + let definition_hidden = root + .attributes + .iter() + .find(|attribute| attribute.name == "hidden") + .is_some_and(|attribute| attribute.value == "true"); + root.attributes + .retain(|attribute| attribute.name != "hidden"); + if use_hidden || definition_hidden { + root.attributes.push(Attribute { + name: "hidden".into(), + value: "true".into(), + }); + } + // Relationship applicability depends on the linked slot parent, not + // on either source unit in isolation. Keep those failures in the + // materialization phase before scalar-specialization validation can + // accidentally relabel them. + validate_expanded_relationships(&root, &self.slot_projections, &self.specializations)?; + if has_scalar_specialization(definition_version) { + self.validate_specialized_component( + &root, + &identity, + specialization.expect("scalar specialization recorded"), + )?; + } + Ok(root) + } + + fn validate_specialized_component( + &self, + root: &ExpandedElement, + identity: &ComponentIdentity, + specialization: usize, + ) -> Result<(), SourceError> { + let mut bindings = vec![]; + visit_expanded_bindings(root, specialization, &mut bindings); + let sites = bindings + .into_iter() + .filter_map(|binding| specialization_error_site(binding, &self.specializations)) + .collect::>(); + let mut root = root.clone(); + root.attributes.retain(|attribute| { + !matches!( + attribute.name.as_str(), + "x" | "y" | "flow" | "grow" | "align" + ) + }); + let mut xml = String::from(""); + write_expanded_element(&root, &mut xml); + xml.push_str(""); + grida_xml::parse(&xml).map(|_| ()).map_err(|error| { + let context = sites + .iter() + .map(|site| { + let selection = match &site.selection { + ValueSelection::CalleeDefault => format!( + "default at {}:{}", + site.ultimate_origin.source, site.ultimate_origin.span.start + ), + ValueSelection::Supplied { argument } => { + format!("argument at {}:{}", argument.source, argument.span.start) + } + }; + format!( + "prop `{}` from {selection}, bound at {}:{} ({})", + site.prop, + site.binding.source, + site.binding.span.start, + binding_target_label(&site.binding) + ) + }) + .collect::>() + .join("; "); + let message = if context.is_empty() { + error.to_string() + } else { + format!("{error}; {context}") + }; + SourceError::at_phase( + ErrorPhase::Specialize, + &identity.source, + Some(&identity.id), + &self.use_chain, + message, + ) + .with_specialization_sites(sites) + }) + } + + fn materialize_attribute( + &self, + source: &str, + version: SourceVersion, + element: &Element, + attribute: &Attribute, + environment: Option<&PropEnvironment>, + ) -> Result { + let literal_origin = || ValueOrigin { + source: source.into(), + span: element.span, + authored: attribute.value.clone(), + }; + if !has_scalar_specialization(version) + || environment.is_none() + || sink_category(&element.name, &attribute.name).is_none() + { + return Ok(MaterializedScalar { + value: attribute.value.clone(), + origin: literal_origin(), + binding: None, + }); + } + match scan_attribute(&attribute.value).map_err(|message| { + SourceError::at_phase( + ErrorPhase::Specialize, + source, + self.component_stack + .last() + .map(|component| component.id.as_str()), + &self.use_chain, + message, + ) + })? { + AttributeExpression::Literal(value) => Ok(MaterializedScalar { + value, + origin: literal_origin(), + binding: None, + }), + AttributeExpression::Binding(name) => { + let value = environment + .expect("checked") + .get(&name) + .expect("source validation checks declared binding"); + Ok(MaterializedScalar { + value: value.value.clone(), + origin: value.ultimate_origin.clone(), + binding: Some(name), + }) + } + } + } + + fn materialize_text( + &self, + source: &str, + version: SourceVersion, + element: &Element, + text: &str, + environment: Option<&PropEnvironment>, + ) -> Result<(String, Vec<(usize, String)>), SourceError> { + if !has_scalar_specialization(version) + || environment.is_none() + || !matches!(element.name.as_str(), "text" | "tspan") + { + return Ok((text.into(), vec![])); + } + let segments = scan_bindings(text).map_err(|message| { + SourceError::at_phase( + ErrorPhase::Specialize, + source, + self.component_stack + .last() + .map(|component| component.id.as_str()), + &self.use_chain, + message, + ) + })?; + let mut materialized = String::new(); + let mut bindings = vec![]; + for (segment_index, segment) in segments.into_iter().enumerate() { + match segment { + BindingSegment::Literal(value) => materialized.push_str(&value), + BindingSegment::Binding(name) => { + materialized.push_str( + &environment + .expect("checked") + .get(&name) + .expect("source validation checks declared text binding") + .value, + ); + bindings.push((segment_index, name)); + } + } + } + Ok((materialized, bindings)) + } + + fn materialize_use_value( + &self, + source: &str, + version: SourceVersion, + attribute: &str, + value: &str, + span: SourceSpan, + environment: Option<&PropEnvironment>, + ) -> Result { + let origin = || ValueOrigin { + source: source.into(), + span, + authored: value.into(), + }; + if !has_scalar_specialization(version) || environment.is_none() { + return Ok(MaterializedScalar { + value: value.into(), + origin: origin(), + binding: None, + }); + } + match scan_attribute(value).map_err(|message| { + SourceError::at_phase( + ErrorPhase::Specialize, + source, + self.component_stack + .last() + .map(|component| component.id.as_str()), + &self.use_chain, + message, + ) + })? { + AttributeExpression::Literal(value) => Ok(MaterializedScalar { + value, + origin: origin(), + binding: None, + }), + AttributeExpression::Binding(name) => { + let effective = environment + .expect("checked") + .get(&name) + .expect("source validation checks use binding"); + let declaration = PropDeclaration { + name, + scalar_type: effective.scalar_type, + values: effective.enum_values.clone(), + default: None, + span, + }; + validate_binding_target(&declaration, "use", attribute).map_err(|message| { + SourceError::at_phase( + ErrorPhase::Specialize, + source, + self.component_stack + .last() + .map(|component| component.id.as_str()), + &self.use_chain, + message, + ) + })?; + Ok(MaterializedScalar { + value: effective.value.clone(), + origin: effective.ultimate_origin.clone(), + binding: Some(declaration.name), + }) + } + } + } + + #[allow(clippy::too_many_arguments)] + fn specialize_component( + &mut self, + caller_source: &str, + caller_version: SourceVersion, + outer_environment: Option<&PropEnvironment>, + use_element: &Element, + definition_version: SourceVersion, + definition: &ComponentSource, + identity: &ComponentIdentity, + ) -> Result<(PropEnvironment, Option), SourceError> { + let arguments = use_element + .element_children() + .filter(|element| element.name == "arg") + .collect::>(); + if definition_version == SourceVersion::Draft1 { + if !arguments.is_empty() { + return Err(SourceError::at_phase( + ErrorPhase::Specialize, + caller_source, + Some(&identity.id), + &self.use_chain, + "Version 1 components have an empty interface and cannot receive arguments", + )); + } + return Ok((BTreeMap::new(), None)); + } + debug_assert!(has_scalar_specialization(definition_version)); + if !has_scalar_specialization(caller_version) { + return Err(SourceError::at_phase( + ErrorPhase::Link, + caller_source, + Some(&identity.id), + &self.use_chain, + format!( + "Version {} source cannot reference a Version {} scalar component", + caller_version.as_str(), + definition_version.as_str() + ), + )); + } + + let declarations = definition + .props + .iter() + .map(|declaration| (declaration.name.as_str(), declaration)) + .collect::>(); + let mut supplied = BTreeMap::new(); + for argument in arguments { + let name = argument.attribute("name").expect("argument validated"); + if !declarations.contains_key(name) { + return Err(SourceError::at_phase( + ErrorPhase::Specialize, + caller_source, + Some(&identity.id), + &self.use_chain, + format!( + "unknown argument `{name}` for component `{}`; available: {}", + identity.id, + declarations.keys().copied().collect::>().join(", ") + ), + )); + } + supplied.insert(name, argument); + } + + let mut environment = BTreeMap::new(); + for declaration in &definition.props { + let effective = if let Some(argument) = supplied.get(declaration.name.as_str()) { + let argument_site = ArgumentSite { + source: caller_source.into(), + span: argument.span, + name: declaration.name.clone(), + }; + let raw = argument.attribute("value").expect("argument validated"); + match scan_attribute(raw).map_err(|message| { + SourceError::at_phase( + ErrorPhase::Specialize, + caller_source, + Some(&identity.id), + &self.use_chain, + message, + ) + })? { + AttributeExpression::Literal(value) => { + let mut literal = + validate_typed_value(declaration, &value, caller_source, argument.span) + .map_err(|message| { + SourceError::at_phase( + ErrorPhase::Specialize, + caller_source, + Some(&identity.id), + &self.use_chain, + message, + ) + })?; + literal.origin.authored = raw.into(); + EffectiveValue { + scalar_type: declaration.scalar_type, + enum_values: declaration.values.clone(), + value: literal.value, + selection: ValueSelection::Supplied { + argument: argument_site, + }, + ultimate_origin: literal.origin, + forwarding: vec![], + } + } + AttributeExpression::Binding(name) => { + let outer = outer_environment + .and_then(|environment| environment.get(&name)) + .ok_or_else(|| { + SourceError::at_phase( + ErrorPhase::Specialize, + caller_source, + Some(&identity.id), + &self.use_chain, + format!( + "argument `{}` forwards undeclared outer prop `{name}`", + declaration.name + ), + ) + })?; + validate_forwarding(outer, declaration).map_err(|message| { + SourceError::at_phase( + ErrorPhase::Specialize, + caller_source, + Some(&identity.id), + &self.use_chain, + message, + ) + })?; + let mut forwarding = outer.forwarding.clone(); + forwarding.push(argument_site.clone()); + EffectiveValue { + scalar_type: declaration.scalar_type, + enum_values: declaration.values.clone(), + value: outer.value.clone(), + selection: ValueSelection::Supplied { + argument: argument_site, + }, + ultimate_origin: outer.ultimate_origin.clone(), + forwarding, + } + } + } + } else if let Some(default) = &declaration.default { + EffectiveValue { + scalar_type: declaration.scalar_type, + enum_values: declaration.values.clone(), + value: default.value.clone(), + selection: ValueSelection::CalleeDefault, + ultimate_origin: default.origin.clone(), + forwarding: vec![], + } + } else { + return Err(SourceError::at_phase( + ErrorPhase::Specialize, + caller_source, + Some(&identity.id), + &self.use_chain, + format!( + "argument `{}` is required by component `{}`", + declaration.name, identity.id + ), + )); + }; + environment.insert(declaration.name.clone(), effective); + } + + let specialization = self.specializations.len(); + self.specializations.push(SpecializationProvenance { + component: identity.clone(), + use_chain: self.use_chain.clone(), + props: definition + .props + .iter() + .map(|declaration| { + let value = environment.get(&declaration.name).expect("inserted"); + PropValueProvenance { + name: declaration.name.clone(), + scalar_type: declaration.scalar_type, + value: value.value.clone(), + selection: value.selection.clone(), + ultimate_origin: value.ultimate_origin.clone(), + forwarding: value.forwarding.clone(), + binding_targets: vec![], + materialized_occurrences: vec![], + } + }) + .collect(), + }); + Ok((environment, Some(specialization))) + } + + fn resolve_source( + &mut self, + containing_id: &str, + location: &str, + ) -> Result { + if location.is_empty() { + return Ok(containing_id.into()); + } + let cache_key = (containing_id.to_owned(), location.to_owned()); + if let Some(identity) = self.resolutions.get(&cache_key) { + return Ok(identity.clone()); + } + let containing = self + .units + .get(containing_id) + .expect("containing source loaded") + .snapshot + .clone(); + let snapshot = self + .provider + .resolve(&containing, location) + .map_err(|message| { + SourceError::at_phase( + ErrorPhase::Resolve, + containing_id, + None, + &self.use_chain, + format!("could not resolve `{location}`: {message}"), + ) + })?; + if snapshot.identity().is_empty() || snapshot.base().is_empty() { + let missing = if snapshot.identity().is_empty() { + "canonical source identity" + } else { + "canonical source base" + }; + return Err(SourceError::at_phase( + ErrorPhase::Resolve, + containing_id, + None, + &self.use_chain, + format!("resolved `{location}` without a non-empty {missing}"), + )); + } + let identity = snapshot.identity().to_owned(); + if let Some(existing) = self.units.get(&identity) { + if existing.snapshot != snapshot { + return Err(SourceError::at_phase( + ErrorPhase::Resolve, + containing_id, + None, + &self.use_chain, + format!( + "canonical source identity `{identity}` returned inconsistent bytes or base" + ), + )); + } + } else { + let unit = parse_source(snapshot).map_err(|mut error| { + if error.message.starts_with("unsupported version") { + error.phase = ErrorPhase::Resolve; + } + if error.use_chain.is_empty() { + error.use_chain = self.use_chain.clone(); + } + error + })?; + self.units.insert(identity.clone(), unit); + } + self.resolutions.insert(cache_key, identity.clone()); + Ok(identity) + } + + fn resource_rid(&mut self, source: &str, authored: &str) -> Result { + let key = (source.to_owned(), authored.to_owned()); + if let Some(runtime_rid) = self.resource_keys.get(&key) { + return Ok(runtime_rid.clone()); + } + let base = self + .units + .get(source) + .ok_or_else(|| { + SourceError::at_phase( + ErrorPhase::Materialize, + source, + None, + &self.use_chain, + "resource origin is not a loaded source unit", + ) + })? + .snapshot + .base() + .to_owned(); + let runtime_rid = format!("grida-xml-resource-{}", self.resources.len()); + self.resources.push(ResourceManifestEntry { + runtime_rid: runtime_rid.clone(), + source: source.into(), + base, + authored: authored.into(), + }); + self.resource_keys.insert(key, runtime_rid.clone()); + Ok(runtime_rid) + } +} + +/// 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/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, +) -> Result { + let entry = parse_source(entry)?; + Linker::new(entry, provider).materialize() +} + +fn is_render_element(name: &str) -> bool { + matches!( + name, + "container" | "rect" | "ellipse" | "line" | "path" | "text" | "group" | "lens" + ) +} + +fn payload_source_tag(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", + } +} + +fn expanded_render_children(element: &ExpandedElement) -> Vec<&ExpandedElement> { + element + .children + .iter() + .filter_map(|child| match child { + ExpandedContent::Element(child) if is_render_element(&child.name) => { + Some(child.as_ref()) + } + ExpandedContent::Element(_) | ExpandedContent::Text(_) => None, + }) + .collect() +} + +fn local_expanded_bindings<'a>(element: &'a ExpandedElement, out: &mut Vec<&'a ExpandedBinding>) { + out.extend(&element.bindings); + for child in &element.children { + if let ExpandedContent::Element(child) = child { + if !is_render_element(&child.name) { + local_expanded_bindings(child, out); + } + } + } +} + +fn attach_node_provenance( + element: &ExpandedElement, + 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> { + if !is_render_element(&element.name) { + return Err(format!( + "provenance root <{}> is not a render element", + element.name + )); + } + let node = document + .get_opt(node_id) + .ok_or_else(|| format!("materialized node {node_id} is not live"))?; + let actual = payload_source_tag(&node.payload); + if element.name != actual { + return Err(format!( + "provenance/source kind <{}> was parsed as <{actual}> at node {node_id}", + element.name + )); + } + if out.insert(node_id, element.provenance.clone()).is_some() { + return Err(format!( + "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) + .ok_or_else(|| "slot assignment references an unknown projection".to_string())?; + let assignment = projection + .assignments + .get_mut(marker.assignment) + .ok_or_else(|| "slot assignment references an unknown assignment".to_string())?; + if assignment.node.replace(node_id).is_some() { + return Err(format!( + "slot `{}` assignment received more than one ordinary root", + projection.definition.name + )); + } + } + let mut bindings = vec![]; + local_expanded_bindings(element, &mut bindings); + for binding in bindings { + let specialization = specializations + .get_mut(binding.specialization) + .ok_or_else(|| "binding references an unknown specialization".to_string())?; + let prop = specialization + .props + .iter_mut() + .find(|prop| prop.name == binding.prop) + .ok_or_else(|| { + format!( + "binding references unknown prop `{}` in specialization {}", + binding.prop, binding.specialization + ) + })?; + if !prop.binding_targets.contains(&binding.target) { + prop.binding_targets.push(binding.target.clone()); + } + if binding.materialized { + prop.materialized_occurrences + .push(MaterializedBindingOccurrence { + target: binding.target.clone(), + node: node_id, + }); + } + } + let children = expanded_render_children(element); + if children.len() != node.children.len() { + return Err(format!( + "provenance child count {} disagrees with parsed node {node_id} child count {}", + children.len(), + node.children.len() + )); + } + for (child, &child_id) in children.into_iter().zip(&node.children) { + attach_node_provenance( + child, + document, + child_id, + out, + addresses_by_node, + nodes_by_address, + specializations, + slot_projections, + )?; + } + Ok(()) +} + +fn escape_attribute(value: &str, out: &mut String) { + for character in value.chars() { + match character { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '"' => out.push_str("""), + '\t' => out.push_str(" "), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + _ => out.push(character), + } + } +} + +fn escape_text(value: &str, out: &mut String) { + for character in value.chars() { + match character { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(character), + } + } +} + +fn write_start(name: &str, attributes: &[Attribute], out: &mut String) { + let _ = write!(out, "<{name}"); + for attribute in attributes { + let _ = write!(out, " {}=\"", attribute.name); + escape_attribute(&attribute.value, out); + out.push('"'); + } +} + +fn write_element(element: &Element, out: &mut String) { + write_start(&element.name, &element.attributes, out); + if element.children.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + for child in &element.children { + match child { + Content::Element(child) => write_element(child, out), + Content::Text(text) => escape_text(text, out), + } + } + let _ = write!(out, "", element.name); +} + +fn write_expanded_element(element: &ExpandedElement, out: &mut String) { + write_start(&element.name, &element.attributes, out); + if element.children.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + for child in &element.children { + match child { + ExpandedContent::Element(child) => write_expanded_element(child, out), + ExpandedContent::Text(text) => escape_text(text, out), + } + } + let _ = write!(out, "", element.name); +} diff --git a/model-v2/a/lab/src/lib.rs b/model-v2/a/lab/src/lib.rs new file mode 100644 index 0000000000..eb77c4c310 --- /dev/null +++ b/model-v2/a/lab/src/lib.rs @@ -0,0 +1,25 @@ +//! 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 grida_xml; +pub mod grida_xml_source; +pub mod math; +pub mod measure; +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/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..b96d82049f --- /dev/null +++ b/model-v2/a/lab/src/measure.rs @@ -0,0 +1,534 @@ +//! 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 non-newline character advances `0.6 × font_size` +//! - line height is `1.2 × font_size` +//! - 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 + +use crate::model::{TextPayloadRef, TextStyleRec}; + +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 +} + +/// 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; + } + 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 { + current.push_str(&pending_spaces); + current.push_str(&word); + current_len = candidate_len; + } + 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) +} + +/// 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() { + // "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 + } + + #[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" + ); + } + + #[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 new file mode 100644 index 0000000000..998384bbb6 --- /dev/null +++ b/model-v2/a/lab/src/model.rs @@ -0,0 +1,1829 @@ +//! The `anchor` document model — lab subset of `model-v2/models/a.md`. +//! +//! Kinds implemented: frame, shape (rect/ellipse/line/path), 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); +//! - 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; +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, + 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, +} + +/// 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 { + 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, PartialEq)] +pub enum ShapeDesc { + Rect, + Ellipse, + /// The box's horizontal midline; height intent must be Fixed(0) (a.md §3.2). + Line, + /// Validated SVG path data in the fixed unit reference box. + Path(Arc), +} + +/// 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] }, +} + +/// 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 { + layout: LayoutBehavior, + clips_content: bool, + }, + Shape { + desc: ShapeDesc, + }, + Text { + content: String, + font_size: f32, + }, + AttributedText { + attributed_string: AttributedString, + default_style: TextStyleRec, + }, + 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 { .. }) + } + /// 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", + Payload::Shape { .. } => "shape", + 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 +/// 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); + pub const TRANSPARENT: Color = Color(0x0000_0000); + + /// Packed `0xAARRGGBB`. + pub fn argb(self) -> u32 { + self.0 + } + + /// 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 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return 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 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)) + } +} + +/// 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) + } +} +impl From for Color { + fn from(s: String) -> Color { + Color::from(s.as_str()) + } +} + +/// 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", + } + } +} + +/// 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 +/// order. +#[derive(Debug, Clone, PartialEq)] +pub struct Stroke { + pub paints: Paints, + pub width: StrokeWidth, + pub align: StrokeAlign, + pub cap: StrokeCap, + pub join: StrokeJoin, + pub miter_limit: f32, + pub dash_array: Option>, +} + +impl Stroke { + /// Box primitives default inside. Lines and paths default center: lines + /// have no interior, while paths may contain open and closed contours. + pub fn default_for(payload: &Payload) -> Option { + let align = match payload { + Payload::Shape { + desc: ShapeDesc::Line, + } + | Payload::Shape { + desc: ShapeDesc::Path(_), + } => StrokeAlign::Center, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse, + } + | Payload::Text { .. } + | Payload::AttributedText { .. } => StrokeAlign::Inside, + Payload::Group | Payload::Lens { .. } => return None, + }; + Some(Stroke { + paints: Paints::default(), + width: StrokeWidth::Uniform(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.normalized() == default.width.normalized() + && 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.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; + } + if matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Path(path) + } if !path.all_contours_closed && self.align != StrokeAlign::Center + ) { + 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, + } + } +} + +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 corner_radius: RectangularCornerRadius, + pub corner_smoothing: CornerSmoothing, + pub fills: Paints, + pub strokes: Vec, +} + +/// 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)] +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>, + /// 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`). + /// `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. +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()) + } + + /// 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() + } + /// 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() + } + + /// 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) + } + + 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) { + 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] = next_generation; + } + + /// 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); + 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); + 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 { + 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(), + None => return 0, + }; + let mut n = 1; + for c in children { + n += self.tombstone_rec(c); + } + 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.vacate(id); + } + + /// 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, + ) { + 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); + } + 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 { + 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); + 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, + generations: vec![0; cap], + arena: fresh_arena_id(), + 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![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + fills: Paints::default(), + strokes: vec![], + }, + ); + DocBuilder { + nodes, + next: 1, + root: 0, + } + } + + 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( + id, + Node { + id, + header, + payload, + children: vec![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + fills: Paints::default(), + strokes: vec![], + }, + ); + 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() + } +} + +#[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 new file mode 100644 index 0000000000..e477196d43 --- /dev/null +++ b/model-v2/a/lab/src/ops.rs @@ -0,0 +1,600 @@ +//! 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, RotationInFlow}; + +#[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)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +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, 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. + 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) +} + +// --------------------------------------------------------------------------- +// 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/path.rs b/model-v2/a/lab/src/path.rs new file mode 100644 index 0000000000..f0304435ec --- /dev/null +++ b/model-v2/a/lab/src/path.rs @@ -0,0 +1,926 @@ +//! Backend-independent SVG path analysis for boxed unit-reference paths. +//! +//! Authored `d` remains the inspectable source value. Analysis happens once +//! at the model boundary and produces absolute move/line/quad/cubic/rational- +//! conic commands that every later internal consumer can share. SVG arcs are +//! lowered once to at most four exact rational quadratics, quantized once to +//! the same f32 values consumed by bounds and raster materialization. + +use std::fmt; +use std::sync::Arc; + +use kurbo::{CubicBez, Line, Point, QuadBez, Rect, Shape as _}; +use svgtypes::{PathParser, PathSegment}; + +use crate::math::RectF; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FillRule { + #[default] + NonZero, + EvenOdd, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PathCommand { + MoveTo { + x: f32, + y: f32, + }, + LineTo { + x: f32, + y: f32, + }, + QuadTo { + x1: f32, + y1: f32, + x: f32, + y: f32, + }, + CubicTo { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + x: f32, + y: f32, + }, + ConicTo { + x1: f32, + y1: f32, + x: f32, + y: f32, + weight: f32, + }, + Close, +} + +/// One validated path parse, shared verbatim by resolution and rendering. +#[derive(Debug, Clone)] +pub struct PathArtifact { + pub d: Arc, + pub commands: Arc<[PathCommand]>, + pub fill_rule: FillRule, + pub unit_bounds: RectF, + pub all_contours_closed: bool, +} + +impl PartialEq for PathArtifact { + fn eq(&self, other: &Self) -> bool { + self.d == other.d + && self.commands == other.commands + && self.fill_rule == other.fill_rule + && self.unit_bounds == other.unit_bounds + && self.all_contours_closed == other.all_contours_closed + } +} + +impl PathArtifact { + /// Equality of everything that can change rendered path geometry. The + /// authored `d` spelling is deliberately excluded: relative commands and + /// whitespace-only rewrites that normalize to the same absolute commands + /// are not visual damage. + pub fn same_visual_geometry(&self, other: &Self) -> bool { + self.commands == other.commands + && self.fill_rule == other.fill_rule + && self.unit_bounds == other.unit_bounds + && self.all_contours_closed == other.all_contours_closed + } +} + +/// The one box-mapped command stream consumed by resolved bounds, damage, +/// drawlist construction, and raster materialization. Every coordinate has +/// already undergone the exact f32 multiply by the resolved width/height. +#[derive(Debug, Clone)] +pub struct ResolvedPathArtifact { + pub source: Arc, + pub commands: Arc<[PathCommand]>, + pub fill_rule: FillRule, + pub local_bounds: RectF, + pub all_contours_closed: bool, +} + +impl PartialEq for ResolvedPathArtifact { + fn eq(&self, other: &Self) -> bool { + self.commands == other.commands + && self.fill_rule == other.fill_rule + && self.local_bounds == other.local_bounds + && self.all_contours_closed == other.all_contours_closed + } +} + +impl ResolvedPathArtifact { + pub fn same_visual_geometry(&self, other: &Self) -> bool { + self == other + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathError(String); + +impl PathError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for PathError {} + +#[derive(Clone, Copy)] +enum PreviousCurve { + Cubic(f64, f64), + Quadratic(f64, f64), + Other, +} + +/// Validate and analyze an SVG path in Grida's fixed unit reference box. +pub fn analyze( + d: impl Into>, + fill_rule: FillRule, +) -> Result, PathError> { + let d = d.into(); + if d.trim().is_empty() { + return Err(PathError::new("path data must not be empty")); + } + + let mut commands = Vec::new(); + let mut current = (0.0_f64, 0.0_f64); + let mut contour_start = current; + let mut contour_has_geometry = false; + let mut contour_closed = false; + let mut has_geometry = false; + let mut all_contours_closed = true; + let mut after_close = false; + let mut previous = PreviousCurve::Other; + + let finish_contour = |has_geometry: bool, closed: bool, all_closed: &mut bool| { + if has_geometry && !closed { + *all_closed = false; + } + }; + + for parsed in PathParser::from(d.as_ref()) { + let segment = + parsed.map_err(|error| PathError::new(format!("invalid SVG path data: {error}")))?; + + if after_close + && !matches!( + segment, + PathSegment::MoveTo { .. } | PathSegment::ClosePath { .. } + ) + { + commands.push(PathCommand::MoveTo { + x: finite_f32(contour_start.0)?, + y: finite_f32(contour_start.1)?, + }); + current = contour_start; + contour_has_geometry = false; + contour_closed = false; + after_close = false; + } + + match segment { + PathSegment::MoveTo { abs, x, y } => { + finish_contour( + contour_has_geometry, + contour_closed, + &mut all_contours_closed, + ); + let point = absolute_point(abs, current, x, y); + commands.push(PathCommand::MoveTo { + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_start = point; + contour_has_geometry = false; + contour_closed = false; + after_close = false; + previous = PreviousCurve::Other; + } + PathSegment::LineTo { abs, x, y } => { + let point = absolute_point(abs, current, x, y); + commands.push(PathCommand::LineTo { + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_has_geometry = true; + has_geometry = true; + previous = PreviousCurve::Other; + } + PathSegment::HorizontalLineTo { abs, x } => { + let point = (if abs { x } else { current.0 + x }, current.1); + commands.push(PathCommand::LineTo { + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_has_geometry = true; + has_geometry = true; + previous = PreviousCurve::Other; + } + PathSegment::VerticalLineTo { abs, y } => { + let point = (current.0, if abs { y } else { current.1 + y }); + commands.push(PathCommand::LineTo { + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_has_geometry = true; + has_geometry = true; + previous = PreviousCurve::Other; + } + PathSegment::CurveTo { + abs, + x1, + y1, + x2, + y2, + x, + y, + } => { + let control1 = absolute_point(abs, current, x1, y1); + let control2 = absolute_point(abs, current, x2, y2); + let point = absolute_point(abs, current, x, y); + commands.push(PathCommand::CubicTo { + x1: finite_f32(control1.0)?, + y1: finite_f32(control1.1)?, + x2: finite_f32(control2.0)?, + y2: finite_f32(control2.1)?, + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_has_geometry = true; + has_geometry = true; + previous = PreviousCurve::Cubic(control2.0, control2.1); + } + PathSegment::SmoothCurveTo { abs, x2, y2, x, y } => { + let control1 = match previous { + PreviousCurve::Cubic(px, py) => (2.0 * current.0 - px, 2.0 * current.1 - py), + _ => current, + }; + let control2 = absolute_point(abs, current, x2, y2); + let point = absolute_point(abs, current, x, y); + commands.push(PathCommand::CubicTo { + x1: finite_f32(control1.0)?, + y1: finite_f32(control1.1)?, + x2: finite_f32(control2.0)?, + y2: finite_f32(control2.1)?, + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_has_geometry = true; + has_geometry = true; + previous = PreviousCurve::Cubic(control2.0, control2.1); + } + PathSegment::Quadratic { abs, x1, y1, x, y } => { + let control = absolute_point(abs, current, x1, y1); + let point = absolute_point(abs, current, x, y); + commands.push(PathCommand::QuadTo { + x1: finite_f32(control.0)?, + y1: finite_f32(control.1)?, + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_has_geometry = true; + has_geometry = true; + previous = PreviousCurve::Quadratic(control.0, control.1); + } + PathSegment::SmoothQuadratic { abs, x, y } => { + let control = match previous { + PreviousCurve::Quadratic(px, py) => { + (2.0 * current.0 - px, 2.0 * current.1 - py) + } + _ => current, + }; + let point = absolute_point(abs, current, x, y); + commands.push(PathCommand::QuadTo { + x1: finite_f32(control.0)?, + y1: finite_f32(control.1)?, + x: finite_f32(point.0)?, + y: finite_f32(point.1)?, + }); + current = point; + contour_has_geometry = true; + has_geometry = true; + previous = PreviousCurve::Quadratic(control.0, control.1); + } + PathSegment::EllipticalArc { + abs, + rx, + ry, + x_axis_rotation, + large_arc, + sweep, + x, + y, + } => { + if !rx.is_finite() || !ry.is_finite() || rx < 0.0 || ry < 0.0 { + return Err(PathError::new( + "SVG arc radii must be finite and non-negative", + )); + } + if !x_axis_rotation.is_finite() { + return Err(PathError::new("SVG arc rotation must be finite")); + } + let point = absolute_point(abs, current, x, y); + let rx = finite_f32(rx)?; + let ry = finite_f32(ry)?; + let x = finite_f32(point.0)?; + let y = finite_f32(point.1)?; + let start = (finite_f32(current.0)?, finite_f32(current.1)?); + if (rx == 0.0 || ry == 0.0) && (x, y) != start { + commands.push(PathCommand::LineTo { x, y }); + } else if (x, y) != start { + commands.extend(center_arc_commands( + start, + (x, y), + (rx, ry), + x_axis_rotation, + large_arc, + sweep, + )?); + } + current = point; + if (x, y) != start { + contour_has_geometry = true; + has_geometry = true; + } + previous = PreviousCurve::Other; + } + PathSegment::ClosePath { .. } => { + commands.push(PathCommand::Close); + current = contour_start; + contour_closed = true; + after_close = true; + previous = PreviousCurve::Other; + } + } + } + + finish_contour( + contour_has_geometry, + contour_closed, + &mut all_contours_closed, + ); + if !has_geometry { + return Err(PathError::new( + "path data must contain at least one drawing segment", + )); + } + + let commands = drawable_commands(commands); + let bounds = command_bounds(&commands, true)?; + + Ok(Arc::new(PathArtifact { + d, + commands: commands.into(), + fill_rule, + unit_bounds: bounds, + all_contours_closed, + })) +} + +/// Map a validated unit-reference artifact into one resolved local box. +/// Multiplication occurs exactly once here; later consumers must not rescale +/// the commands independently. +pub fn materialize( + source: Arc, + width: f32, + height: f32, +) -> Result, PathError> { + if !width.is_finite() || width < 0.0 || !height.is_finite() || height < 0.0 { + return Err(PathError::new( + "resolved path box must have finite non-negative dimensions", + )); + } + let commands: Vec = source + .commands + .iter() + .map(|command| { + Ok(match *command { + PathCommand::MoveTo { x, y } => PathCommand::MoveTo { + x: scaled_f32(x, width)?, + y: scaled_f32(y, height)?, + }, + PathCommand::LineTo { x, y } => PathCommand::LineTo { + x: scaled_f32(x, width)?, + y: scaled_f32(y, height)?, + }, + PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo { + x1: scaled_f32(x1, width)?, + y1: scaled_f32(y1, height)?, + x: scaled_f32(x, width)?, + y: scaled_f32(y, height)?, + }, + PathCommand::CubicTo { + x1, + y1, + x2, + y2, + x, + y, + } => PathCommand::CubicTo { + x1: scaled_f32(x1, width)?, + y1: scaled_f32(y1, height)?, + x2: scaled_f32(x2, width)?, + y2: scaled_f32(y2, height)?, + x: scaled_f32(x, width)?, + y: scaled_f32(y, height)?, + }, + PathCommand::ConicTo { + x1, + y1, + x, + y, + weight, + } => PathCommand::ConicTo { + x1: scaled_f32(x1, width)?, + y1: scaled_f32(y1, height)?, + x: scaled_f32(x, width)?, + y: scaled_f32(y, height)?, + weight, + }, + PathCommand::Close => PathCommand::Close, + }) + }) + .collect::>()?; + let local_bounds = command_bounds(&commands, false)?; + Ok(Arc::new(ResolvedPathArtifact { + commands: commands.into(), + fill_rule: source.fill_rule, + local_bounds, + all_contours_closed: source.all_contours_closed, + source, + })) +} + +fn scaled_f32(value: f32, scale: f32) -> Result { + let mapped = value * scale; + if mapped.is_finite() { + Ok(if mapped == 0.0 { 0.0 } else { mapped }) + } else { + Err(PathError::new( + "resolved path coordinates exceed finite f32 geometry", + )) + } +} + +/// Remove move-only contours. They have no fill or stroke geometry and must +/// not perturb tight bounds or visual-damage identity. +fn drawable_commands(commands: Vec) -> Vec { + let mut out = Vec::with_capacity(commands.len()); + let mut contour = Vec::new(); + let flush = |contour: &mut Vec, out: &mut Vec| { + if contour.iter().any(|command| { + matches!( + command, + PathCommand::LineTo { .. } + | PathCommand::QuadTo { .. } + | PathCommand::CubicTo { .. } + | PathCommand::ConicTo { .. } + ) + }) { + out.append(contour); + } else { + contour.clear(); + } + }; + for command in commands { + if matches!(command, PathCommand::MoveTo { .. }) && !contour.is_empty() { + flush(&mut contour, &mut out); + } + if matches!(command, PathCommand::Close) + && matches!(contour.last(), Some(PathCommand::Close)) + { + continue; + } + contour.push(command); + } + flush(&mut contour, &mut out); + out +} + +fn absolute_point(abs: bool, current: (f64, f64), x: f64, y: f64) -> (f64, f64) { + if abs { + (x, y) + } else { + (current.0 + x, current.1 + y) + } +} + +fn finite_f32(value: f64) -> Result { + let value = value as f32; + if value.is_finite() { + Ok(if value == 0.0 { 0.0 } else { value }) + } else { + Err(PathError::new("path coordinates must be finite f32 values")) + } +} + +fn command_bounds(commands: &[PathCommand], require_unit_box: bool) -> Result { + let mut bounds: Option = None; + let mut current: Option = None; + let mut contour_start: Option = None; + for command in commands { + match *command { + PathCommand::MoveTo { x, y } => { + let point = Point::new(x.into(), y.into()); + current = Some(point); + contour_start = Some(point); + } + PathCommand::LineTo { x, y } => { + let next = Point::new(x.into(), y.into()); + include_bounds( + &mut bounds, + Line::new(required_current(current), next).bounding_box(), + ); + current = Some(next); + } + PathCommand::QuadTo { x1, y1, x, y } => { + let next = Point::new(x.into(), y.into()); + include_bounds( + &mut bounds, + QuadBez::new( + required_current(current), + Point::new(x1.into(), y1.into()), + next, + ) + .bounding_box(), + ); + current = Some(next); + } + PathCommand::CubicTo { + x1, + y1, + x2, + y2, + x, + y, + } => { + let next = Point::new(x.into(), y.into()); + include_bounds( + &mut bounds, + CubicBez::new( + required_current(current), + Point::new(x1.into(), y1.into()), + Point::new(x2.into(), y2.into()), + next, + ) + .bounding_box(), + ); + current = Some(next); + } + PathCommand::ConicTo { + x1, + y1, + x, + y, + weight, + } => { + let next = Point::new(x.into(), y.into()); + include_bounds( + &mut bounds, + conic_bounds( + required_current(current), + Point::new(x1.into(), y1.into()), + next, + weight.into(), + ), + ); + current = Some(next); + } + PathCommand::Close => { + let start = contour_start.expect("validated close follows a move command"); + include_bounds( + &mut bounds, + Line::new(required_current(current), start).bounding_box(), + ); + current = Some(start); + } + } + } + let bounds = bounds.expect("validated path contains drawable geometry"); + if !bounds.is_finite() { + return Err(PathError::new("path coordinates must be finite")); + } + if require_unit_box + && (bounds.x0 < 0.0 || bounds.y0 < 0.0 || bounds.x1 > 1.0 || bounds.y1 > 1.0) + { + return Err(PathError::new(format!( + "path geometry must stay inside the unit reference box 0..1; tight bounds are {} {} {} {}", + bounds.x0, + bounds.y0, + bounds.width(), + bounds.height() + ))); + } + rectf_covering(bounds) +} + +/// Encode f64 LTRB bounds in RectF without letting separate f32 rounding of +/// origin and extent move the reconstructed right/bottom edge inward. +fn rectf_covering(bounds: Rect) -> Result { + let (x, w) = covering_axis(bounds.x0, bounds.x1)?; + let (y, h) = covering_axis(bounds.y0, bounds.y1)?; + Ok(RectF { x, y, w, h }) +} + +fn covering_axis(min: f64, max: f64) -> Result<(f32, f32), PathError> { + let start = floor_f32(min)?; + let target_end = ceil_f32(max)?; + let mut extent = ceil_f32(f64::from(target_end) - f64::from(start))?; + while start + extent < target_end { + extent = next_up_f32(extent); + } + Ok((start, extent)) +} + +fn floor_f32(value: f64) -> Result { + let rounded = finite_f32(value)?; + Ok(if f64::from(rounded) > value { + next_down_f32(rounded) + } else { + rounded + }) +} + +fn ceil_f32(value: f64) -> Result { + let rounded = finite_f32(value)?; + Ok(if f64::from(rounded) < value { + next_up_f32(rounded) + } else { + rounded + }) +} + +fn next_up_f32(value: f32) -> f32 { + if value == f32::INFINITY { + return value; + } + if value == 0.0 { + return f32::from_bits(1); + } + let bits = value.to_bits(); + f32::from_bits(if value > 0.0 { bits + 1 } else { bits - 1 }) +} + +fn next_down_f32(value: f32) -> f32 { + if value == f32::NEG_INFINITY { + return value; + } + if value == 0.0 { + return -f32::from_bits(1); + } + let bits = value.to_bits(); + f32::from_bits(if value > 0.0 { bits - 1 } else { bits + 1 }) +} + +fn required_current(current: Option) -> Point { + current.expect("validated path drawing command follows a move command") +} + +fn include_bounds(bounds: &mut Option, next: Rect) { + *bounds = Some(bounds.map_or(next, |bounds| bounds.union(next))); +} + +#[derive(Clone, Copy)] +struct CenterArc { + center_x: f64, + center_y: f64, + radius_x: f64, + radius_y: f64, + x_axis_rotation: f64, +} + +/// SVG endpoint-to-center conversion without a geometric tolerance. Exact +/// zero radii and coincident endpoints are normalized by the caller; every +/// positive-radius, distinct-endpoint arc reaches this conversion. +fn center_arc_commands( + start: (f32, f32), + end: (f32, f32), + radii: (f32, f32), + x_axis_rotation_degrees: f64, + large_arc: bool, + sweep: bool, +) -> Result, PathError> { + let (start_x, start_y) = (f64::from(start.0), f64::from(start.1)); + let (end_x, end_y) = (f64::from(end.0), f64::from(end.1)); + let mut radius_x = f64::from(radii.0); + let mut radius_y = f64::from(radii.1); + // A circle has no distinguished ellipse axis. Canonicalizing its inert + // authored rotation avoids injecting trig residue into the conic stream. + let x_axis_rotation = if radii.0 == radii.1 { + 0.0 + } else { + x_axis_rotation_degrees.rem_euclid(360.0).to_radians() + }; + let (sin_rotation, cos_rotation) = sin_cos_quadrant_exact(x_axis_rotation); + + let half_dx = (start_x - end_x) * 0.5; + let half_dy = (start_y - end_y) * 0.5; + let x_prime = cos_rotation * half_dx + sin_rotation * half_dy; + let y_prime = -sin_rotation * half_dx + cos_rotation * half_dy; + + // SVG radii correction. `hypot` avoids overflow in the ratio sum and + // makes tiny positive radii ordinary arcs rather than pseudo-degenerate. + let radii_scale = (x_prime / radius_x).hypot(y_prime / radius_y); + if radii_scale > 1.0 { + radius_x *= radii_scale; + radius_y *= radii_scale; + } + + let normalized_x = x_prime / radius_x; + let normalized_y = y_prime / radius_y; + let normalized_squared = normalized_x * normalized_x + normalized_y * normalized_y; + if normalized_squared == 0.0 || !normalized_squared.is_finite() { + return Err(PathError::new( + "SVG arc center conversion produced invalid normalized geometry", + )); + } + let sign = if large_arc == sweep { -1.0 } else { 1.0 }; + let coefficient = sign * ((1.0 - normalized_squared).max(0.0) / normalized_squared).sqrt(); + let center_prime_x = coefficient * radius_x * normalized_y; + let center_prime_y = coefficient * -radius_y * normalized_x; + let center_x = + cos_rotation * center_prime_x - sin_rotation * center_prime_y + (start_x + end_x) * 0.5; + let center_y = + sin_rotation * center_prime_x + cos_rotation * center_prime_y + (start_y + end_y) * 0.5; + + let ux = (x_prime - center_prime_x) / radius_x; + let uy = (y_prime - center_prime_y) / radius_y; + let start_angle = uy.atan2(ux); + // The endpoint-vector atan2 formula loses a nearly-full sweep when its + // tiny complementary chord rounds to zero. The corrected normalized + // half-chord directly defines the small central angle and remains stable + // across that range. + let small_sweep = 2.0 * normalized_squared.sqrt().clamp(0.0, 1.0).asin(); + let sweep_magnitude = if large_arc { + std::f64::consts::TAU - small_sweep + } else { + small_sweep + }; + let sweep_angle = if sweep { + sweep_magnitude + } else { + -sweep_magnitude + }; + + if [ + center_x, + center_y, + radius_x, + radius_y, + x_axis_rotation, + start_angle, + sweep_angle, + ] + .into_iter() + .any(|value| !value.is_finite()) + { + return Err(PathError::new( + "SVG arc center conversion produced non-finite geometry", + )); + } + + let arc = CenterArc { + center_x, + center_y, + radius_x, + radius_y, + x_axis_rotation, + }; + let raw_count = sweep_angle.abs() / std::f64::consts::FRAC_PI_2; + let nearest_count = raw_count.round(); + let stable_count = + if (raw_count - nearest_count).abs() <= 32.0 * f64::EPSILON * raw_count.max(1.0) { + nearest_count + } else { + raw_count + }; + let count = stable_count.ceil().clamp(1.0, 4.0) as usize; + let step = sweep_angle / count as f64; + let mut commands = Vec::with_capacity(count); + for index in 0..count { + let end_angle = start_angle + step * (index + 1) as f64; + let middle_angle = end_angle - step * 0.5; + let weight = (step * 0.5).cos(); + let (middle_sin, middle_cos) = sin_cos_quadrant_exact(middle_angle); + let control = arc_point_from_unit( + arc, + snap_unit_trig_identity(middle_cos / weight), + snap_unit_trig_identity(middle_sin / weight), + ); + let endpoint = if index + 1 == count { + end + } else { + let point = arc_point(arc, end_angle); + (finite_f32(point.x)?, finite_f32(point.y)?) + }; + commands.push(PathCommand::ConicTo { + x1: finite_f32(control.x)?, + y1: finite_f32(control.y)?, + x: endpoint.0, + y: endpoint.1, + weight: finite_f32(weight)?, + }); + } + Ok(commands) +} + +/// Tight bounds of the exact stored rational quadratic with weights +/// `[1, weight, 1]`. Extrema are roots of the rational derivative, a +/// quadratic after its cubic term cancels. +fn conic_bounds(start: Point, control: Point, end: Point, weight: f64) -> Rect { + let mut bounds = Rect::from_points(start, end); + for axis in 0..2 { + let p0 = if axis == 0 { start.x } else { start.y }; + let p1 = if axis == 0 { control.x } else { control.y }; + let p2 = if axis == 0 { end.x } else { end.y }; + let numerator_a = p0 - 2.0 * weight * p1 + p2; + let numerator_b = 2.0 * (weight * p1 - p0); + let numerator_c = p0; + let denominator_d = 2.0 * (1.0 - weight); + let denominator_e = 2.0 * (weight - 1.0); + let derivative_0 = numerator_b - numerator_c * denominator_e; + let derivative_1 = 2.0 * (numerator_a - numerator_c * denominator_d); + let derivative_2 = numerator_a * denominator_e - numerator_b * denominator_d; + for t in kurbo::common::solve_quadratic(derivative_0, derivative_1, derivative_2) { + if t > 0.0 && t < 1.0 { + bounds = bounds.union_pt(conic_point(start, control, end, weight, t)); + } + } + } + bounds +} + +fn conic_point(start: Point, control: Point, end: Point, weight: f64, t: f64) -> Point { + let one_minus_t = 1.0 - t; + let start_factor = one_minus_t * one_minus_t; + let control_factor = 2.0 * weight * one_minus_t * t; + let end_factor = t * t; + let denominator = start_factor + control_factor + end_factor; + let point = Point::new( + (start_factor * start.x + control_factor * control.x + end_factor * end.x) / denominator, + (start_factor * start.y + control_factor * control.y + end_factor * end.y) / denominator, + ); + // Positive rational weights keep the curve inside its control hull. + // Clamp only arithmetic that violates that exact invariant. + Point::new( + point.x.clamp( + start.x.min(control.x).min(end.x), + start.x.max(control.x).max(end.x), + ), + point.y.clamp( + start.y.min(control.y).min(end.y), + start.y.max(control.y).max(end.y), + ), + ) +} + +fn arc_point(arc: CenterArc, angle: f64) -> Point { + let (sin_angle, cos_angle) = sin_cos_quadrant_exact(angle); + arc_point_from_unit(arc, cos_angle, sin_angle) +} + +fn sin_cos_quadrant_exact(angle: f64) -> (f64, f64) { + let quadrant = (angle / std::f64::consts::FRAC_PI_2).round(); + if (angle - quadrant * std::f64::consts::FRAC_PI_2).abs() + <= 32.0 * f64::EPSILON * angle.abs().max(1.0) + { + return match (quadrant as i64).rem_euclid(4) { + 0 => (0.0, 1.0), + 1 => (1.0, 0.0), + 2 => (0.0, -1.0), + _ => (-1.0, 0.0), + }; + } + angle.sin_cos() +} + +fn snap_unit_trig_identity(value: f64) -> f64 { + for exact in [-1.0, 0.0, 1.0] { + if (value - exact).abs() <= 32.0 * f64::EPSILON { + return exact; + } + } + value +} + +fn arc_point_from_unit(arc: CenterArc, unit_x: f64, unit_y: f64) -> Point { + let (sin_rotation, cos_rotation) = sin_cos_quadrant_exact(arc.x_axis_rotation); + Point::new( + arc.center_x + arc.radius_x * unit_x * cos_rotation - arc.radius_y * unit_y * sin_rotation, + arc.center_y + arc.radius_x * unit_x * sin_rotation + arc.radius_y * unit_y * cos_rotation, + ) +} diff --git a/model-v2/a/lab/src/pick.rs b/model-v2/a/lab/src/pick.rs new file mode 100644 index 0000000000..1c59a54419 --- /dev/null +++ b/model-v2/a/lab/src/pick.rs @@ -0,0 +1,77 @@ +//! 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; +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; + +/// 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(r: &Resolved, id: NodeId, p: (f32, f32)) -> Option { + r.world_opt(id)?; // hidden subtree + 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 = 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); + rounded_box::contains( + b.w, + b.h, + clip.corner_radius, + clip.corner_smoothing, + (lx, ly), + ) + }) + }); + if children_visible { + 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 query.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 + } +} 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 new file mode 100644 index 0000000000..8e0979d490 --- /dev/null +++ b/model-v2/a/lab/src/resolve.rs @@ -0,0 +1,1650 @@ +//! 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::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}; +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, + }, +} + +/// 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, PartialEq)] +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>, + /// Final-width text layout. `None` for non-text and unresolved nodes. + pub(crate) text_layouts: Vec>>, + /// 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, +} + +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], + 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(), + } + } + 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() + } + /// 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() + } + pub fn text_layout_of(&self, id: NodeId) -> &Arc { + self.text_layouts[id as usize] + .as_ref() + .expect("node has no resolved text layout") + } + pub fn text_layout_opt(&self, id: NodeId) -> Option<&Arc> { + self.text_layouts.get(id as usize)?.as_ref() + } + pub fn resolved_path_of(&self, id: NodeId) -> &Arc { + self.resolved_paths[id as usize] + .as_ref() + .expect("node has no resolved path artifact") + } + 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. + 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() + } + 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> { + view: &'a ValueView<'a>, + text_layout: &'a dyn TextLayoutOracle, + opts: ResolveOptions, + out: Resolved, + /// Derived-box kinds: union rect of children in node-local space, + /// cached once children are committed (children commit exactly once). + /// `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 { + resolve_view(&ValueView::base(doc), opts) +} + +/// Resolve with an explicit text-layout authority. +pub fn resolve_with_text_layout( + doc: &Document, + 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 { + 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, + 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); + + // Store one final artifact per text node using the width that survived + // layout, stretch, and clamps. No later stage may independently reflow it. + for index in 0..doc.capacity() { + let id = index as NodeId; + let Some(node) = doc.get_opt(id) else { + continue; + }; + let Some(text) = node.payload.as_text() else { + continue; + }; + let Some(box_in_parent) = cx.out.box_opt(id) else { + continue; + }; + let layout = cx + .text_layout + .layout(text, Some(box_in_parent.w)) + .with_assigned_box(RectF { + x: 0.0, + y: 0.0, + w: box_in_parent.w, + h: box_in_parent.h, + }); + if layout.unresolved_glyphs > 0 { + cx.out.reports.push(Report::ErrorByRule { + node: id, + field: "text", + rule: "text layout contains unresolved glyphs", + }); + } + cx.out.text_layouts[index] = Some(layout); + } + + // Path syntax was analyzed once at the model boundary. Resolution maps + // every coordinate through the final box exactly once; paint, bounds, and + // damage then consume that same rounded command stream. + for index in 0..doc.capacity() { + let id = index as NodeId; + let Some(node) = doc.get_opt(id) else { + continue; + }; + let Payload::Shape { + desc: ShapeDesc::Path(artifact), + } = &node.payload + else { + continue; + }; + if let Some(box_in_parent) = cx.out.box_opt(id) { + match materialize(Arc::clone(artifact), box_in_parent.w, box_in_parent.h) { + Ok(path) => cx.out.resolved_paths[index] = Some(path), + Err(_) => cx.out.reports.push(Report::ErrorByRule { + node: id, + field: "path", + rule: "resolved path coordinates exceed finite f32 geometry", + }), + } + } + } + + // 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.view.document().get(id); + + if node.payload.box_is_derived() { + // width/height intents are ignored-by-rule on derived kinds (§8). + 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, + 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, 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 = 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); + } + if h.is_none() && !text_auto_height { + h = intent_extent_y(id, cx); + } + + // aspect_ratio resolves an under-specified axis only (G-5). + 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), + (None, Some(hv)) => w = Some(hv * ar_w / ar_h), + _ => {} + } + } + } + + let width_before_clamp = w; + 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 + }); + // 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, + cx.view.min_width_unchecked(id), + cx.view.max_width_unchecked(id), + cx, + ); + if text_auto_height { + 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!(cx.view.width_unchecked(id), 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(cx.text_layout.layout(text, wrap_width).height); + } + + 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 + }); + + // Height clamps apply after any width-driven text re-measure. Min beats + // max on both axes (G-4 declared rule). + if !is_line { + hv = clamp_axis( + id, + "height", + hv, + cx.view.min_height_unchecked(id), + cx.view.max_height_unchecked(id), + 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.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( + cx.text_layout + .layout(payload.as_text().expect("matched text"), None) + .width, + ), + Payload::Frame { .. } => Some(hug_size(id, cx).0), + _ => None, + }, + } +} + +/// 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.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), + _ => 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.view.document().get(id); + let layout = cx.view.layout(id); + match layout.mode { + LayoutMode::Flex => { + let definite_w = match cx.view.width_unchecked(id) { + SizeIntent::Fixed(v) => Some(v), + SizeIntent::Auto => None, + }; + let definite_h = match cx.view.height_unchecked(id) { + 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.view.active(child_id) { + continue; + } + let (cw, ch) = extent_of(child_id, None, cx); + 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 { + RectF { + x: 0.0, + y: 0.0, + w: cw, + h: ch, + } + }; + 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 + // 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 = 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)) + .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.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 cx.view.x(id) { + 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 cx.view.y(id) { + 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 cx.view.x(id) { + 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 cx.view.y(id) { + 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[id as usize] { + return u; + } + let children: Vec = cx.view.document().get(id).children.clone(); + let mut union: Option = None; + for child_id in children { + if !cx.view.active(child_id) { + 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.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 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, + 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[id as usize] = Some(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.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 + // 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[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[id as usize] = Some(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); + + // 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.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, + }; + 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.view.document().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) { + match layout.mode { + LayoutMode::None => layout_free_children(id, extent, layout.padding, cx), + LayoutMode::Flex => { + 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.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 { + // 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[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 + // as commit) lands on the slot center. + let d = Affine::rotate_deg(theta) + .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, + 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 { + if !cx.view.active(child_id) || cx.view.flow(child_id) != Flow::Absolute { + continue; + } + let b = place_by_bindings(child_id, extent, cx); + commit(child_id, b, cx); + } + } + } +} + +/// 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.view.document().get(id).children.clone(); + for child_id in children { + if !cx.view.active(child_id) { + 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) { + if cx.view.grow(id) != 0.0 { + cx.out.reports.push(Report::IgnoredByRule { + node: id, + field: "grow", + rule: "no layout parent", + }); + } + if cx.view.self_align(id) != 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(Payload); + +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.view.document().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.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 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 = 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; + + 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 { + cx.view.grow(*child_id) + } else { + 0.0 + }, + align_self: match cx.view.self_align(*child_id) { + 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() + }; + 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(); + 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 text_payload = child.payload.clone(); + match cx.view.width_unchecked(*child_id) { + SizeIntent::Fixed(v) => style.size.width = length(v), + SizeIntent::Auto => style.size.width = auto(), + } + 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 cx.view.self_align(*child_id) == 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(text_payload)) + .unwrap() + } else { + let b = extent_of(*child_id, None, cx); + basis = b; + 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 effective cross + // size remains Auto; retaining the resolved basis as Definite here + // 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 && 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 + && (cx.view.self_align(*child_id) == SelfAlign::Stretch + || (!box_is_derived + && cx.view.self_align(*child_id) == SelfAlign::Auto + && layout.cross_align == CrossAlign::Stretch + && cross_is_auto)); + if stretches_cross { + 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 text_layout = cx.text_layout.layout( + t.0.as_text().expect("text measure context carries text"), + constraint, + ); + taffy::geometry::Size { + width: known.width.unwrap_or(text_layout.width), + height: known.height.unwrap_or(text_layout.height), + } + } + 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[id as usize] { + Some(ops) => world.then(&ops), + None => world, + }; + 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); + } + } +} + +#[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. Repeated stroke geometries overlap independently, so +/// each edge takes their maximum outward reach rather than summing widths. +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 { + desc: ShapeDesc::Line + } + ); + let is_path = matches!( + &node.payload, + Payload::Shape { + desc: ShapeDesc::Path(_) + } + ); + let mut outsets = StrokeOutsets::default(); + 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, 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, + } + }; + if (node.payload.as_text().is_some() || is_path) && 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 { + 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.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(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 + // stroke coverage as every other outline. No base ink means there is no + // glyph outline for a stroke to cover (not even for an empty terminal + // line), so it remains a zero-area local bound. + let base_local_rect = if node.payload.as_text().is_some() { + cx.out + .text_layout_opt(id) + .expect("resolved text has a final text-layout artifact") + .ink_bounds + } else if matches!( + &node.payload, + Payload::Shape { + desc: ShapeDesc::Path(_) + } + ) { + cx.out.resolved_path_opt(id).map(|path| path.local_bounds) + } else { + Some(RectF { + x: 0.0, + y: 0.0, + w: own_box.w, + h: own_box.h, + }) + }; + let local_rect = base_local_rect.map(|base| RectF { + x: base.x - stroke_outsets.left, + y: base.y - stroke_outsets.top, + w: base.w + stroke_outsets.left + stroke_outsets.right, + h: base.h + stroke_outsets.top + stroke_outsets.bottom, + }); + + let mut aabb = match &node.payload { + // Derived kinds: bounds come from children only (D-1). + Payload::Group | Payload::Lens { .. } => None, + Payload::Shape { + desc: ShapeDesc::Path(_), + } if local_rect.is_none() => None, + _ => Some(local_rect.unwrap_or(RectF::EMPTY).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 { .. }) && 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| { + 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), + }); + } + } + 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/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/svgout.rs b/model-v2/a/lab/src/svgout.rs new file mode 100644 index 0000000000..b3edef2c62 --- /dev/null +++ b/model-v2/a/lab/src/svgout.rs @@ -0,0 +1,307 @@ +//! 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, +} + +#[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 + ))); + } + 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!( + "{owner} uses a solid blend mode the SVG snapshot subset cannot represent" + ))), + [_] => Err(SvgError(format!( + "{owner} uses a rich paint the SVG snapshot subset cannot represent" + ))), + paints => Err(SvgError(format!( + "{owner} has {} paints; the SVG snapshot subset supports at most one solid", + paints.len() + ))), + } +} + +fn escape_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn escape_attr(value: &str) -> String { + escape_text(value).replace('"', """) +} + +pub fn render(doc: &Document, resolved: &Resolved, opts: &SvgOptions) -> Result { + 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, ""); + Ok(out) +} + +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 Ok(()); // hidden + }; + 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", + 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 fill = fill.attributes("fill"); + let _ = writeln!( + out, + r#" "#, + b.w, + b.h, + mat(world), + fill + ); + } + 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 + ); + } + ShapeDesc::Path(path) => { + let fill = fill.attributes("fill"); + let transform = world.then(&Affine::scale(b.w, b.h)); + let fill_rule = match path.fill_rule { + FillRule::NonZero => "nonzero", + FillRule::EvenOdd => "evenodd", + }; + let _ = writeln!( + out, + r#" "#, + // SVG snapshot/export is an explicit foreign-language + // boundary and therefore parses its own validated SVG d. + // Internal bounds, damage, drawlist, and paint use only + // the shared rational-conic artifact. + escape_attr(path.d.as_ref()), + mat(&transform), + fill_rule, + fill + ); + } + }, + Payload::Text { content, font_size } => { + let fill = fill.attributes("fill"); + let _ = writeln!( + out, + r#" {}"#, + font_size, + mat(world), + font_size, + fill, + 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 { .. } => {} + } + + for c in &node.children { + paint(doc, *c, resolved, out)?; + } + Ok(()) +} diff --git a/model-v2/a/lab/src/text_layout.rs b/model-v2/a/lab/src/text_layout.rs new file mode 100644 index 0000000000..128cb2c045 --- /dev/null +++ b/model-v2/a/lab/src/text_layout.rs @@ -0,0 +1,262 @@ +//! Backend-independent resolved text layout. +//! +//! A [`TextLayoutOracle`] is the only authority that may turn authored text +//! into line and glyph geometry. The resolver consumes only this contract and +//! stores the final artifact; renderers must not independently reflow text. + +use crate::math::RectF; +use crate::measure::layout_text_payload; +use crate::model::TextPayloadRef; +use std::ops::Range; +use std::sync::Arc; + +/// Opaque key into the font registry owned by a text-layout backend. +/// +/// The key identifies the exact font instance used to shape a glyph run. A +/// renderer resolves it through the same backend/registry instead of trying +/// to reconstruct a font from authored style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TextFontKey(u32); + +impl TextFontKey { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn get(self) -> u32 { + self.0 + } +} + +/// One positioned glyph emitted by a shaping backend. +#[derive(Debug, Clone, PartialEq)] +pub struct TextGlyph { + /// Backend glyph identifier in the font selected by its run. + pub id: u16, + /// UTF-8 byte offset of the source cluster that produced this glyph. + pub cluster: u32, + /// Glyph origin in text-box-local coordinates. + pub x: f32, + pub y: f32, + /// Optional glyph ink bounds in text-box-local coordinates. + pub bounds: Option, +} + +/// A positioned run shaped with one exact font. +#[derive(Debug, Clone, PartialEq)] +pub struct TextGlyphRun { + pub line_index: usize, + /// Index into the authored attributed runs; `None` for uniform text. + pub source_run: Option, + /// Exact font-instance identity within the declared layout environment. + /// Durability and cross-process meaning depend on that environment's + /// identity policy; [`TextFontKey`] is only a replay-registry address and + /// must not substitute for this field in semantic comparisons. + pub font_identity: String, + /// Opaque address in the replay registry accompanying this artifact. + pub font: TextFontKey, + pub glyphs: Vec, +} + +/// Metrics and source mapping for one materialized visual line. +#[derive(Debug, Clone, PartialEq)] +pub struct TextLine { + /// Visible source text on this line. A separator discarded by soft wrap + /// and an authored newline are intentionally absent. + pub text: String, + /// UTF-8 byte range in the authored string corresponding to `text`. + pub byte_range: Range, + /// Complete source range consumed by the line, including an authored + /// newline or separator whitespace discarded at a soft wrap. + pub source_range: Range, + /// Why this visual line ended. + pub end: TextLineBreak, + pub left: f32, + pub width: f32, + pub top: f32, + pub height: f32, + pub baseline: f32, + pub ascent: f32, + pub descent: f32, +} + +/// The boundary that ended one materialized visual line. +/// +/// This is deliberately richer than a `hard_break` bit: a final line is not +/// an authored newline, and consumers such as caret placement must be able to +/// distinguish it from both a soft wrap and truncation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextLineBreak { + /// The oracle selected a wrap opportunity before more source content. + Soft, + /// An authored line terminator ended the line. + Explicit, + /// The line reaches the end of the complete, untruncated source. + Terminal, + /// Paragraph overflow policy replaced or omitted remaining source. + Truncated, +} + +/// The owned result of resolving one text payload under one width constraint. +/// +/// The result is returned and stored behind [`Arc`], establishing the one +/// source that each geometry-sensitive consumer must progressively adopt. +/// The current proving engine consumes it for sizing, painting, bounds, and +/// damage; complete picking, selection, and export projections remain open. +#[derive(Debug, Clone, PartialEq)] +pub struct TextLayout { + /// Stable oracle and oracle-version identifier. + pub oracle: &'static str, + /// Identity of the complete font/language environment used by this + /// result. A proving backend may explicitly label a process-local value; + /// such an artifact is inspectable but not portable or durable. + pub environment: String, + /// Exact optional maximum inline extent supplied to this oracle call. + /// `None` means that the inline axis was unconstrained. + pub width_constraint: Option, + /// Final text box assigned by surrounding layout, in node-local + /// coordinates. This is not a synonym for logical or ink bounds. + pub assigned_box: RectF, + /// Measured logical content extent reported by the oracle. + pub width: f32, + pub height: f32, + pub lines: Vec, + pub glyph_runs: Vec, + /// Logical layout bounds when supplied by the oracle. + pub logical_bounds: Option, + /// Union of glyph ink when supplied by the oracle. + pub ink_bounds: Option, + /// Glyphs the oracle could not resolve under its explicit font + /// environment. Strict hosts report any nonzero value as a resolution + /// error instead of accepting silent tofu. + pub unresolved_glyphs: usize, +} + +impl TextLayout { + /// Attach the box assigned by surrounding layout to an oracle result. + /// + /// Oracles initially report their measured content box because they are + /// also called during size negotiation. The resolver calls this exactly + /// once for the stored final artifact after all box rules have settled. + pub fn with_assigned_box(self: Arc, assigned_box: RectF) -> Arc { + if self.assigned_box == assigned_box { + return self; + } + Arc::new(TextLayout { + assigned_box, + ..self.as_ref().clone() + }) + } +} + +/// Pure text-layout boundary used by model resolution. +pub trait TextLayoutOracle { + /// Resolve authored text under an optional maximum logical width. + fn layout(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc; +} + +/// Honest adapter for the lab's deterministic `0.6 / 1.2` metric. +/// +/// This is a layout oracle, not a shaper: it produces line metrics and source +/// ranges but deliberately emits no glyph runs. +#[derive(Debug, Clone, Copy, Default)] +pub struct StubTextLayoutOracle; + +pub const STUB_TEXT_LAYOUT_ORACLE: StubTextLayoutOracle = StubTextLayoutOracle; +pub const STUB_TEXT_LAYOUT_ORACLE_ID: &str = "stub@lab-0"; +pub const STUB_TEXT_LAYOUT_ENVIRONMENT_ID: &str = "fontless@lab-0"; + +impl TextLayoutOracle for StubTextLayoutOracle { + fn layout(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc { + let measured_lines = layout_text_payload(text, max_width); + let mut cursor = 0usize; + let mut top = 0.0; + let mut lines = Vec::with_capacity(measured_lines.len()); + + for (index, line) in measured_lines.into_iter().enumerate() { + if index > 0 { + if text.text.as_bytes().get(cursor) == Some(&b'\n') { + cursor += 1; + } else if !line.text.starts_with(' ') { + // The deterministic stub discards only ASCII separator + // spaces that would otherwise trail a soft-wrapped line. + while text.text.as_bytes().get(cursor) == Some(&b' ') { + cursor += 1; + } + } + } + + debug_assert!( + text.text[cursor..].starts_with(&line.text), + "stub line must remain a contiguous source slice" + ); + let start = cursor; + cursor += line.text.len(); + let end = cursor; + let ascent = line.baseline_y - top; + let descent = line.height - ascent; + lines.push(TextLine { + text: line.text, + byte_range: u32::try_from(start).expect("text offset exceeds u32") + ..u32::try_from(end).expect("text offset exceeds u32"), + source_range: u32::try_from(start).expect("text offset exceeds u32") + ..u32::try_from(end).expect("text offset exceeds u32"), + end: TextLineBreak::Terminal, + left: 0.0, + width: line.width, + top, + height: line.height, + baseline: line.baseline_y, + ascent, + descent, + }); + top += line.height; + } + + for index in 0..lines.len() { + let consumed_end = lines + .get(index + 1) + .map(|next| next.byte_range.start) + .unwrap_or_else(|| u32::try_from(text.text.len()).expect("text exceeds u32")); + lines[index].source_range.end = consumed_end; + lines[index].end = if text + .text + .as_bytes() + .get(lines[index].byte_range.end as usize) + == Some(&b'\n') + { + TextLineBreak::Explicit + } else if index + 1 == lines.len() { + TextLineBreak::Terminal + } else { + TextLineBreak::Soft + }; + } + + let width = lines.iter().map(|line| line.width).fold(0.0, f32::max); + let height = lines.iter().map(|line| line.height).sum(); + Arc::new(TextLayout { + oracle: STUB_TEXT_LAYOUT_ORACLE_ID, + environment: STUB_TEXT_LAYOUT_ENVIRONMENT_ID.to_owned(), + width_constraint: max_width, + assigned_box: RectF { + x: 0.0, + y: 0.0, + w: width, + h: height, + }, + width, + height, + lines, + glyph_runs: Vec::new(), + logical_bounds: Some(RectF { + x: 0.0, + y: 0.0, + w: width, + h: height, + }), + ink_bounds: None, + unresolved_glyphs: 0, + }) + } +} diff --git a/model-v2/a/lab/src/textir.rs b/model-v2/a/lab/src/textir.rs new file mode 100644 index 0000000000..c9a9174c72 --- /dev/null +++ b/model-v2/a/lab/src/textir.rs @@ -0,0 +1,3284 @@ +//! 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 crate::path; +use crate::renderability; +use quick_xml::events::attributes::Attributes; +use quick_xml::events::{BytesDecl, BytesStart, Event}; +use quick_xml::Reader; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::sync::Arc; + +#[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_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 == '-' || 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_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 { + 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) +} + +/// Parse one axis of Draft 0 corner radii. The deliberately small grammar is +/// either one value for every corner or four explicit values in TL, TR, BR, +/// BL order; CSS's two- and three-value shorthands are not aliases. +fn parse_corner_axis(s: &str, what: &str) -> 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 { + return err(format!("{what} must be greater than zero")); + } + Ok(value) +} + +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; + } + 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"); + } + 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_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)?; + let paint = 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}>")), + }?; + renderability::validate_paint(&paint).map_err(|error| ParseError(error.to_string()))?; + Ok(paint) +} + +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)) => { + paints.push(parse_paint_element(reader, &el, true, channel_tag)?); + } + Ok(Event::Start(el)) => { + 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(); + 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 supports_fill(payload: &Payload) -> bool { + matches!( + payload, + Payload::Frame { .. } | Payload::Text { .. } | Payload::AttributedText { .. } + ) || matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse | ShapeDesc::Path(_) + } + ) +} + +fn path_aware_kind_name(payload: &Payload) -> &'static str { + if matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Path(_) + } + ) { + "path" + } else { + payload.kind_name() + } +} + +fn grida_xml_default_fills(payload: &Payload) -> Paints { + match payload { + Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse | ShapeDesc::Path(_), + } + | Payload::Text { .. } + | Payload::AttributedText { .. } => 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_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 <{}>", + path_aware_kind_name(payload) + )); + } + 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(|| { + ParseError(format!( + " is not valid on <{}>", + path_aware_kind_name(payload) + )) + })?; + stroke.paints = paints; + + if let Some(value) = attributes.remove("width") { + stroke.width = parse_stroke_width(&value, payload)?; + } + if let Some(value) = attributes.remove("align") { + stroke.align = parse_stroke_align(&value)?; + } + + let is_line = matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Line + } + ); + let path = match payload { + Payload::Shape { + desc: ShapeDesc::Path(path), + } => Some(path), + _ => None, + }; + let supports_join = matches!( + payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect + } + ) || path.is_some(); + let supports_dash = matches!( + payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect | ShapeDesc::Ellipse | ShapeDesc::Line | ShapeDesc::Path(_) + } + ); + + if let Some(value) = attributes.remove("cap") { + if !is_line && path.is_none() { + return err("stroke attribute `cap` is valid only on and "); + } + stroke.cap = parse_stroke_cap(&value)?; + } + if let Some(value) = attributes.remove("join") { + if !supports_join { + 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 !supports_join { + 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\""); + } + if path.is_some_and(|path| !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", + ); + } + 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, + fill_seen: bool, + stroke_seen: bool, + legacy_fill_seen: bool, + content_started: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Dialect { + TextIr, + GridaXml, +} + +pub fn parse(input: &str) -> Result { + 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_dialect(input, Dialect::GridaXml) +} + +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(!grida_xml); + + 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 grida_xml { + // `.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() { + 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(); + + if grida_xml && 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 grida_xml && (!envelope_open || envelope_closed) { + return err(format!("<{tag}> must be inside ")); + } + 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!( + "<{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 parent_node = nodes.get(&parent_id).unwrap(); + let payload = parent_node.payload.clone(); + let corner_smoothing = parent_node.corner_smoothing; + + // 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 !supports_fill(&payload) { + 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, corner_smoothing, 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 grida_xml && matches!(tag.as_str(), "fills" | "strokes") { + return err(format!( + "plural <{tag}> is not Draft 0; use singular <{}>", + if tag == "fills" { "fill" } else { "stroke" } + )); + } + if grida_xml && is_legacy_gradient_tag(&tag) { + return err( + "kind-specific gradient tags are not Draft 0; use ", + ); + } + 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", + ); + } + return err(format!( + "<{tag}> is a paint and must be a direct child of or " + )); + } + if grida_xml && tag == "stop" { + return err(" must be a direct child of a gradient paint"); + } + if grida_xml && tag == "frame" { + return err(" belongs to historical textir; use "); + } + if grida_xml && tag == "shape" { + return err( + " is reserved in Draft 0; use , , , or ", + ); + } + 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| { + !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", + parent.tag + )); + } + if let Some(parent) = stack.last_mut() { + parent.content_started = true; + } + + 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)), + (Dialect::GridaXml, "path") => ("path", None), + _ => (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 path_d: Option = None; + let mut path_fill_rule = FillRule::NonZero; + 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; + 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 corner_radius_seen = false; + let mut corner_smoothing_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 mut path_only_attr: Option = None; + let strict = grida_xml; + + let mut source_attributes = el.attributes(); + source_attributes.with_checks(false); + let mut seen_source_attributes = BTreeSet::new(); + for attr in source_attributes { + let attr = attr.map_err(|e| ParseError(format!("attr: {e}")))?; + let key = String::from_utf8_lossy(attr.key.as_ref()).to_string(); + if !seen_source_attributes.insert(key.clone()) { + return err(format!("duplicate `{key}` on <{tag}>")); + } + 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" => { + x_seen = true; + header.x = parse_binding(&val, "x")?; + } + "y" => { + y_seen = true; + header.y = parse_binding(&val, "y")?; + } + "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 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 !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 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 !grida_xml => { + header.min_width = Some(parse_non_negative(&val, "min-w", false)?) + } + "min-width" if grida_xml => { + box_constraint_seen = true; + header.min_width = Some(parse_non_negative(&val, "min-width", true)?) + } + "max-w" if !grida_xml => { + header.max_width = Some(parse_non_negative(&val, "max-w", false)?) + } + "max-width" if grida_xml => { + box_constraint_seen = true; + header.max_width = Some(parse_non_negative(&val, "max-width", true)?) + } + "min-h" if !grida_xml => { + header.min_height = Some(parse_non_negative(&val, "min-h", false)?) + } + "min-height" if grida_xml => { + box_constraint_seen = true; + header.min_height = Some(parse_non_negative(&val, "min-height", true)?) + } + "max-h" if !grida_xml => { + header.max_height = Some(parse_non_negative(&val, "max-h", false)?) + } + "max-height" if grida_xml => { + box_constraint_seen = true; + header.max_height = Some(parse_non_negative(&val, "max-height", true)?) + } + "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 grida_xml => { + 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)?, + )); + } + "corner-radius" if grida_xml => { + 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)?, + "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" => { + 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, + "end" => SelfAlign::End, + "stretch" => SelfAlign::Stretch, + _ => return err(format!("bad align `{val}`")), + } + } + "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, + _ => return err(format!("bad layout `{val}`")), + } + } + "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" => { + 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, + "end" => MainAlign::End, + "space-between" => MainAlign::SpaceBetween, + "space-around" => MainAlign::SpaceAround, + "space-evenly" => MainAlign::SpaceEvenly, + _ => return err(format!("bad main `{val}`")), + } + } + "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, + "end" => CrossAlign::End, + "stretch" => CrossAlign::Stretch, + _ => return err(format!("bad cross `{val}`")), + } + } + "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_non_negative(g, "gap", strict)?; + layout.gap_cross = layout.gap_main; + } + [m, c] => { + 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_non_negative(p, "padding", strict)) + .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" => { + frame_only_attr.get_or_insert_with(|| key.clone()); + clips = parse_bool(&val, "clips", strict)?; + } + "kind" if !grida_xml => { + shape_only_attr.get_or_insert_with(|| key.clone()); + shape_kind = Some(match val.as_str() { + "rect" => ShapeDesc::Rect, + "ellipse" => ShapeDesc::Ellipse, + "line" => ShapeDesc::Line, + _ => return err(format!("bad shape kind `{val}`")), + }) + } + "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_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()); + lens_ops = parse_lens_ops(&val, strict)?; + } + "d" if grida_xml => { + path_only_attr.get_or_insert_with(|| key.clone()); + path_d = Some(val); + } + "fill-rule" if grida_xml => { + path_only_attr.get_or_insert_with(|| key.clone()); + path_fill_rule = match val.as_str() { + "nonzero" => FillRule::NonZero, + "evenodd" => FillRule::EvenOdd, + _ => { + return err(format!( + "fill-rule on must be `nonzero` or `evenodd`, found `{val}`" + )) + } + }; + } + "fill" => { + let fillable = matches!(node_tag, "frame" | "text") + || node_tag == "path" + || matches!( + shape_kind.as_ref(), + 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}>")), + } + } + + if grida_xml { + 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 node_tag != "path" { + if let Some(attr) = path_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 && !matches!(node_tag, "shape" | "path") { + return err( + "aspect-ratio is only valid on and , or on ", + ); + } + let supports_corners = + node_tag == "frame" || matches!(shape_kind.as_ref(), 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 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()) + { + 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 matches!(node_tag, "shape" | "path") { + match shape_kind.as_ref() { + 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"); + } + } + Some(ShapeDesc::Path(_)) => unreachable!("path is analyzed below"), + None if node_tag == "path" => { + 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( + " requires both axes supplied by a fixed size or Span, or exactly one supplied axis plus 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 + // legal for frames. + Payload::Frame { + layout, + clips_content: clips, + } + } + "shape" => { + let desc = shape_kind + .take() + .ok_or_else(|| ParseError(" requires kind".into()))?; + if matches!(desc, ShapeDesc::Line) { + header.height = SizeIntent::Fixed(0.0); // §3.2 locked + } + Payload::Shape { desc } + } + "path" => { + let d = path_d.ok_or_else(|| ParseError(" requires `d`".into()))?; + let artifact = path::analyze(d, path_fill_rule) + .map_err(|error| ParseError(format!(" d: {error}")))?; + Payload::Shape { + desc: ShapeDesc::Path(artifact), + } + } + "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}>")), + }; + renderability::validate_geometry(&header, &payload) + .map_err(|error| ParseError(error.to_string()))?; + + 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 grida_xml => grida_xml_default_fills(&payload), + None => Paints::default(), + }; + let node = Node { + id, + header, + payload, + children: vec![], + corner_radius, + corner_smoothing, + 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 grida_xml { + 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 = node_tag == "text"; + stack.push(Pending { + id, + tag, + text_content: String::new(), + text_segments: vec![], + default_text_style: is_text.then_some(TextStyleRec { + font_size, + font_weight, + font_style_italic, + }), + is_text, + 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, None)?; + } + } + Ok(Event::Text(t)) => { + let txt = t.unescape().map_err(|e| ParseError(format!("text: {e}")))?; + if grida_xml && !declaration_seen && !txt.is_empty() { + pre_declaration_content = true; + } + if let Some(p) = stack.last_mut() { + 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 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 grida_xml && 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::Decl(decl)) if grida_xml => { + 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 grida_xml => { + if !declaration_seen { + pre_declaration_content = true; + } + } + Ok(Event::CData(_)) if grida_xml => { + return err("CDATA is not supported; use escaped text content"); + } + Ok(Event::PI(_) | Event::DocType(_)) if grida_xml => { + return err("processing instructions and doctypes are not supported"); + } + Ok(_) => {} + } + } + + if !stack.is_empty() { + return err("unclosed elements"); + } + if grida_xml { + 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, + 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 mut p = stack + .pop() + .ok_or_else(|| ParseError("unbalanced end".into()))?; + if p.is_text { + 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(()) +} + +// --------------------------------------------------------------------------- +// 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_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}") +} + +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 escaped = val + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">"); + let _ = write!(out, " {key}=\"{escaped}\""); +} + +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)] +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, Dialect::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, Dialect::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 !supports_fill(&node.payload) && !node.fills.is_empty() { + return Err(format!( + "<{}> cannot carry fills", + path_aware_kind_name(&node.payload) + )); + } + 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 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 + .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_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", + path_aware_kind_name(&node.payload) + )); + } + 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 + )); + } + Ok(()) +} + +fn validate_path_box_for_write(node: &Node) -> Result<(), String> { + if !matches!( + &node.payload, + Payload::Shape { + desc: ShapeDesc::Path(_) + } + ) { + return Ok(()); + } + + let validate_size = |intent: SizeIntent, axis: &str| match intent { + SizeIntent::Auto => Ok(()), + SizeIntent::Fixed(value) if value.is_finite() && value >= 0.0 => Ok(()), + SizeIntent::Fixed(_) => Err(format!( + " {axis} must be a finite non-negative number" + )), + }; + validate_size(node.header.width, "width")?; + validate_size(node.header.height, "height")?; + + let validate_binding = |binding: AxisBinding, axis: &str| { + let finite = match binding { + AxisBinding::Pin { offset, .. } => offset.is_finite(), + AxisBinding::Span { start, end } => start.is_finite() && end.is_finite(), + }; + if finite { + Ok(()) + } else { + Err(format!(" {axis} binding must contain finite numbers")) + } + }; + validate_binding(node.header.x, "x")?; + validate_binding(node.header.y, "y")?; + + for (name, value) in [ + ("min-width", node.header.min_width), + ("max-width", node.header.max_width), + ("min-height", node.header.min_height), + ("max-height", node.header.max_height), + ] { + if value.is_some_and(|value| !value.is_finite() || value < 0.0) { + return Err(format!( + " {name} must be a finite non-negative number" + )); + } + } + + if matches!(node.header.x, AxisBinding::Span { .. }) + && (matches!(node.header.width, SizeIntent::Fixed(_)) + || node.header.min_width.is_some() + || node.header.max_width.is_some()) + { + return Err("a span x binding cannot also declare width/min-width/max-width".into()); + } + if matches!(node.header.y, AxisBinding::Span { .. }) + && (matches!(node.header.height, SizeIntent::Fixed(_)) + || node.header.min_height.is_some() + || node.header.max_height.is_some()) + { + return Err("a span y binding cannot also declare height/min-height/max-height".into()); + } + + let has_aspect = match node.header.aspect_ratio { + None => false, + Some((a, b)) if a.is_finite() && b.is_finite() && a > 0.0 && b > 0.0 => true, + Some(_) => { + return Err(" aspect-ratio terms must be finite and greater than zero".into()) + } + }; + let width_supplied = matches!(node.header.width, SizeIntent::Fixed(_)) + || matches!(node.header.x, AxisBinding::Span { .. }); + let height_supplied = matches!(node.header.height, SizeIntent::Fixed(_)) + || matches!(node.header.y, AxisBinding::Span { .. }); + if !matches!( + (width_supplied, height_supplied, has_aspect), + (true, true, false) | (true, false, true) | (false, true, true) + ) { + return Err( + " requires both axes supplied by a fixed size or Span, or exactly one supplied axis plus aspect-ratio" + .into(), + ); + } + Ok(()) +} + +pub(crate) fn validate_path_for_write(node: &Node) -> Result<(), String> { + let Payload::Shape { + desc: ShapeDesc::Path(artifact), + } = &node.payload + else { + return Ok(()); + }; + validate_path_box_for_write(node)?; + let validated = path::analyze(Arc::clone(&artifact.d), artifact.fill_rule) + .map_err(|error| format!("node {} has invalid path data: {error}", node.id))?; + // Reanalysis starts from the same authored d and must reproduce every + // derived field consumed by bounds, damage, and paint. + if validated.as_ref() != artifact.as_ref() { + return Err(format!( + "node {} has a path artifact inconsistent with its authored d", + node.id + )); + } + Ok(()) +} + +fn push_common_paint_attrs( + out: &mut String, + active: bool, + opacity: f32, + blend_mode: BlendMode, + _tag: &str, +) -> Result<(), String> { + 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 push_transform_attr( + out: &mut String, + transform: crate::math::Affine, + _tag: &str, +) -> Result<(), String> { + 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> { + let indent = " ".repeat(depth); + for stop in stops { + let _ = write!(out, "{indent}"); + } + 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> { + renderability::validate_paint(paint).map_err(|error| error.to_string())?; + let indent = " ".repeat(depth); + match paint { + Paint::Solid(solid) => { + let _ = write!(out, "{indent}"); + } + paint @ (Paint::LinearGradient(_) + | Paint::RadialGradient(_) + | Paint::SweepGradient(_) + | Paint::DiamondGradient(_)) => { + write_gradient(paint, depth, out)?; + } + 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()); + } + }; + let fit = match image.fit { + ImagePaintFit::Fit(fit) => fit, + ImagePaintFit::Transform(_) | ImagePaintFit::Tile(_) => { + unreachable!("render capability validation accepts only named image fits") + } + }; + 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 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() + } else { + values.iter().chain(values).copied().collect() + } +} + +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, + corner_smoothing: CornerSmoothing, + depth: usize, + inline: bool, + out: &mut String, +) -> Result<(), String> { + renderability::validate_stroke(stroke, payload, corner_smoothing) + .map_err(|error| error.to_string())?; + 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, " 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()); + } + if matches!( + payload, + Payload::Shape { + desc: ShapeDesc::Line + } | Payload::Shape { + desc: ShapeDesc::Path(_) + } + ) && stroke.cap != default.cap + { + push_attr(out, "cap", stroke.cap.as_str()); + } + if matches!( + payload, + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect + } + | Payload::Shape { + desc: ShapeDesc::Path(_) + } + ) { + if stroke.join != default.join { + push_attr(out, "join", stroke.join.as_str()); + } + if stroke.miter_limit != default.miter_limit { + push_attr(out, "miter-limit", &fmt_num(stroke.miter_limit)); + } + } + if let Some(values) = &stroke.dash_array { + let value = normalized_dash_array(values) + .iter() + .map(|value| fmt_num(*value)) + .collect::>() + .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, + dialect: Dialect, + out: &mut String, +) -> Result<(), String> { + let node = doc.get(id); + let indent = " ".repeat(depth); + let grida_xml = dialect == Dialect::GridaXml; + if !grida_xml + && matches!( + &node.payload, + Payload::Shape { + desc: ShapeDesc::Path(_) + } + ) + { + return Err(format!( + "node {} is a path historical E3 TextIr cannot represent", + node.id + )); + } + let tag = match (dialect, &node.payload) { + (Dialect::GridaXml, Payload::Frame { .. }) => "container", + ( + Dialect::GridaXml, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ) => "rect", + ( + Dialect::GridaXml, + Payload::Shape { + desc: ShapeDesc::Ellipse, + }, + ) => "ellipse", + ( + Dialect::GridaXml, + Payload::Shape { + desc: ShapeDesc::Line, + }, + ) => "line", + ( + Dialect::GridaXml, + Payload::Shape { + desc: ShapeDesc::Path(_), + }, + ) => "path", + _ => 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) = + if grida_xml { + ( + "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 { + 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, width_attr, &fmt_num(v)), + SizeIntent::Auto => { + if matches!(node.payload, Payload::Frame { .. }) + && !matches!(node.header.x, AxisBinding::Span { .. }) + { + push_attr(out, width_attr, "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, height_attr, &fmt_num(v)); + } + } + SizeIntent::Auto => { + if matches!(node.payload, Payload::Frame { .. }) + && !matches!(node.header.y, AxisBinding::Span { .. }) + { + push_attr(out, height_attr, "auto"); + } + } + } + } + if let Some(v) = node.header.min_width { + push_attr(out, min_width_attr, &fmt_num(v)); + } + if let Some(v) = node.header.max_width { + push_attr(out, max_width_attr, &fmt_num(v)); + } + if let Some(v) = node.header.min_height { + push_attr(out, min_height_attr, &fmt_num(v)); + } + if let Some(v) = node.header.max_height { + push_attr(out, max_height_attr, &fmt_num(v)); + } + 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)); + } + 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 } => { + if let ShapeDesc::Path(artifact) = desc { + debug_assert!(grida_xml, "historical path rejected above"); + push_attr(out, "d", artifact.d.as_ref()); + if artifact.fill_rule == FillRule::EvenOdd { + push_attr(out, "fill-rule", "evenodd"); + } + } else if !grida_xml { + let kind = match desc { + ShapeDesc::Rect => "rect", + ShapeDesc::Ellipse => "ellipse", + ShapeDesc::Line => "line", + ShapeDesc::Path(_) => unreachable!(), + }; + push_attr(out, "kind", kind); + } + } + 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, + 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 } => { + 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(" ")); + } + } + let fill_emission = match dialect { + Dialect::TextIr => { + if let Some(fill) = historical_fill(node)? { + push_attr(out, "fill", &fill.to_hex()); + } + FillEmission::Omit + } + 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 dialect { + Dialect::TextIr => vec![], + Dialect::GridaXml => { + for stroke in &node.strokes { + renderability::validate_stroke(stroke, &node.payload, node.corner_smoothing) + .map_err(|error| error.to_string())?; + } + 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 = node.payload.as_text().is_some(); + if is_text { + let _ = write!(out, ">"); + if has_fill_child { + write_fill(fill_emission, depth + 1, true, out)?; + } + for stroke in strokes { + write_stroke( + stroke, + &node.payload, + node.corner_smoothing, + depth + 1, + true, + out, + )?; + } + 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 { + let _ = writeln!(out, ">"); + if has_fill_child { + write_fill(fill_emission, depth + 1, false, out)?; + } + for stroke in strokes { + write_stroke( + stroke, + &node.payload, + node.corner_smoothing, + depth + 1, + false, + out, + )?; + } + for c in &node.children { + print_node(doc, *c, depth + 1, dialect, out)?; + } + let _ = writeln!(out, "{indent}"); + } + Ok(()) +} 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/arena_pick.rs b/model-v2/a/lab/tests/arena_pick.rs new file mode 100644 index 0000000000..982ccdecde --- /dev/null +++ b/model-v2/a/lab/tests/arena_pick.rs @@ -0,0 +1,250 @@ +//! 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(&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(&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(&r, 175.0, 175.0), Some(over)); + // non-overlapped part of the lower one still hits it + assert_eq!(pick(&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(&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(&r, 330.0, 330.0), Some(doc.root)); + // ...the POST-ops position hits, and promotes to the lens + assert_eq!(pick(&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(&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(&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(&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/common/mod.rs b/model-v2/a/lab/tests/common/mod.rs new file mode 100644 index 0000000000..b18d184d5f --- /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, ResolveOptions, Resolved, 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/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/derived.rs b/model-v2/a/lab/tests/derived.rs new file mode 100644 index 0000000000..b0d7a02b19 --- /dev/null +++ b/model-v2/a/lab/tests/derived.rs @@ -0,0 +1,177 @@ +//! 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/draft0_resolution.rs b/model-v2/a/lab/tests/draft0_resolution.rs new file mode 100644 index 0000000000..147ce467e1 --- /dev/null +++ b/model-v2/a/lab/tests/draft0_resolution.rs @@ -0,0 +1,430 @@ +//! 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 = StrokeWidth::Uniform(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 = StrokeWidth::Uniform(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 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.clone(), 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); + 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 fontless_stub_text_does_not_invent_glyph_or_stroke_ink() { + 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 = StrokeWidth::Uniform(2.0); + stroke.align = StrokeAlign::Center; + builder.node_mut(text).strokes.push(stroke); + + let resolved = run(&builder.build()); + assert_rect( + resolved.aabb_of(text), + 20.0, + 20.0, + 0.0, + 0.0, + "a glyphless layout has no outline for its text stroke to cover", + ); +} + +#[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 = StrokeWidth::None; + 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/edge_census.rs b/model-v2/a/lab/tests/edge_census.rs new file mode 100644 index 0000000000..57e4b0bf53 --- /dev/null +++ b/model-v2/a/lab/tests/edge_census.rs @@ -0,0 +1,578 @@ +//! 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..676968c406 --- /dev/null +++ b/model-v2/a/lab/tests/escape.rs @@ -0,0 +1,128 @@ +//! 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..f1c6f785a3 --- /dev/null +++ b/model-v2/a/lab/tests/flip.rs @@ -0,0 +1,354 @@ +//! 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/generations.rs b/model-v2/a/lab/tests/generations.rs new file mode 100644 index 0000000000..20d423394c --- /dev/null +++ b/model-v2/a/lab/tests/generations.rs @@ -0,0 +1,91 @@ +//! 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(); + 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/geometry.rs b/model-v2/a/lab/tests/geometry.rs new file mode 100644 index 0000000000..91be0b8de1 --- /dev/null +++ b/model-v2/a/lab/tests/geometry.rs @@ -0,0 +1,175 @@ +//! 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/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_paints.rs b/model-v2/a/lab/tests/grida_xml_paints.rs new file mode 100644 index 0000000000..8edb2ee5a6 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_paints.rs @@ -0,0 +1,587 @@ +//! 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"), + ("", "farther apart"), + ("", "farther apart"), + ("", "farther apart"), + ("", "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 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 [ + (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("farther apart") + )); +} + +#[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_paths.rs b/model-v2/a/lab/tests/grida_xml_paths.rs new file mode 100644 index 0000000000..2ae5f738a8 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_paths.rs @@ -0,0 +1,603 @@ +use std::sync::Arc; + +use anchor_lab::grida_xml; +use anchor_lab::math::RectF; +use anchor_lab::model::{ + AxisBinding, Color, FillRule, Paints, Payload, ShapeDesc, SizeIntent, StrokeAlign, StrokeCap, + StrokeJoin, +}; +use anchor_lab::path::{self, PathCommand}; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use anchor_lab::svgout::{self, SvgOptions}; +use anchor_lab::textir; + +fn source(path: &str) -> String { + format!( + "{path}" + ) +} + +fn only_path(document: &anchor_lab::model::Document) -> anchor_lab::model::NodeId { + let render_root = document.get(document.root).children[0]; + document.get(render_root).children[0] +} + +#[test] +fn path_is_a_boxed_unit_reference_shape_with_one_shared_artifact() { + let d = "M 0 1 L .5 0 L 1 1 Z"; + let document = grida_xml::parse(&source(&format!( + "" + ))) + .unwrap(); + let id = only_path(&document); + let node = document.get(id); + let Payload::Shape { + desc: ShapeDesc::Path(artifact), + } = &node.payload + else { + panic!("expected path shape"); + }; + assert_eq!(artifact.d.as_ref(), d); + assert_eq!(artifact.fill_rule, FillRule::NonZero); + assert!(artifact.all_contours_closed); + assert_eq!( + artifact.unit_bounds, + RectF { + x: 0.0, + y: 0.0, + w: 1.0, + h: 1.0 + } + ); + assert_eq!(node.fills, Paints::solid(Color::BLACK)); + + let resolved = resolve(&document, &ResolveOptions::default()); + let resolved_path = resolved.resolved_path_of(id); + assert!(Arc::ptr_eq(artifact, &resolved_path.source)); + assert!(matches!( + resolved_path.commands[0], + PathCommand::MoveTo { x: 0.0, y: 100.0 } + )); + assert_eq!( + resolved.aabb_of(id), + RectF { + x: 10.0, + y: 20.0, + w: 200.0, + h: 100.0, + } + ); + assert!(resolved.resolved_path_opt(document.root).is_none()); +} + +#[test] +fn tight_path_geometry_not_the_layout_box_starts_visual_bounds() { + let document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let id = only_path(&document); + let resolved = resolve(&document, &ResolveOptions::default()); + assert_eq!(resolved.xywh(id), (10.0, 20.0, 200.0, 100.0)); + assert_eq!( + resolved.aabb_of(id), + RectF { + x: 60.0, + y: 45.0, + w: 100.0, + h: 50.0, + } + ); +} + +#[test] +fn path_miter_strokes_expand_bounds_conservatively() { + let document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let id = only_path(&document); + let bounds = resolve(&document, &ResolveOptions::default()).aabb_of(id); + assert_eq!( + bounds, + RectF { + x: 17.0, + y: 17.0, + w: 66.0, + h: 66.0, + } + ); +} + +#[test] +fn evenodd_and_source_spelling_round_trip_canonically() { + let d = "m 0 0 l 1 0 0 1 -1 0 z"; + let document = grida_xml::parse(&source(&format!( + "" + ))) + .unwrap(); + let printed = grida_xml::print(&document).unwrap(); + assert!(printed.contains(&format!("d=\"{d}\""))); + assert!(printed.contains("fill-rule=\"evenodd\"")); + let reparsed = grida_xml::parse(&printed).unwrap(); + let Payload::Shape { + desc: ShapeDesc::Path(path), + } = &reparsed.get(only_path(&reparsed)).payload + else { + panic!("expected path"); + }; + assert_eq!(path.fill_rule, FillRule::EvenOdd); + assert_eq!(path.d.as_ref(), d); + assert!(textir::try_print(&document) + .unwrap_err() + .to_string() + .contains("historical E3 TextIr cannot represent")); +} + +#[test] +fn equivalent_path_spellings_share_visual_identity() { + let absolute = path::analyze("M 0 0 L 1 0 L 1 1 Z", FillRule::NonZero).unwrap(); + let relative = path::analyze("m0 0h1v1z", FillRule::NonZero).unwrap(); + assert_ne!(absolute.d, relative.d); + assert_ne!( + absolute, relative, + "source-tier equality retains authored d" + ); + assert!(absolute.same_visual_geometry(&relative)); + + let evenodd = path::analyze("m0 0h1v1z", FillRule::EvenOdd).unwrap(); + assert!(!absolute.same_visual_geometry(&evenodd)); + + let arc_absolute = path::analyze("M .5 0 A .5 .5 0 0 1 1 .5", FillRule::NonZero).unwrap(); + let arc_relative = path::analyze("m .5 0 a .5 .5 360 0 1 .5 .5", FillRule::NonZero).unwrap(); + assert_ne!(arc_absolute.d, arc_relative.d); + assert_ne!(arc_absolute, arc_relative); + assert!(arc_absolute.same_visual_geometry(&arc_relative)); +} + +#[test] +fn curves_use_realized_extrema_and_arcs_become_bounded_rational_conics() { + // The quadratic control point is outside the unit box, but the realized + // curve only touches y=0 and is therefore valid. + let quadratic = path::analyze("M 0 1 Q .5 -1 1 1", FillRule::NonZero).unwrap(); + assert_eq!( + quadratic.unit_bounds, + RectF { + x: 0.0, + y: 0.0, + w: 1.0, + h: 1.0, + } + ); + let overshoot = path::analyze("M 0 1 Q .5 -1.1 1 1", FillRule::NonZero) + .unwrap_err() + .to_string(); + assert!(overshoot.contains("unit reference box"), "{overshoot}"); + + let circle = path::analyze( + "M .5 0 A .5 .5 0 0 1 1 .5 A .5 .5 0 0 1 .5 1 A .5 .5 0 0 1 0 .5 A .5 .5 0 0 1 .5 0 Z", + FillRule::NonZero, + ) + .unwrap(); + let conics: Vec<_> = circle + .commands + .iter() + .filter_map(|command| match command { + PathCommand::ConicTo { + x1, + y1, + x, + y, + weight, + } => Some((*x1, *y1, *x, *y, *weight)), + _ => None, + }) + .collect(); + assert_eq!(conics.len(), 4); + assert_eq!(conics[0].0, 1.0); + assert_eq!(conics[0].1, 0.0); + assert_eq!(conics[0].2, 1.0); + assert_eq!(conics[0].3, 0.5); + for (_, _, _, _, weight) in conics { + assert!((weight - std::f32::consts::FRAC_1_SQRT_2).abs() < 1.0e-7); + } + assert!(circle.all_contours_closed); + assert_eq!( + circle.unit_bounds, + RectF { + x: 0.0, + y: 0.0, + w: 1.0, + h: 1.0 + } + ); + for rotation in [1, 89, 91, 180, 270, 359] { + let rotated_circle = path::analyze( + format!( + "M .5 0 A .5 .5 {rotation} 0 1 1 .5 A .5 .5 {rotation} 0 1 .5 1 A .5 .5 {rotation} 0 1 0 .5 A .5 .5 {rotation} 0 1 .5 0 Z" + ), + FillRule::NonZero, + ) + .unwrap(); + assert!( + rotated_circle.same_visual_geometry(&circle), + "circle rotation {rotation}" + ); + } + + let rotated_ellipse = path::analyze( + "M .5 0 A .5 .25 90 0 1 .75 .5 A .5 .25 90 0 1 .5 1 A .5 .25 90 0 1 .25 .5 A .5 .25 90 0 1 .5 0 Z", + FillRule::NonZero, + ) + .unwrap(); + assert_eq!( + rotated_ellipse.unit_bounds, + RectF { + x: 0.25, + y: 0.0, + w: 0.5, + h: 1.0, + } + ); + assert_eq!( + rotated_ellipse + .commands + .iter() + .filter(|command| matches!(command, PathCommand::ConicTo { .. })) + .count(), + 4 + ); + + let tiny = path::analyze( + "M .5 .5 A .000001 .000001 0 0 1 .500001 .500001", + FillRule::NonZero, + ) + .unwrap(); + assert!(matches!(tiny.commands[1], PathCommand::ConicTo { .. })); + + let extreme_large_arc = path::analyze("M 0 0 A 1e-20 1e-20 37 1 1 1e-45 0", FillRule::NonZero) + .unwrap_err() + .to_string(); + assert!( + extreme_large_arc.contains("unit reference box"), + "{extreme_large_arc}" + ); +} + +#[test] +fn path_reports_focused_source_errors() { + let cases = [ + ("", "requires `d`"), + ( + "", + "must not be empty", + ), + ( + "", + "invalid SVG path data", + ), + ( + "", + "unit reference box", + ), + ( + "", + "unit reference box", + ), + ( + "", + "must be `nonzero` or `evenodd`", + ), + ( + "", + "duplicate `fill-rule`", + ), + ( + "", + "requires both axes", + ), + ]; + for (path, expected) in cases { + let error = grida_xml::parse(&source(path)).unwrap_err().to_string(); + assert!( + error.contains(expected), + "{error:?} did not contain {expected:?}" + ); + } +} + +#[test] +fn move_only_contours_and_coincident_arcs_do_not_invent_geometry_or_bounds() { + let path = path::analyze("M 1 1 M 0 0 L 1 1", FillRule::NonZero).unwrap(); + assert_eq!( + path.unit_bounds, + RectF { + x: 0.0, + y: 0.0, + w: 1.0, + h: 1.0 + } + ); + + let error = path::analyze("M .5 .5 A .2 .2 0 0 1 .5 .5", FillRule::NonZero) + .unwrap_err() + .to_string(); + assert!(error.contains("at least one drawing segment"), "{error}"); +} + +#[test] +fn open_and_closed_path_stroke_contracts_are_explicit() { + let open_error = grida_xml::parse(&source( + "", + )) + .unwrap_err() + .to_string(); + assert!(open_error.contains("every drawable contour is explicitly closed")); + + let document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let stroke = &document.get(only_path(&document)).strokes[0]; + assert_eq!(stroke.align, StrokeAlign::Outside); + assert_eq!(stroke.cap, StrokeCap::Round); + assert_eq!(stroke.join, StrokeJoin::Bevel); + assert_eq!(stroke.miter_limit, 7.0); + assert_eq!(stroke.dash_array.as_deref(), Some(&[2.0, 1.0][..])); + + let per_side = grida_xml::parse(&source( + "", + )) + .unwrap_err() + .to_string(); + assert!(per_side.contains("four-value stroke width is valid only")); +} + +#[test] +fn path_accepts_render_children_in_its_declared_local_box() { + let document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let path = only_path(&document); + let child = document.get(path).children[0]; + let resolved = resolve(&document, &ResolveOptions::default()); + assert_eq!(resolved.xywh(path), (0.0, 0.0, 200.0, 100.0)); + assert_eq!(resolved.xywh(child), (50.0, 20.0, 30.0, 10.0)); + assert!(matches!( + resolved.resolved_path_of(path).commands.last(), + Some(PathCommand::Close) + )); +} + +#[test] +fn svg_snapshot_maps_the_unit_path_through_its_resolved_box() { + let document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let resolved = resolve(&document, &ResolveOptions::default()); + let svg = svgout::render( + &document, + &resolved, + &SvgOptions { + show_aabb: false, + width: 400.0, + height: 300.0, + }, + ) + .unwrap(); + assert!(svg.contains( + "" + )); + + let arc_document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let arc_svg = svgout::render( + &arc_document, + &resolve(&arc_document, &ResolveOptions::default()), + &SvgOptions { + show_aabb: false, + width: 400.0, + height: 300.0, + }, + ) + .unwrap(); + assert!(arc_svg.contains("d=\"M .5 0 A .5 .5 0 0 1 1 .5\"")); +} + +#[test] +fn writer_rejects_programmatic_path_box_states_the_reader_cannot_accept() { + let base = grida_xml::parse(&source( + "", + )) + .unwrap(); + let id = only_path(&base); + let rejected = |mut document: anchor_lab::model::Document, + mutate: fn(&mut anchor_lab::model::Header), + expected: &str| { + mutate(&mut document.get_mut(id).header); + let error = grida_xml::print(&document).unwrap_err().to_string(); + assert!( + error.contains(expected), + "{error:?} did not contain {expected:?}" + ); + }; + + rejected( + base.clone(), + |header| { + header.width = SizeIntent::Auto; + header.height = SizeIntent::Auto; + }, + "requires both axes", + ); + rejected( + base.clone(), + |header| header.aspect_ratio = Some((2.0, 1.0)), + "requires both axes", + ); + rejected( + base.clone(), + |header| { + header.x = AxisBinding::Span { + start: 0.0, + end: 0.0, + } + }, + "span x binding", + ); + rejected( + base.clone(), + |header| header.min_width = Some(-1.0), + "min-width must be a finite non-negative", + ); + rejected( + base.clone(), + |header| header.aspect_ratio = Some((0.0, 1.0)), + "aspect-ratio terms", + ); + rejected( + base.clone(), + |header| header.x = AxisBinding::start(f32::NAN), + "x binding must contain finite", + ); + rejected( + base, + |header| header.width = SizeIntent::Fixed(-1.0), + "width must be a finite non-negative", + ); +} + +#[test] +fn programmatic_open_path_alignment_fails_closed_for_paint_and_bounds() { + let mut document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let id = only_path(&document); + document.get_mut(id).strokes[0].align = StrokeAlign::Inside; + let node = document.get(id); + assert!(!node.strokes[0].renderable_for(&node.payload, node.corner_smoothing)); + assert_eq!( + resolve(&document, &ResolveOptions::default()).aabb_of(id), + RectF { + x: 25.0, + y: 25.0, + w: 50.0, + h: 50.0, + } + ); + assert!(grida_xml::print(&document) + .unwrap_err() + .to_string() + .contains("every drawable contour is explicitly closed")); +} + +#[test] +fn unit_and_huge_mapped_bounds_conservatively_contain_stored_commands() { + let line = path::analyze( + "M 0.45802047848701477 0 L 0.9617093205451965 0", + FillRule::NonZero, + ) + .unwrap(); + let PathCommand::LineTo { x: unit_end, .. } = line.commands[1] else { + panic!("expected line endpoint"); + }; + assert!(line.unit_bounds.x + line.unit_bounds.w >= unit_end); + + let mapped = path::materialize(line, 30_270_271_994_254_590_000.0_f32, 1.0).unwrap(); + let PathCommand::LineTo { x: mapped_end, .. } = mapped.commands[1] else { + panic!("expected mapped line endpoint"); + }; + assert!(mapped.local_bounds.x + mapped.local_bounds.w >= mapped_end); + + let circle = path::analyze( + "M .5 0 A .5 .5 0 0 1 1 .5 A .5 .5 0 0 1 .5 1 A .5 .5 0 0 1 0 .5 A .5 .5 0 0 1 .5 0 Z", + FillRule::NonZero, + ) + .unwrap(); + let mapped = path::materialize(circle, 19_384_729_239_552.0, 7_193_847_234_560.0).unwrap(); + let rotated_circle = path::analyze( + "M .5 0 A .5 .5 359 0 1 1 .5 A .5 .5 359 0 1 .5 1 A .5 .5 359 0 1 0 .5 A .5 .5 359 0 1 .5 0 Z", + FillRule::NonZero, + ) + .unwrap(); + let rotated_mapped = + path::materialize(rotated_circle, 19_384_729_239_552.0, 7_193_847_234_560.0).unwrap(); + assert_eq!(rotated_mapped, mapped); + let bounds = mapped.local_bounds; + let left = f64::from(bounds.x); + let top = f64::from(bounds.y); + let right = f64::from(bounds.x + bounds.w); + let bottom = f64::from(bounds.y + bounds.h); + let mut current = (0.0_f32, 0.0_f32); + for command in mapped.commands.iter() { + match *command { + PathCommand::MoveTo { x, y } | PathCommand::LineTo { x, y } => { + current = (x, y); + } + PathCommand::ConicTo { + x1, + y1, + x, + y, + weight, + } => { + for sample in 0..=1024 { + let t = sample as f64 / 1024.0; + let one_minus_t = 1.0 - t; + let a = one_minus_t * one_minus_t; + let b = 2.0 * f64::from(weight) * one_minus_t * t; + let c = t * t; + let denominator = a + b + c; + let px = (a * f64::from(current.0) + b * f64::from(x1) + c * f64::from(x)) + / denominator; + let py = (a * f64::from(current.1) + b * f64::from(y1) + c * f64::from(y)) + / denominator; + assert!(px >= left && px <= right, "x={px} outside {left}..{right}"); + assert!(py >= top && py <= bottom, "y={py} outside {top}..{bottom}"); + } + current = (x, y); + } + PathCommand::Close => {} + PathCommand::QuadTo { .. } | PathCommand::CubicTo { .. } => { + unreachable!("circle arc lowers only to conics") + } + } + } +} + +#[test] +fn resolved_control_overflow_fails_closed_without_losing_children() { + let mut document = grida_xml::parse(&source( + "", + )) + .unwrap(); + let id = only_path(&document); + let child = document.get(id).children[0]; + document.get_mut(id).header.height = SizeIntent::Fixed(f32::MAX); + + let resolved = resolve(&document, &ResolveOptions::default()); + assert!(resolved.resolved_path_opt(id).is_none()); + assert!(resolved.reports.iter().any(|report| matches!( + report, + anchor_lab::resolve::Report::ErrorByRule { + node, + field: "path", + rule: "resolved path coordinates exceed finite f32 geometry", + } if *node == id + ))); + assert_eq!( + resolved.aabb_of(child), + RectF { + x: 0.0, + y: 0.0, + w: 1.0, + h: 1.0, + } + ); + assert_eq!(resolved.aabb_of(id), resolved.aabb_of(child)); +} 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..54a3adf326 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_properties.rs @@ -0,0 +1,366 @@ +//! 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_source.rs b/model-v2/a/lab/tests/grida_xml_source.rs new file mode 100644 index 0000000000..e19b6a17a0 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_source.rs @@ -0,0 +1,575 @@ +//! Producer contract for retained Draft 1 Grida XML source programs. + +use anchor_lab::grida_xml; +use anchor_lab::grida_xml_source::{ + self, ErrorPhase, SourceProvider, SourceSnapshot, SourceVersion, +}; +use anchor_lab::model::{AxisBinding, Paint, Payload, ResourceRef}; +use std::collections::BTreeMap; + +fn snapshot(identity: &str, base: &str, source: &str) -> SourceSnapshot { + SourceSnapshot::new(identity, base, source) +} + +#[derive(Default)] +struct MemoryProvider { + sources: BTreeMap<(String, String), SourceSnapshot>, + requests: Vec<(String, String)>, +} + +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.requests + .push((containing.identity().to_owned(), location.to_owned())); + self.sources + .get(&(containing.identity().to_owned(), location.to_owned())) + .cloned() + .ok_or_else(|| "not found".into()) + } +} + +#[test] +fn immutable_snapshot_and_exact_versioned_source_unit_are_public_outcomes() { + let source = r##" + + + +"##; + let unit = grida_xml_source::parse_source(snapshot("entry", "memory:/", source)).unwrap(); + + assert_eq!(unit.snapshot().identity(), "entry"); + assert_eq!(unit.snapshot().base(), "memory:/"); + assert_eq!(unit.snapshot().source(), source); + assert_eq!(unit.version(), SourceVersion::Draft1); + assert_eq!(unit.component_ids().collect::>(), ["badge"]); + assert!(unit.has_scene()); + + let version2 = grida_xml_source::parse_source(snapshot( + "future", + "memory:/", + r##""##, + )) + .unwrap(); + assert_eq!(version2.version(), SourceVersion::Version2); +} + +#[test] +fn draft1_source_grammar_is_strict_before_linking() { + let cases = [ + ( + r##""##, + "must precede the scene root", + ), + ( + r##""##, + "duplicate component", + ), + ( + r##""##, + "lowercase kebab-case", + ), + ( + r##""##, + "cannot declare x", + ), + ( + r##""##, + "requires an ID fragment", + ), + ( + r##""##, + "cannot contain character data or child elements", + ), + ( + " \n ", + "cannot contain character data", + ), + ( + r##""##, + "unknown attribute `width` on ", + ), + ( + r##""##, + "requires Grida XML Version 2", + ), + ]; + + for (source, expected) in cases { + let error = + grida_xml_source::parse_source(snapshot("bad", "memory:/", source)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse, "{error}"); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn local_uses_materialize_as_independent_boxed_subtrees_in_caller_order() { + let source = r##" + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", source), &mut provider) + .unwrap(); + + assert!( + provider.requests.is_empty(), + "local uses require no provider call" + ); + let document_root = output.document.get(output.document.root); + let scene = document_root.children[0]; + let instances = &output.document.get(scene).children; + assert_eq!(instances.len(), 2); + assert_ne!(instances[0], instances[1]); + for &instance in instances { + assert!(matches!( + output.document.get(instance).payload, + Payload::Frame { .. } + )); + assert_eq!( + output.document.get(instance).header.name.as_deref(), + Some("Definition") + ); + assert_eq!(output.document.get(instance).children.len(), 1); + assert_ne!(output.document.get(instance).children[0], instances[0]); + assert_ne!(output.document.get(instance).children[0], instances[1]); + } + assert_eq!( + output.document.get(instances[0]).header.x, + AxisBinding::start(5.0) + ); + assert_eq!( + output.document.get(instances[1]).header.x, + AxisBinding::start(25.0) + ); + assert!(output.document.get(instances[0]).header.active); + assert!(!output.document.get(instances[1]).header.active); + + let first = output.provenance.get(&instances[0]).unwrap(); + let second = output.provenance.get(&instances[1]).unwrap(); + assert_eq!(first.component.as_ref().unwrap().source, "entry"); + assert_eq!(first.component.as_ref().unwrap().id, "badge"); + assert_eq!(first.use_chain.len(), 1); + assert_eq!(first.use_chain[0].name.as_deref(), Some("first")); + assert_eq!(second.use_chain[0].name.as_deref(), Some("second")); + assert_eq!( + output + .provenance + .get(&output.document.get(instances[0]).children[0]) + .unwrap() + .use_chain, + first.use_chain + ); +} + +#[test] +fn external_uses_load_one_retained_immutable_source_unit() { + let entry = r##" + + + + + + +"##; + let library = r##" + + + + + +"##; + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./library.grida.xml", + snapshot("library", "memory:/library/", library), + ); + + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + assert_eq!(provider.requests.len(), 1, "one lexical edge is cached"); + assert_eq!(output.program.entry(), "entry"); + assert_eq!(output.program.units().len(), 2); + assert_eq!( + output.program.unit("library").unwrap().snapshot().source(), + library + ); + let scene = output.document.get(output.document.root).children[0]; + assert_eq!(output.document.get(scene).children.len(), 2); +} + +#[test] +fn hidden_component_edges_still_participate_in_cycle_detection() { + let source = r##" + + + + + +"##; + let mut provider = MemoryProvider::default(); + let error = grida_xml_source::materialize(snapshot("cycle", "memory:/", source), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Link); + assert!( + error.message.contains("cycle#a -> cycle#b -> cycle#a"), + "{error}" + ); +} + +#[test] +fn equal_relative_image_strings_from_distinct_origins_get_distinct_runtime_rids() { + let entry = r##" + + + + + + +"##; + let component = |color: &str| { + format!( + r##""## + ) + }; + let a = component("#ff0000"); + let b = component("#0000ff"); + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./a.grida.xml", + snapshot("a", "memory:/a/", a.as_str()), + ); + provider.insert( + "entry", + "./b.grida.xml", + snapshot("b", "memory:/b/", b.as_str()), + ); + + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + assert_eq!(output.resources.len(), 2); + assert_eq!(output.resources[0].authored, "./texture.png"); + assert_eq!(output.resources[1].authored, "./texture.png"); + assert_eq!(output.resources[0].source, "a"); + assert_eq!(output.resources[1].source, "b"); + assert_eq!(output.resources[0].base, "memory:/a/"); + assert_eq!(output.resources[1].base, "memory:/b/"); + assert_ne!( + output.resources[0].runtime_rid, + output.resources[1].runtime_rid + ); + + let mut image_rids = vec![]; + for id in 0..output.document.capacity() as u32 { + let Some(node) = output.document.get_opt(id) else { + continue; + }; + for paint in node.fills.iter() { + if let Paint::Image(image) = paint { + let ResourceRef::Rid(rid) = &image.image else { + panic!("source materialization emits runtime RIDs") + }; + image_rids.push(rid.clone()); + } + } + } + image_rids.sort(); + let mut manifest_rids = output + .resources + .iter() + .map(|entry| entry.runtime_rid.clone()) + .collect::>(); + manifest_rids.sort(); + assert_eq!(image_rids, manifest_rids); +} + +#[test] +fn failures_keep_parse_resolution_and_link_phases_distinct() { + let entry = r##""##; + let mut provider = MemoryProvider::default(); + let resolution = + grida_xml_source::materialize(snapshot("entry", "memory:/", entry), &mut provider) + .unwrap_err(); + assert_eq!(resolution.phase, ErrorPhase::Resolve); + assert!(resolution.message.contains("./missing.grida.xml")); + assert_eq!( + resolution.authored_use.as_ref().unwrap().href, + "./missing.grida.xml#x" + ); + + let unknown = r##""##; + let link = grida_xml_source::materialize(snapshot("entry", "memory:/", unknown), &mut provider) + .unwrap_err(); + assert_eq!(link.phase, ErrorPhase::Link); + assert!(link.message.contains("not defined")); + + let future = r##""##; + let direct = + grida_xml_source::parse_source(snapshot("future", "memory:/", future)).unwrap_err(); + assert_eq!(direct.phase, ErrorPhase::Parse); + + let future_entry = + r##""##; + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./future.grida.xml", + snapshot("future", "memory:/future/", future), + ); + let dependency = + grida_xml_source::materialize(snapshot("entry", "memory:/", future_entry), &mut provider) + .unwrap_err(); + assert_eq!(dependency.phase, ErrorPhase::Resolve); + assert_eq!( + dependency.authored_use.as_ref().unwrap().href, + "./future.grida.xml#x" + ); + + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./invalid.grida.xml", + snapshot( + "", + "memory:/invalid/", + r##""##, + ), + ); + let invalid_snapshot = r##""##; + let invalid = grida_xml_source::materialize( + snapshot("entry", "memory:/", invalid_snapshot), + &mut provider, + ) + .unwrap_err(); + assert_eq!(invalid.phase, ErrorPhase::Resolve); + assert!(invalid.message.contains("canonical source identity")); +} + +#[test] +fn canonical_identity_aliases_share_one_unit_and_inconsistent_snapshots_fail() { + let entry = r##" + + + + +"##; + let library = r##""##; + let mut provider = MemoryProvider::default(); + let canonical = snapshot("library", "memory:/library/", library); + provider.insert("entry", "./library.grida.xml", canonical.clone()); + provider.insert("entry", "./alias.grida.xml", canonical); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + assert_eq!(provider.requests.len(), 2); + assert_eq!(output.program.units().len(), 2); + let scene = output.document.get(output.document.root).children[0]; + assert_eq!(output.document.get(scene).children.len(), 2); + + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./library.grida.xml", + snapshot("library", "memory:/library/", library), + ); + provider.insert( + "entry", + "./alias.grida.xml", + snapshot( + "library", + "memory:/different/", + r##""##, + ), + ); + let error = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Resolve); + assert!(error.message.contains("inconsistent bytes or base")); + assert_eq!( + error.authored_use.as_ref().unwrap().href, + "./alias.grida.xml#card" + ); +} + +#[test] +fn image_manifest_covers_strokes_and_text_runs_and_dedupes_repeated_instances() { + let source = r##" + + + + + + run + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", source), &mut provider) + .unwrap(); + assert_eq!( + output.resources.len(), + 2, + "same origin+identifier dedupes across uses" + ); + assert_eq!( + output + .resources + .iter() + .map(|resource| resource.authored.as_str()) + .collect::>(), + ["./stroke.png", "./run.png"] + ); + + let mut stroke_images = 0; + let mut run_images = 0; + for id in 0..output.document.capacity() as u32 { + let Some(node) = output.document.get_opt(id) else { + continue; + }; + stroke_images += node + .strokes + .iter() + .flat_map(|stroke| stroke.paints.iter()) + .filter(|paint| matches!(paint, Paint::Image(_))) + .count(); + if let Payload::AttributedText { + attributed_string, .. + } = &node.payload + { + run_images += attributed_string + .runs + .iter() + .filter_map(|run| run.fills.as_ref()) + .flat_map(|fills| fills.iter()) + .filter(|paint| matches!(paint, Paint::Image(_))) + .count(); + } + } + assert_eq!(stroke_images, 2); + assert_eq!(run_images, 2); + assert_eq!(output.provenance.len() + 1, output.document.len()); + assert!(output + .provenance + .values() + .all(|provenance| { provenance.use_chain.is_empty() || provenance.use_chain.len() == 1 })); +} + +#[test] +fn use_relationship_context_is_validated_during_materialization() { + let source = r##" + + + + +"##; + grida_xml_source::parse_source(snapshot("entry", "memory:/", source)) + .expect("source-local syntax is valid before the target relationship is materialized"); + let mut provider = MemoryProvider::default(); + let error = grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Materialize); + assert!(error + .to_string() + .contains("flow is only valid on a child of a flex container")); + assert_eq!(error.source, "entry"); + assert_eq!(error.component.as_deref(), Some("item")); + assert_eq!(error.use_chain.len(), 1); + assert_eq!(error.use_chain[0].href, "#item"); + assert_eq!(error.use_chain[0].target.source, "entry"); + assert_eq!(error.use_chain[0].target.id, "item"); + + let valid = r##" + + + + + + +"##; + let output = grida_xml_source::materialize( + snapshot("valid", "memory:/", valid), + &mut MemoryProvider::default(), + ) + .expect("omitted flow is the canonical in-flow default"); + let scene = output.document.get(output.document.root).children[0]; + let instance = output.document.get(scene).children[0]; + assert_eq!(output.document.get(instance).header.grow, 1.0); +} + +#[test] +fn draft0_source_materialization_rekeys_images_without_changing_the_draft0_api() { + let source = r##""##; + let ordinary = grida_xml::parse(source).unwrap(); + let original_rect = ordinary + .get(ordinary.get(ordinary.root).children[0]) + .children[0]; + let Paint::Image(original) = &ordinary.get(original_rect).fills[0] else { + panic!("image") + }; + assert_eq!(original.image, ResourceRef::Rid("./image.png".into())); + + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", source), &mut provider) + .unwrap(); + let scene = output.document.get(output.document.root).children[0]; + let rect = output.document.get(scene).children[0]; + let Paint::Image(materialized) = &output.document.get(rect).fills[0] else { + panic!("image") + }; + assert_eq!(output.resources.len(), 1); + assert_eq!(output.resources[0].authored, "./image.png"); + assert_eq!( + materialized.image, + ResourceRef::Rid(output.resources[0].runtime_rid.clone()) + ); + assert_eq!(grida_xml::VERSION, "0"); +} + +#[test] +fn draft0_parse_and_print_contract_remains_unchanged() { + let source = r##""##; + let ordinary = grida_xml::parse(source).unwrap(); + let mut provider = MemoryProvider::default(); + let linked = + grida_xml_source::materialize(snapshot("draft0", "memory:/", source), &mut provider) + .unwrap(); + assert_eq!(ordinary, linked.document); + assert_eq!(linked.program.units().len(), 1); + assert_eq!( + linked.program.unit("draft0").unwrap().version(), + SourceVersion::Draft0 + ); + assert!(grida_xml::print(&ordinary) + .unwrap() + .starts_with("")); +} diff --git a/model-v2/a/lab/tests/grida_xml_source_v2.rs b/model-v2/a/lab/tests/grida_xml_source_v2.rs new file mode 100644 index 0000000000..b15a0ead9d --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_source_v2.rs @@ -0,0 +1,701 @@ +//! Version 2 typed scalar specialization contract. + +use anchor_lab::grida_xml_source::{ + self, BindingTargetKind, ErrorPhase, ScalarType, SourceProvider, SourceSnapshot, SourceVersion, + ValueSelection, +}; +use anchor_lab::model::{BoxFit, ImagePaintFit, Paint, Payload, ResourceRef, SizeIntent}; +use std::collections::BTreeMap; + +fn snapshot(identity: &str, base: &str, source: &str) -> SourceSnapshot { + SourceSnapshot::new(identity, base, source) +} + +#[derive(Default)] +struct MemoryProvider(BTreeMap<(String, String), SourceSnapshot>); + +impl MemoryProvider { + fn insert(&mut self, from: &str, location: &str, source: SourceSnapshot) { + self.0 + .insert((from.to_owned(), location.to_owned()), source); + } +} + +impl SourceProvider for MemoryProvider { + fn resolve( + &mut self, + containing: &SourceSnapshot, + location: &str, + ) -> Result { + self.0 + .get(&(containing.identity().to_owned(), location.to_owned())) + .cloned() + .ok_or_else(|| "not found".into()) + } +} + +fn authored_scene(output: &grida_xml_source::MaterializedProgram) -> u32 { + output.document.get(output.document.root).children[0] +} + +#[test] +fn all_six_scalar_types_specialize_into_the_existing_ordinary_model() { + let source = r##" + + + + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", source), &mut provider) + .unwrap(); + + assert_eq!( + output.program.unit("entry").unwrap().version(), + SourceVersion::Version2 + ); + let scene = authored_scene(&output); + let card = output.document.get(scene).children[0]; + let card_node = output.document.get(card); + assert_eq!(card_node.header.name.as_deref(), Some("Feature")); + assert_eq!(card_node.header.width, SizeIntent::Fixed(120.0)); + assert!(!card_node.header.active); + let Paint::Solid(background) = &card_node.fills[0] else { + panic!("color prop materializes through ordinary compact fill") + }; + assert_eq!(background.color.0, 0xFF12_3456); + + let rect = card_node.children[0]; + let Paint::Image(image) = &output.document.get(rect).fills[0] else { + panic!("resource prop materializes as image paint") + }; + assert_eq!(image.fit, ImagePaintFit::Fit(BoxFit::Contain)); + let ResourceRef::Rid(runtime_rid) = &image.image else { + panic!("source materialization uses runtime RID") + }; + assert_eq!(runtime_rid, &output.resources[0].runtime_rid); + assert_eq!(output.resources[0].source, "entry"); + assert_eq!(output.resources[0].authored, "./noise.png"); + + let text = card_node.children[1]; + let Payload::Text { content, .. } = &output.document.get(text).payload else { + panic!("string prop materializes into ordinary text") + }; + assert_eq!(content, "Feature"); + + let specialization = &output.specializations[0]; + assert_eq!(specialization.component.id, "card"); + assert_eq!( + specialization + .props + .iter() + .map(|prop| prop.scalar_type) + .collect::>(), + [ + ScalarType::String, + ScalarType::Boolean, + ScalarType::Number, + ScalarType::Color, + ScalarType::Enum, + ScalarType::Resource, + ] + ); + assert!(specialization + .props + .iter() + .all(|prop| matches!(prop.selection, ValueSelection::Supplied { .. }))); + let title = specialization + .props + .iter() + .find(|prop| prop.name == "title") + .unwrap(); + assert_eq!(title.binding_targets.len(), 2); + assert_eq!(title.materialized_occurrences.len(), 2); + assert!(title.binding_targets.iter().any( + |target| matches!(&target.kind, BindingTargetKind::Attribute { name } if name == "name") + )); + assert!(title + .binding_targets + .iter() + .any(|target| matches!(target.kind, BindingTargetKind::Text { .. }))); + let texture = specialization + .props + .iter() + .find(|prop| prop.name == "texture") + .unwrap(); + assert_eq!(texture.binding_targets.len(), 1); + assert_eq!(texture.materialized_occurrences.len(), 1); + assert_eq!(texture.materialized_occurrences[0].node, rect); +} + +#[test] +fn defaults_and_explicit_values_that_equal_defaults_keep_distinct_selection() { + let source = r##" + + + + + {label} + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .unwrap(); + assert_eq!(output.specializations.len(), 2); + let first = output.specializations[0] + .props + .iter() + .find(|prop| prop.name == "accent") + .unwrap(); + let second = output.specializations[1] + .props + .iter() + .find(|prop| prop.name == "accent") + .unwrap(); + assert_eq!(first.value, second.value); + assert_eq!(first.selection, ValueSelection::CalleeDefault); + assert!(matches!(second.selection, ValueSelection::Supplied { .. })); + assert_eq!(first.ultimate_origin.source, "entry"); + assert_eq!(second.ultimate_origin.source, "entry"); +} + +#[test] +fn empty_string_presence_distinguishes_defaults_from_supplied_arguments() { + let source = r##" + + + + {value} + + + + + + +"##; + let output = grida_xml_source::materialize( + snapshot("entry", "memory:/", source), + &mut MemoryProvider::default(), + ) + .expect("empty strings are present values, not null or omission"); + assert_eq!(output.specializations.len(), 2); + let defaulted = &output.specializations[0].props[0]; + let supplied = &output.specializations[1].props[0]; + assert_eq!(defaulted.value, ""); + assert_eq!(supplied.value, ""); + assert_eq!(defaulted.selection, ValueSelection::CalleeDefault); + assert!(matches!( + supplied.selection, + ValueSelection::Supplied { .. } + )); +} + +#[test] +fn nested_forwarding_preserves_the_ultimate_resource_literal_origin() { + let entry = r##" + + + + + + + + +"##; + let library = r##" + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./library.grida.xml", + snapshot("library", "memory:/library/", library), + ); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + + assert_eq!(output.resources.len(), 2); + assert_eq!(output.resources[0].source, "entry"); + assert_eq!(output.resources[0].authored, "./caller.png"); + assert_eq!(output.resources[1].source, "library"); + assert_eq!(output.resources[1].authored, "./default.png"); + + let leafs = output + .specializations + .iter() + .filter(|specialization| specialization.component.id == "leaf") + .collect::>(); + assert_eq!(leafs.len(), 2); + assert_eq!(leafs[0].props[0].ultimate_origin.source, "entry"); + assert_eq!(leafs[1].props[0].ultimate_origin.source, "library"); + for leaf in leafs { + assert!(matches!( + leaf.props[0].selection, + ValueSelection::Supplied { .. } + )); + assert_eq!(leaf.props[0].forwarding.len(), 1); + assert_eq!(leaf.props[0].forwarding[0].source, "library"); + assert_eq!(leaf.props[0].forwarding[0].name, "texture"); + } + + let wrappers = output + .specializations + .iter() + .filter(|specialization| specialization.component.id == "wrapper") + .collect::>(); + assert_eq!(wrappers.len(), 2); + for wrapper in wrappers { + let asset = &wrapper.props[0]; + assert_eq!(asset.name, "asset"); + assert_eq!(asset.binding_targets.len(), 1); + assert!(asset.materialized_occurrences.is_empty()); + let target = &asset.binding_targets[0]; + assert_eq!(target.source, "library"); + assert_eq!(target.element, "arg"); + assert!(target.span.end > target.span.start); + assert!(matches!( + &target.kind, + BindingTargetKind::Argument { use_href, argument } + if use_href == "#leaf" && argument == "texture" + )); + } +} + +#[test] +fn brace_scanning_is_source_local_and_substituted_strings_are_single_pass() { + let source = r##" + + + + literal {{label}}; value {label} + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .unwrap(); + let scene = authored_scene(&output); + let message = output.document.get(scene).children[0]; + assert_eq!( + output.document.get(message).header.name.as_deref(), + Some("{literal}") + ); + let text = output.document.get(message).children[0]; + let Payload::Text { content, .. } = &output.document.get(text).payload else { + panic!("text") + }; + assert_eq!(content, "literal {label}; value {other}"); +} + +#[test] +fn version2_accepts_version1_static_components_without_reinterpreting_braces() { + let entry = r##""##; + let v1 = r##"{price} {{price}}"##; + let mut provider = MemoryProvider::default(); + provider.insert("entry", "./v1.grida.xml", snapshot("v1", "memory:/v1/", v1)); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + let scene = authored_scene(&output); + let instance = output.document.get(scene).children[0]; + let text = output.document.get(instance).children[0]; + let Payload::Text { content, .. } = &output.document.get(text).payload else { + panic!("text") + }; + assert_eq!(content, "{price} {{price}}"); + assert!(output.specializations.is_empty()); + + let v1_entry = + r##""##; + let v2 = r##""##; + let mut provider = MemoryProvider::default(); + provider.insert( + "v1-entry", + "./v2.grida.xml", + snapshot("v2", "memory:/v2/", v2), + ); + let error = grida_xml_source::materialize( + snapshot("v1-entry", "memory:/entry/", v1_entry), + &mut provider, + ) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Link); + assert!(error.authored_use.is_some()); +} + +#[test] +fn declaration_and_binding_failures_are_source_errors() { + let cases = [ + ( + r##""##, + "reserved", + ), + ( + r##""##, + "duplicate prop", + ), + ( + r##""##, + "has no binding or forwarding site", + ), + ( + r##"{n}"##, + "cannot bind to text content", + ), + ( + r##"{missing}"##, + "is not declared", + ), + ( + r##""##, + "interpolation is invalid", + ), + ( + r##"{label"##, + "unescaped `{`", + ), + ( + r##""##, + "must precede fill, stroke, and render children", + ), + ( + r##""##, + "not a valid color", + ), + ( + r##""##, + "member `stretch` is not accepted", + ), + ( + r##""##, + "opacity must be between 0 and 1", + ), + ]; + for (source, expected) in cases { + let error = + grida_xml_source::parse_source(snapshot("bad", "memory:/", source)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse, "{error}"); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn specialization_rejects_missing_unknown_and_mistyped_arguments() { + let cases = [ + ( + r##""##, + "is required", + ), + ( + r##""##, + "unknown argument", + ), + ( + r##""##, + "must be exactly", + ), + ( + r##""##, + "must be finite", + ), + ( + r##""##, + "not a valid color", + ), + ( + r##""##, + "not a member", + ), + ( + r##""##, + "non-empty", + ), + ]; + for (source, expected) in cases { + let mut provider = MemoryProvider::default(); + let error = + grida_xml_source::materialize(snapshot("bad", "memory:/", source), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Specialize, "{error}"); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn forwarding_requires_exact_types_and_enum_subset_compatibility() { + let cases = [ + ( + r##" + + + + +"##, + "cannot forward", + ), + ( + r##" + + + + +"##, + "source member `fill` is not accepted", + ), + ]; + for (source, expected) in cases { + let mut provider = MemoryProvider::default(); + let error = grida_xml_source::materialize( + snapshot("bad-forward", "memory:/", source), + &mut provider, + ) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Specialize, "{error}"); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn specialized_values_run_the_existing_target_specific_validation() { + let source = r##" + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let error = grida_xml_source::materialize( + snapshot("target-invalid", "memory:/", source), + &mut provider, + ) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Specialize); + assert!(error + .to_string() + .contains("opacity must be between 0 and 1")); + assert_eq!(error.use_chain.len(), 1); + assert_eq!(error.component.as_deref(), Some("surface")); + assert_eq!(error.specialization_sites.len(), 1); + let site = &error.specialization_sites[0]; + assert_eq!(site.prop, "alpha"); + assert!(matches!( + &site.selection, + ValueSelection::Supplied { argument } if argument.source == "target-invalid" + )); + assert!(matches!( + &site.binding.kind, + BindingTargetKind::Attribute { name } if name == "opacity" + )); + assert_eq!(site.binding.source, "target-invalid"); + assert!(error.to_string().contains("prop `alpha` from argument")); + assert!(error.to_string().contains("bound at target-invalid")); +} + +#[test] +fn version2_use_cannot_send_arguments_to_a_version1_component() { + let entry = r##" + +"##; + let v1 = r##""##; + let mut provider = MemoryProvider::default(); + provider.insert("entry", "./v1.grida.xml", snapshot("v1", "memory:/v1/", v1)); + let error = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Specialize); + assert!(error.to_string().contains("empty interface")); +} + +#[test] +fn duplicate_args_and_top_level_forwarding_are_rejected_during_source_parse() { + let cases = [ + ( + r##""##, + "duplicate argument", + ), + ( + r##""##, + "cannot forward without an enclosing component scope", + ), + ]; + for (source, expected) in cases { + let error = + grida_xml_source::parse_source(snapshot("bad", "memory:/", source)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn required_props_do_not_acquire_invented_values_during_source_validation() { + let free_layout = r##" + + + + + + + +"##; + grida_xml_source::parse_source(snapshot("free", "memory:/", free_layout)) + .expect("source validation must select a witness from the declared enum domain"); + grida_xml_source::materialize( + snapshot("free", "memory:/", free_layout), + &mut MemoryProvider::default(), + ) + .expect("the caller-supplied free-layout branch is valid"); + + for (layout_values, flow_values) in [("none flex", "in absolute"), ("flex none", "absolute in")] + { + let source = format!( + r##" + + + + + + + + + + + + + +"## + ); + grida_xml_source::parse_source(snapshot("positioned", "memory:/", &source)) + .expect("source validation searches declared enum combinations independent of order"); + grida_xml_source::materialize( + snapshot("positioned", "memory:/", &source), + &mut MemoryProvider::default(), + ) + .expect("the supplied flex/absolute combination is valid"); + } + + let auto_axis = r##" + + + + + + + +"##; + grida_xml_source::parse_source(snapshot("auto", "memory:/", auto_axis)) + .expect("enum witnesses must preserve the declared auto sizing branch"); + grida_xml_source::materialize( + snapshot("auto", "memory:/", auto_axis), + &mut MemoryProvider::default(), + ) + .expect("auto plus aspect ratio supplies exactly one primitive axis"); + + let bounded_stop = r##" + + + + + + + + + + + + + + + +"##; + grida_xml_source::parse_source(snapshot("gradient", "memory:/", bounded_stop)) + .expect("numeric source validation derives witnesses from authored bounds"); + grida_xml_source::materialize( + snapshot("gradient", "memory:/", bounded_stop), + &mut MemoryProvider::default(), + ) + .expect("the supplied stop remains between its authored neighbors"); + + for values in ["none flex", "flex none"] { + let source = format!( + r##" + + + + +"## + ); + grida_xml_source::parse_source(snapshot("entry", "memory:/", &source)) + .expect("required enum member order must not create a source-time effective value"); + let mut provider = MemoryProvider::default(); + grida_xml_source::materialize(snapshot("entry", "memory:/", &source), &mut provider) + .expect("the caller-supplied flex value satisfies the complete ordinary state"); + } + + let source = r##" + + + + +"##; + grida_xml_source::parse_source(snapshot("entry", "memory:/", source)) + .expect("required number props remain deferred during source validation"); + let mut provider = MemoryProvider::default(); + grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .expect("zero smoothing is compatible with elliptical corners"); + + let incompatible = source.replace("value=\"0\"", "value=\"1\""); + let mut provider = MemoryProvider::default(); + let error = + grida_xml_source::materialize(snapshot("entry", "memory:/", &incompatible), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Specialize); + 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_v3.rs b/model-v2/a/lab/tests/grida_xml_source_v3.rs new file mode 100644 index 0000000000..abf3aeeeec --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_source_v3.rs @@ -0,0 +1,816 @@ +//! Version 3 named static slot projection producer contract. + +use anchor_lab::grida_xml; +use anchor_lab::grida_xml_source::{ + self, BindingTargetKind, ErrorPhase, SourceProvider, SourceSnapshot, SourceVersion, +}; +use anchor_lab::model::{AxisBinding, Document, NodeId, Paint}; +use std::collections::BTreeMap; + +fn snapshot(identity: &str, base: &str, source: &str) -> SourceSnapshot { + SourceSnapshot::new(identity, base, source) +} + +#[derive(Default)] +struct MemoryProvider { + sources: BTreeMap<(String, String), SourceSnapshot>, + requests: Vec<(String, String)>, +} + +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.requests + .push((containing.identity().to_owned(), location.to_owned())); + self.sources + .get(&(containing.identity().to_owned(), location.to_owned())) + .cloned() + .ok_or_else(|| "not found".into()) + } +} + +fn authored_scene(output: &grida_xml_source::MaterializedProgram) -> NodeId { + output.document.get(output.document.root).children[0] +} + +fn solid_color(document: &Document, node: NodeId) -> u32 { + let Paint::Solid(solid) = &document.get(node).fills[0] else { + panic!("expected a solid fill") + }; + solid.color.0 +} + +#[test] +fn version3_alone_accepts_unique_named_empty_slots_in_component_render_positions() { + let source = r##" + + + + + + +"##; + let unit = grida_xml_source::parse_source(snapshot("entry", "memory:/", source)).unwrap(); + assert_eq!(unit.version(), SourceVersion::Version3); + assert_eq!(unit.component_ids().collect::>(), ["card"]); + + for version in ["0", "1", "2"] { + let source = format!( + r##""## + ); + let error = + grida_xml_source::parse_source(snapshot("old", "memory:/", &source)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse, "{error}"); + } + + let outside = r##""##; + let error = + grida_xml_source::parse_source(snapshot("outside", "memory:/", outside)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse); + assert!(error.message.contains("only in a Version 3 component")); + + let leaf = r##""##; + let error = grida_xml_source::parse_source(snapshot("leaf", "memory:/", leaf)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse); + assert!(error.message.contains("not valid inside ")); +} + +#[test] +fn malformed_duplicate_and_misplaced_slot_syntax_has_focused_parse_diagnostics() { + let cases = [ + ( + r##""##, + "requires exactly `name`", + ), + ( + r##""##, + "lowercase kebab-case", + ), + ( + r##""##, + "requires exactly `name`", + ), + ( + r##"text"##, + "must be empty", + ), + ( + r##""##, + "duplicate slot `body`", + ), + ( + r##""##, + "valid only on a direct Version 3 ", + ), + ( + r##""##, + "requires `slot`", + ), + ( + r##""##, + "must be direct render roots", + ), + ( + r##"{label}"##, + "must precede every render assignment", + ), + ]; + + for (source, expected) in cases { + let error = + grida_xml_source::parse_source(snapshot("bad", "memory:/", source)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse, "{error}"); + assert!(error.message.contains(expected), "{error}"); + } +} + +#[test] +fn slot_markers_start_render_content_for_property_ordering_regardless_of_projection() { + let late_properties = [ + r##""##, + r##""##, + ]; + let assignments = ["", r##""##]; + + for late_property in late_properties { + for assignment in assignments { + let source = format!( + r##" + + + + {late_property} + + {assignment} + +"## + ); + let error = + grida_xml_source::parse_source(snapshot("late", "memory:/", &source)).unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Parse, "{error}"); + assert!( + error + .message + .contains("must appear before content or scene children"), + "{error}" + ); + } + } +} + +#[test] +fn leading_paint_properties_remain_valid_before_slots_for_empty_and_populated_uses() { + let source = r##" + + + + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("ordered", "memory:/", source), &mut provider) + .unwrap(); + let instances = &output.document.get(authored_scene(&output)).children; + assert_eq!(instances.len(), 2); + assert!(output.document.get(instances[0]).children.is_empty()); + assert_eq!(output.document.get(instances[1]).children.len(), 1); +} + +#[test] +fn zero_one_and_many_assignments_project_at_the_marker_in_caller_order() { + let source = r##" + + + + + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .unwrap(); + let scene = authored_scene(&output); + let cards = &output.document.get(scene).children; + assert_eq!(cards.len(), 2); + for &card in cards { + assert_eq!(output.document.get(card).children.len(), 3); + } + let empty_frame = output.document.get(cards[0]).children[1]; + let filled_frame = output.document.get(cards[1]).children[1]; + assert!(output.document.get(empty_frame).children.is_empty()); + let assigned = &output.document.get(filled_frame).children; + assert_eq!(assigned.len(), 2); + assert_eq!( + output.document.get(assigned[0]).header.name.as_deref(), + Some("first") + ); + assert_eq!( + output.document.get(assigned[1]).header.name.as_deref(), + Some("second") + ); + + let printed = grida_xml::print(&output.document).unwrap(); + assert!(!printed.contains(">() + .as_slice(), + assigned.as_slice() + ); + assert!(filled + .assignments + .iter() + .all(|assignment| assignment.site.source == "entry" + && assignment.site.component.is_none() + && assignment.site.name == "media")); + assert_eq!(output.provenance[&assigned[0]].source, "entry"); + assert_eq!(output.provenance[&assigned[0]].use_chain.len(), 1); +} + +#[test] +fn assignment_root_span_bindings_keep_their_size_evidence() { + let source = r##" + + + + + + + + + +"##; + let output = grida_xml_source::materialize( + snapshot("entry", "memory:/", source), + &mut MemoryProvider::default(), + ) + .expect("span bindings supply projected primitive extents"); + let scene = authored_scene(&output); + let instance = output.document.get(scene).children[0]; + let [rect, line] = output.document.get(instance).children.as_slice() else { + panic!("expected projected rect and line") + }; + assert_eq!( + output.document.get(*rect).header.x, + AxisBinding::Span { + start: 0.0, + end: 0.0 + } + ); + assert_eq!( + output.document.get(*rect).header.y, + AxisBinding::Span { + start: 0.0, + end: 0.0 + } + ); + assert!(matches!( + output.document.get(*line).header.x, + AxisBinding::Span { .. } + )); +} + +#[test] +fn unknown_assignments_are_link_errors_and_only_version3_targets_receive_them() { + let unknown = r##" + + + + +"##; + let mut provider = MemoryProvider::default(); + let error = + grida_xml_source::materialize(snapshot("entry", "memory:/", unknown), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Link); + assert!(error.message.contains("unknown slot assignment `missing`")); + assert!(error.message.contains("available: body")); + + let entry = r##""##; + let v2 = r##""##; + let mut provider = MemoryProvider::default(); + provider.insert("entry", "./v2.grida.xml", snapshot("v2", "memory:/v2/", v2)); + let error = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Link); + assert!(error.message.contains("require a Version 3 target")); +} + +#[test] +fn assigned_roots_observe_the_linked_slot_parents_relationship_rules() { + let source = r##" + + + + + + +"##; + grida_xml_source::parse_source(snapshot("entry", "memory:/", source)) + .expect("the assignment root has no parent relationship until linking"); + let mut provider = MemoryProvider::default(); + let error = grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Projection); + assert!(error + .message + .contains("x/y are not valid on in-flow ")); + assert_eq!(error.source, "entry"); + assert_eq!( + error.component, None, + "the assigned root is caller-authored" + ); + assert_eq!(error.use_chain.len(), 1); + assert_eq!(error.use_chain[0].target.id, "row"); + let projection = error + .slot_projection + .as_ref() + .expect("projected relationship error retains both authored sites"); + assert_eq!(projection.definition.source, "entry"); + assert_eq!(projection.definition.component.id, "row"); + assert_eq!(projection.definition.name, "item"); + assert!(projection.definition.span.end > projection.definition.span.start); + assert_eq!(projection.receiving_use.target.id, "row"); + assert_eq!(projection.receiving_use, error.use_chain[0]); + assert_eq!(projection.assignment.source, "entry"); + assert_eq!(projection.assignment.component, None); + assert_eq!(projection.assignment.name, "item"); + assert!(projection.assignment.span.end > projection.assignment.span.start); +} + +#[test] +fn caller_scalar_errors_inside_slots_keep_caller_and_projection_ownership() { + let source = r##" + + + + + + + + + + + + + +"##; + let error = grida_xml_source::materialize( + snapshot("entry", "memory:/", source), + &mut MemoryProvider::default(), + ) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Specialize); + assert_eq!(error.source, "entry"); + assert_eq!(error.component.as_deref(), Some("outer")); + assert!(error.message.contains("opacity must be between 0 and 1")); + assert_eq!(error.specialization_sites.len(), 2); + let specialization = error + .specialization_sites + .iter() + .find(|site| site.prop == "alpha") + .expect("the failing opacity binding remains among the candidates"); + assert_eq!(specialization.prop, "alpha"); + assert_eq!(specialization.ultimate_origin.authored, "2"); + assert_eq!(specialization.binding.element, "rect"); + assert_eq!( + specialization.binding.kind, + BindingTargetKind::Attribute { + name: "opacity".into() + } + ); + assert!(error.specialization_sites.iter().any(|site| { + site.prop == "smooth" + && site.binding.kind + == BindingTargetKind::Attribute { + name: "corner-smoothing".into(), + } + })); + let projection = error + .slot_projection + .as_ref() + .expect("caller-owned scalar failure retains the receiving slot edge"); + assert_eq!(projection.definition.component.id, "shell"); + assert_eq!(projection.definition.name, "body"); + assert_eq!( + projection.receiving_use.name.as_deref(), + Some("inner-shell") + ); + assert_eq!( + projection.assignment.component.as_ref().unwrap().id, + "outer" + ); +} + +#[test] +fn bound_flow_projection_errors_keep_the_causal_scalar_site() { + let source = r##" + + + + + + + + + + + + +"##; + let error = grida_xml_source::materialize( + snapshot("entry", "memory:/", source), + &mut MemoryProvider::default(), + ) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Projection); + assert!(error.message.contains("x/y are not valid on in-flow")); + assert_eq!(error.specialization_sites.len(), 1); + let site = &error.specialization_sites[0]; + assert_eq!(site.prop, "mode"); + assert_eq!( + site.binding.kind, + BindingTargetKind::Attribute { + name: "flow".into() + } + ); + let projection = error.slot_projection.as_ref().unwrap(); + assert_eq!(projection.definition.component.id, "row"); + assert_eq!(projection.definition.name, "item"); + assert_eq!( + projection.assignment.component.as_ref().unwrap().id, + "outer" + ); +} + +#[test] +fn caller_authored_resources_keep_the_caller_base_across_files() { + let entry = r##" + + + +"##; + let library = r##" + + + + +"##; + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./library.grida.xml", + snapshot("library", "memory:/library/", library), + ); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + assert_eq!(output.resources.len(), 2); + let definition_resource = output + .resources + .iter() + .find(|resource| resource.source == "library") + .unwrap(); + let assignment_resource = output + .resources + .iter() + .find(|resource| resource.source == "entry") + .unwrap(); + assert_eq!(definition_resource.base, "memory:/library/"); + assert_eq!(assignment_resource.base, "memory:/entry/"); + assert_eq!(definition_resource.authored, "./texture.png"); + assert_eq!(assignment_resource.authored, "./texture.png"); + assert_ne!( + definition_resource.runtime_rid, + assignment_resource.runtime_rid + ); +} + +#[test] +fn assigned_content_uses_the_caller_prop_environment_not_the_callees_same_name() { + let source = r##" + + + + + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .unwrap(); + let wrapper = output.document.get(authored_scene(&output)).children[0]; + let shell = output.document.get(wrapper).children[0]; + let assigned = output.document.get(shell).children[0]; + assert_eq!(solid_color(&output.document, shell), 0xFF11_1111); + assert_eq!(solid_color(&output.document, assigned), 0xFF22_CC44); + + let wrapper_specialization = output + .specializations + .iter() + .find(|specialization| specialization.component.id == "wrapper") + .unwrap(); + let tone = wrapper_specialization + .props + .iter() + .find(|prop| prop.name == "tone") + .unwrap(); + assert_eq!(tone.materialized_occurrences.len(), 1); + assert_eq!(tone.materialized_occurrences[0].node, assigned); + assert_eq!( + output.provenance[&assigned].component.as_ref().unwrap().id, + "wrapper" + ); +} + +#[test] +fn nested_uses_in_assignments_resolve_from_the_callers_source() { + let entry = r##" + + + +"##; + let shell = + r##""##; + let parts = r##""##; + let mut provider = MemoryProvider::default(); + provider.insert( + "entry", + "./shell.grida.xml", + snapshot("shell", "memory:/shell/", shell), + ); + provider.insert( + "entry", + "./parts.grida.xml", + snapshot("parts", "memory:/parts/", parts), + ); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + assert_eq!( + provider.requests, + [ + ("entry".into(), "./shell.grida.xml".into()), + ("entry".into(), "./parts.grida.xml".into()), + ] + ); + let shell_node = output.document.get(authored_scene(&output)).children[0]; + let badge = output.document.get(shell_node).children[0]; + assert_eq!(output.provenance[&badge].source, "parts"); + assert_eq!(output.provenance[&badge].use_chain.len(), 2); + assert_eq!(output.slot_projections[0].assignments[0].node, badge); +} + +#[test] +fn slots_nested_in_caller_assignment_trees_keep_the_callers_slot_instance() { + let source = r##" + + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/", source), &mut provider) + .unwrap(); + let outer = output.document.get(authored_scene(&output)).children[0]; + let inner = output.document.get(outer).children[0]; + let assignment_container = output.document.get(inner).children[0]; + let leaf = output.document.get(assignment_container).children[0]; + assert_eq!( + output.document.get(leaf).header.name.as_deref(), + Some("leaf") + ); + + assert_eq!(output.slot_projections.len(), 2); + let inner_projection = output + .slot_projections + .iter() + .find(|projection| projection.definition.name == "inner-body") + .unwrap(); + let outer_projection = output + .slot_projections + .iter() + .find(|projection| projection.definition.name == "outer-body") + .unwrap(); + assert_eq!(inner_projection.assignments[0].node, assignment_container); + assert_eq!(outer_projection.assignments[0].node, leaf); + assert_eq!(outer_projection.definition.component.id, "outer"); + assert_eq!(outer_projection.use_chain.len(), 1); + assert_eq!(outer_projection.use_chain[0].target.id, "outer"); +} + +#[test] +fn nested_projection_errors_identify_the_exact_receiving_use() { + let source = r##" + + + + + + + + + + + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let error = + grida_xml_source::materialize(snapshot("nested", "memory:/", source), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Projection); + assert!(error.message.contains("grow/align on "), "{error}"); + assert_eq!(error.use_chain.len(), 2); + assert_eq!(error.use_chain[0].name.as_deref(), Some("outer-instance")); + assert_eq!(error.use_chain[1].name.as_deref(), Some("inner-instance")); + + let projection = error + .slot_projection + .as_ref() + .expect("nested projected failure retains its precise projection edge"); + assert_eq!(projection.definition.component.id, "outer"); + assert_eq!(projection.definition.name, "outer-body"); + assert_eq!(projection.receiving_use.target.id, "outer"); + assert_eq!( + projection.receiving_use.name.as_deref(), + Some("outer-instance") + ); + assert_eq!(projection.receiving_use, error.use_chain[0]); + assert_ne!(projection.receiving_use, *error.use_chain.last().unwrap()); + assert_eq!(projection.assignment.component, None); +} + +#[test] +fn projected_content_drops_only_the_current_callee_from_cycle_detection() { + let finite = r##" + + + + +"##; + let mut provider = MemoryProvider::default(); + let output = + grida_xml_source::materialize(snapshot("finite", "memory:/", finite), &mut provider) + .expect("a caller-owned nested use of the current callee is a finite second instance"); + let outer = output.document.get(authored_scene(&output)).children[0]; + let inner = output.document.get(outer).children[0]; + assert!(output.document.get(inner).children.is_empty()); + assert_eq!(output.slot_projections.len(), 2); + assert_eq!(output.slot_projections[0].assignments[0].node, inner); + assert!(output.slot_projections[1].assignments.is_empty()); + + let cycle = r##" + + + + + + + +"##; + let mut provider = MemoryProvider::default(); + let error = grida_xml_source::materialize(snapshot("cycle", "memory:/", cycle), &mut provider) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Link); + assert!( + error.message.contains("cycle#outer -> cycle#outer"), + "{error}" + ); + assert_eq!(error.use_chain.len(), 3); + assert_eq!(error.use_chain[0].target.id, "outer"); + assert_eq!(error.use_chain[1].target.id, "card"); + assert_eq!(error.use_chain[2].target.id, "outer"); + let projection = error + .slot_projection + .as_ref() + .expect("projected cycle retains the edge that closes it"); + assert_eq!(projection.definition.source, "cycle"); + assert_eq!(projection.definition.component.id, "card"); + assert_eq!(projection.definition.name, "body"); + assert!(projection.definition.span.end > projection.definition.span.start); + assert_eq!(projection.receiving_use.target.id, "card"); + assert_eq!(projection.receiving_use, error.use_chain[1]); + assert_eq!(projection.assignment.source, "cycle"); + assert_eq!( + projection.assignment.component.as_ref().unwrap().id, + "outer" + ); + assert_eq!(projection.assignment.name, "body"); + assert!(projection.assignment.span.end > projection.assignment.span.start); +} + +#[test] +fn version3_links_versions_one_two_and_three_without_backporting_version3() { + let entry = r##" + + + + + +"##; + let v1 = r##""##; + let v2 = r##""##; + let v3 = + r##""##; + let mut provider = MemoryProvider::default(); + provider.insert("entry", "./v1.grida.xml", snapshot("v1", "memory:/v1/", v1)); + provider.insert("entry", "./v2.grida.xml", snapshot("v2", "memory:/v2/", v2)); + provider.insert("entry", "./v3.grida.xml", snapshot("v3", "memory:/v3/", v3)); + let output = + grida_xml_source::materialize(snapshot("entry", "memory:/entry/", entry), &mut provider) + .unwrap(); + assert_eq!( + output.document.get(authored_scene(&output)).children.len(), + 3 + ); + assert_eq!(output.slot_projections.len(), 1); + assert_eq!(output.specializations.len(), 2); + + for old_version in ["1", "2"] { + let old_entry = format!( + r##""## + ); + let mut provider = MemoryProvider::default(); + provider.insert("old", "./v3.grida.xml", snapshot("v3", "memory:/v3/", v3)); + let error = grida_xml_source::materialize( + snapshot("old", "memory:/old/", &old_entry), + &mut provider, + ) + .unwrap_err(); + assert_eq!(error.phase, ErrorPhase::Link); + assert!(error.message.contains(&format!( + "Version {old_version} source cannot link Version 3" + ))); + } +} 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/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 new file mode 100644 index 0000000000..4811ebb88d --- /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, StrokeWidth::Uniform(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, StrokeWidth::Uniform(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, 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}"); + 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, StrokeWidth::Uniform(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..1825eab8f9 --- /dev/null +++ b/model-v2/a/lab/tests/grida_xml_suite.rs @@ -0,0 +1,815 @@ +//! 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"#, + "font-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 ["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}`" + ); + } +} + +/// The authored scene is a free child of the implicit, definite viewport +/// frame. Root bindings therefore use the ordinary binding model; explicit +/// Span keeps viewport-fill intent inspectable without changing `auto`/hug. +#[test] +fn authored_root_bindings_resolve_against_the_viewport_and_roundtrip() { + let source = r#" + + + + + +"#; + let doc = grida_xml::parse(source).expect("root bindings parse"); + let printed = grida_xml::print(&doc).expect("root bindings print"); + let reparsed = grida_xml::parse(&printed).expect("root bindings reparse"); + assert_eq!(doc, reparsed); + + let root = doc.get(doc.root).children[0]; + let nested = doc.get(root).children[0]; + assert_eq!( + doc.get(root).header.x, + AxisBinding::Span { + start: 12.0, + end: 20.0 + } + ); + assert_eq!( + doc.get(root).header.y, + AxisBinding::Span { + start: 8.0, + end: 16.0 + } + ); + assert!(printed.contains( + "" + )); + assert!(!printed.contains("x=\"span 12 20\" y=\"span 8 16\" width=")); + assert!(printed.contains("")); + + for (viewport, expected_root, expected_nested) in [ + ( + (1280.0, 720.0), + (12.0, 8.0, 1248.0, 696.0), + (5.0, 3.0, 1236.0, 684.0), + ), + ( + (390.0, 844.0), + (12.0, 8.0, 358.0, 820.0), + (5.0, 3.0, 346.0, 808.0), + ), + ] { + let resolved = resolve( + &doc, + &ResolveOptions { + viewport, + ..Default::default() + }, + ); + assert!(resolved.reports.is_empty(), "{:?}", resolved.reports); + assert_eq!(resolved.xywh(root), expected_root); + assert_eq!(resolved.xywh(nested), expected_nested); + } + + for (bindings, expected) in [ + (r#"x="10" y="12""#, (10.0, 12.0, 100.0, 50.0)), + (r#"x="end 10" y="end 12""#, (1170.0, 658.0, 100.0, 50.0)), + (r#"x="center -5" y="center 6""#, (585.0, 341.0, 100.0, 50.0)), + ] { + let source = format!( + "" + ); + let doc = grida_xml::parse(&source).expect("root pin binding parses"); + let root = doc.get(doc.root).children[0]; + let resolved = resolve( + &doc, + &ResolveOptions { + viewport: (1280.0, 720.0), + ..Default::default() + }, + ); + assert!(resolved.reports.is_empty(), "{:?}", resolved.reports); + assert_eq!(resolved.xywh(root), expected); + let printed = grida_xml::print(&doc).expect("root pin binding prints"); + assert_eq!( + doc, + grida_xml::parse(&printed).expect("root pin binding reparses") + ); + } +} + +#[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.flow = Flow::Absolute; + 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![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + 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![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + 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 new file mode 100644 index 0000000000..49b57be5a3 --- /dev/null +++ b/model-v2/a/lab/tests/layout.rs @@ -0,0 +1,384 @@ +//! 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 +} + +#[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 new file mode 100644 index 0000000000..b70ec36ea4 --- /dev/null +++ b/model-v2/a/lab/tests/mm_laws.rs @@ -0,0 +1,226 @@ +//! 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![], + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + fills: Paints::default(), + strokes: vec![], + }, + ); + 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/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 new file mode 100644 index 0000000000..50eedc97a2 --- /dev/null +++ b/model-v2/a/lab/tests/ops_suite.rs @@ -0,0 +1,180 @@ +//! 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/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/lab/tests/rotation.rs b/model-v2/a/lab/tests/rotation.rs new file mode 100644 index 0000000000..ae092a3fef --- /dev/null +++ b/model-v2/a/lab/tests/rotation.rs @@ -0,0 +1,211 @@ +//! 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/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/lab/tests/text_layout.rs b/model-v2/a/lab/tests/text_layout.rs new file mode 100644 index 0000000000..efd1eef796 --- /dev/null +++ b/model-v2/a/lab/tests/text_layout.rs @@ -0,0 +1,292 @@ +use anchor_lab::math::RectF; +use anchor_lab::model::{ + AxisBinding, Color, DocBuilder, Header, Paints, Payload, SizeIntent, Stroke, StrokeAlign, + StrokeWidth, TextPayloadRef, +}; +use anchor_lab::resolve::{resolve, resolve_with_text_layout, ResolveOptions}; +use anchor_lab::text_layout::{ + StubTextLayoutOracle, TextLayout, TextLayoutOracle, TextLine, TextLineBreak, +}; +use std::cell::RefCell; +use std::sync::Arc; + +#[test] +fn stub_layout_owns_visual_lines_source_ranges_and_metrics() { + let payload = Payload::Text { + content: "aa bb cc\nx\n".into(), + font_size: 10.0, + }; + let layout = StubTextLayoutOracle.layout(payload.as_text().unwrap(), Some(30.0)); + + assert_eq!((layout.width, layout.height), (30.0, 48.0)); + assert_eq!( + layout + .lines + .iter() + .map(|line| (line.text.as_str(), line.byte_range.clone(), line.end)) + .collect::>(), + [ + ("aa bb", 0..5, TextLineBreak::Soft), + ("cc", 6..8, TextLineBreak::Explicit), + ("x", 9..10, TextLineBreak::Explicit), + ("", 11..11, TextLineBreak::Terminal), + ] + ); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.baseline) + .collect::>(), + [8.5, 20.5, 32.5, 44.5] + ); + assert!(layout.glyph_runs.is_empty(), "the stub is not a shaper"); + assert_eq!( + layout.logical_bounds, + Some(RectF { + x: 0.0, + y: 0.0, + w: 30.0, + h: 48.0, + }) + ); + assert_eq!(layout.ink_bounds, None); + assert_eq!(layout.unresolved_glyphs, 0); + assert_eq!(layout.width_constraint, Some(30.0)); + assert_eq!( + layout.assigned_box, + RectF { + x: 0.0, + y: 0.0, + w: 30.0, + h: 48.0 + } + ); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.source_range.clone()) + .collect::>(), + [0..6, 6..9, 9..11, 11..11] + ); +} + +#[derive(Default)] +struct RecordingOracle { + constraints: RefCell>>, +} + +impl TextLayoutOracle for RecordingOracle { + fn layout(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc { + self.constraints.borrow_mut().push(max_width); + let width = max_width.unwrap_or(54.0); + let height = if max_width.is_some() { 24.0 } else { 12.0 }; + Arc::new(TextLayout { + oracle: "recording@test-1", + environment: "recording-fonts@test-1".into(), + width_constraint: max_width, + assigned_box: RectF { + x: 0.0, + y: 0.0, + w: width, + h: height, + }, + width, + height, + lines: vec![TextLine { + text: text.text.to_owned(), + byte_range: 0..u32::try_from(text.text.len()).unwrap(), + source_range: 0..u32::try_from(text.text.len()).unwrap(), + end: TextLineBreak::Terminal, + left: 0.0, + width, + top: 0.0, + height, + baseline: 8.0, + ascent: 8.0, + descent: height - 8.0, + }], + glyph_runs: Vec::new(), + logical_bounds: Some(RectF { + x: 0.0, + y: 0.0, + w: width, + h: height, + }), + ink_bounds: None, + unresolved_glyphs: 0, + }) + } +} + +#[test] +fn explicit_oracle_drives_measurement_and_final_width_layout_storage() { + let mut builder = DocBuilder::new(); + let mut header = Header::new(SizeIntent::Auto, SizeIntent::Auto); + header.max_width = Some(30.0); + let text = builder.add( + 0, + header, + Payload::Text { + content: "aa bb cc".into(), + font_size: 10.0, + }, + ); + let document = builder.build(); + let oracle = RecordingOracle::default(); + let resolved = resolve_with_text_layout(&document, &ResolveOptions::default(), &oracle); + + assert_eq!( + (resolved.box_of(text).w, resolved.box_of(text).h), + (30.0, 24.0) + ); + let stored = resolved.text_layout_of(text); + assert_eq!(stored.oracle, "recording@test-1"); + assert_eq!((stored.width, stored.height), (30.0, 24.0)); + assert_eq!(stored.width_constraint, Some(30.0)); + assert_eq!( + stored.assigned_box, + RectF { + x: 0.0, + y: 0.0, + w: 30.0, + h: 24.0 + } + ); + assert_eq!(oracle.constraints.borrow().last(), Some(&Some(30.0))); + assert!(resolved.text_layout_opt(0).is_none()); + assert!(resolved.text_layout_opt(u32::MAX).is_none()); +} + +#[test] +fn compatibility_resolve_stores_the_stub_layout() { + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Fixed(30.0), SizeIntent::Auto), + Payload::Text { + content: "aa bb cc".into(), + font_size: 10.0, + }, + ); + let resolved = resolve(&builder.build(), &ResolveOptions::default()); + assert_eq!(resolved.text_layout_of(text).oracle, "stub@lab-0"); + assert_eq!(resolved.text_layout_of(text).lines.len(), 2); +} + +#[test] +fn empty_text_is_one_terminal_line_with_no_ink() { + let payload = Payload::Text { + content: String::new(), + font_size: 10.0, + }; + let layout = StubTextLayoutOracle.layout(payload.as_text().unwrap(), Some(20.0)); + + assert_eq!(layout.lines.len(), 1); + assert_eq!(layout.lines[0].byte_range, 0..0); + assert_eq!(layout.lines[0].source_range, 0..0); + assert_eq!(layout.lines[0].end, TextLineBreak::Terminal); + assert_eq!(layout.width_constraint, Some(20.0)); + assert_eq!(layout.width, 0.0); + assert_eq!(layout.ink_bounds, None); + assert_eq!( + layout.assigned_box, + RectF { + x: 0.0, + y: 0.0, + w: 0.0, + h: 12.0, + } + ); +} + +struct InkOracle; + +impl TextLayoutOracle for InkOracle { + fn layout(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc { + let ink = RectF { + x: 2.0, + y: 3.0, + w: 4.0, + h: 5.0, + }; + Arc::new(TextLayout { + oracle: "ink@test-1", + environment: "ink-font@test-1".into(), + width_constraint: max_width, + assigned_box: ink, + width: ink.w, + height: ink.h, + lines: vec![TextLine { + text: text.text.to_owned(), + byte_range: 0..text.text.len() as u32, + source_range: 0..text.text.len() as u32, + end: TextLineBreak::Terminal, + left: 0.0, + width: ink.w, + top: 0.0, + height: ink.h, + baseline: ink.h, + ascent: ink.h, + descent: 0.0, + }], + glyph_runs: Vec::new(), + logical_bounds: Some(ink), + ink_bounds: Some(ink), + unresolved_glyphs: 0, + }) + } +} + +#[test] +fn text_world_bounds_transform_ink_and_then_apply_stroke_coverage() { + let mut builder = DocBuilder::new(); + let mut header = Header::new(SizeIntent::Fixed(100.0), SizeIntent::Fixed(40.0)); + header.x = AxisBinding::start(10.0); + header.y = AxisBinding::start(20.0); + header.rotation = 90.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 = StrokeWidth::Uniform(2.0); + stroke.align = StrokeAlign::Center; + builder.node_mut(text).strokes.push(stroke); + + let resolved = + resolve_with_text_layout(&builder.build(), &ResolveOptions::default(), &InkOracle); + let layout = resolved.text_layout_of(text); + assert_eq!( + layout.assigned_box, + RectF { + x: 0.0, + y: 0.0, + w: 100.0, + h: 40.0, + } + ); + + // Centered width 2 with the existing conservative text miter factor 4 + // reaches 4 logical units beyond each side of base glyph ink. + let expanded_ink = RectF { + x: -2.0, + y: -1.0, + w: 12.0, + h: 13.0, + }; + assert_eq!( + resolved.aabb_of(text), + expanded_ink.transformed_aabb(&resolved.world_of(text)) + ); + assert_ne!( + resolved.aabb_of(text), + layout + .assigned_box + .transformed_aabb(&resolved.world_of(text)), + "assigned text box must not replace glyph ink in visual bounds" + ); +} 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..475c0eacfa --- /dev/null +++ b/model-v2/a/lab/tests/textir_suite.rs @@ -0,0 +1,105 @@ +//! 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..bda3485086 --- /dev/null +++ b/model-v2/a/lab/tests/visual_only.rs @@ -0,0 +1,384 @@ +//! 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..cf76761ac8 --- /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..c7731339df --- /dev/null +++ b/model-v2/a/spike-canvas/Cargo.lock @@ -0,0 +1,2715 @@ +# 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-engine" +version = "0.0.0" +dependencies = [ + "anchor-lab", + "serde_json", + "skia-safe", +] + +[[package]] +name = "anchor-lab" +version = "0.0.0" +dependencies = [ + "kurbo", + "quick-xml 0.37.5", + "serde", + "svgtypes", + "taffy", +] + +[[package]] +name = "anchor-spike" +version = "0.0.0" +dependencies = [ + "anchor-engine", + "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 = "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" +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 = "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" +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..81c7528c53 --- /dev/null +++ b/model-v2/a/spike-canvas/Cargo.toml @@ -0,0 +1,43 @@ +[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" } +# 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 +# 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 0000000000..f225e62ed7 Binary files /dev/null and b/model-v2/a/spike-canvas/shots/crosszero.png differ diff --git a/model-v2/a/spike-canvas/shots/default.png b/model-v2/a/spike-canvas/shots/default.png new file mode 100644 index 0000000000..a38ac6741e Binary files /dev/null and b/model-v2/a/spike-canvas/shots/default.png differ 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 0000000000..dbd6e6f39a Binary files /dev/null and b/model-v2/a/spike-canvas/shots/rot45.png differ diff --git a/model-v2/a/spike-canvas/shots/ungroup.png b/model-v2/a/spike-canvas/shots/ungroup.png new file mode 100644 index 0000000000..359e88bcfb Binary files /dev/null and b/model-v2/a/spike-canvas/shots/ungroup.png differ 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..87cc2eb273 --- /dev/null +++ b/model-v2/a/spike-canvas/src/interaction.rs @@ -0,0 +1,257 @@ +//! 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..061085b0ec --- /dev/null +++ b/model-v2/a/spike-canvas/src/main.rs @@ -0,0 +1,274 @@ +//! 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 + drawlist -> paint`, +//! immediate, no caches. Live geometry and paint share the host-font text +//! oracle, while the editor still holds no derived state (see TEXTBOOK.md). + +mod camera; +mod interaction; +mod paint; +mod scene; +mod shell; + +use anchor_engine::frame::{FrameBuildError, FrameProduct}; +use anchor_engine::paint::PaintCtx; +use anchor_lab::model::{Document, NodeId}; +use anchor_lab::ops::Op; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved}; +use camera::Camera; + +/// 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() + }, + ) +} + +/// Resolve with the spike's host font environment and retain the exact shaped +/// 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, +) -> Result { + anchor_engine::frame::resolve_and_build( + doc, + &ResolveOptions { + viewport: RESOLVE_VIEWPORT, + ..Default::default() + }, + ctx, + ) +} + +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()); + // 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; + } + shell::run(); +} + +/// 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 (E-A14). + 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); + 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 (`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(); + + // 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 ctx = paint::paint_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)); + 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 + .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") +} + +/// 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; + + 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, 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(); + // 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_unchecked(doc, &resolved); + let t2 = Instant::now(); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + 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)); + 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: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(), + 1000.0 / frame + ); + }; + + // 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((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, + h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + } + b.build() + }; + + 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/paint.rs b/model-v2/a/spike-canvas/src/paint.rs new file mode 100644 index 0000000000..abaac6597c --- /dev/null +++ b/model-v2/a/spike-canvas/src/paint.rs @@ -0,0 +1,19 @@ +//! Typeface acquisition for the spike. The SCENE is painted by the engine +//! (`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 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 { + 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/a/spike-canvas/src/scene.rs b/model-v2/a/spike-canvas/src/scene.rs new file mode 100644 index 0000000000..7877605595 --- /dev/null +++ b/model-v2/a/spike-canvas/src/scene.rs @@ -0,0 +1,411 @@ +//! 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).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 + // 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).fills = Paints::solid("#F1F3F5".into()); + let a1 = b.add(row, named(card(70.0, 110.0), "card.a"), rect()); + 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).fills = Paints::solid("#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).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).fills = Paints::solid("#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).fills = Paints::solid("#F1F3F5".into()); + let h1 = b.add(hug, named(card(60.0, 90.0), "hug.a"), rect()); + 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).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).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). + 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).fills = Paints::solid("#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).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).fills = Paints::solid("#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).fills = Paints::solid("#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).fills = Paints::solid("#171A1F".into()); + + (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).fills = Paints::solid("#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).fills = Paints::solid(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).fills = Paints::solid("#171A1F".into()); + let menu = b.add(header, card(20.0, 20.0), rect()); + 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).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).fills = Paints::solid("#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).fills = Paints::solid("#F1F3F5".into()); + let b2 = b.add(footer, card(70.0, 28.0), rect()); + 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. + 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).fills = Paints::solid("#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).fills = Paints::solid("#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) +} + +#[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 +/// • `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 new file mode 100644 index 0000000000..77041f3a0a --- /dev/null +++ b/model-v2/a/spike-canvas/src/shell/app.rs @@ -0,0 +1,1119 @@ +//! 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_engine::frame::FrameProduct; +use anchor_engine::journal::Journal; +use anchor_engine::query::Query; +use anchor_lab::model::{Document, NodeId}; +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}; +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::paint_ctx; +use crate::shell::hud::{self, HandleKind}; +use crate::{resolve_and_build_doc, scene}; +use anchor_engine::paint::PaintCtx; + +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, + /// 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, + pub gesture: Option, + pub space_held: bool, + pub cursor: (f32, f32), + pub modifiers: ModifiersState, + + // view + pub camera: Camera, + pub ctx: PaintCtx, + pub dpr: f32, + pub last_frame_ms: f32, + /// Per-stage frame breakdown (ms): [resolve+list, 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 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) + 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, + /// 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, + 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::from_env(); + let mut app = App { + doc, + artboard, + selection: None, + hover: None, + 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(), + 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_frame: 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, + gl_context, + exiting: false, + }; + + app.fit_artboard(); + el.run_app(&mut app).expect("event loop"); +} + +impl App { + /// Live geometry must use the same host-font oracle as live painting. + /// The returned drawlist is intentionally discarded when a pointer or + /// editor query needs only the resolved tier. + fn resolve_live(&self) -> Resolved { + 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) { + let resolved = self.resolve_live(); + 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(); + } + + /// 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 { + 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 = self.resolve_live(); + + // 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 Query::new(&resolved).hit_point(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 = self.resolve_live(); + 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 = self.resolve_live(); + 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 = 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:?})")); + } + 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 = self.resolve_live(); + 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 = 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)); + } + } + Fsm::Dragging(Drag::ResizeCorner { id, dx, dy }) => { + let id = *id; + let (dx, dy) = (*dx, *dy); + let r1 = self.resolve_live(); + let p = parent_point(&self.doc, &r1, &self.camera, id, self.cursor); + let rx = self.apply_op( + &r1, + Op::ResizeDrag { + id, + drag: dx, + target: p.0, + }, + ); + let r2 = self.resolve_live(); + let p = parent_point(&self.doc, &r2, &self.camera, id, self.cursor); + 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:?})")); + } + 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 resolved = self.resolve_live(); + let r = if derived { + self.apply_op(&resolved, Op::RotateDerivedCenterFeel { id, deg }) + } else { + self.apply_op(&resolved, Op::SetRotation { 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 = self.resolve_live(); + let (wx, wy) = self.camera.screen_to_world(self.cursor); + self.hover = Query::new(&resolved) + .hit_point(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(); + let resolved = self.resolve_live(); + match self.apply_op(&resolved, Op::Delete { 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 = self.resolve_live(); + 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 { + 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 = self.resolve_live(); + self.push_undo(); + match self.apply_op(&resolved, Op::Ungroup { 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, + ); + } + 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(); + } + _ => {} + } + } + + // ── 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 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)); + 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, + &self.camera, + self.selection, + self.hover, + &self.ctx, + self.dpr, + ); + let t_scene = ms(t1); + + let t2 = std::time::Instant::now(); + self.gpu.gr_context.flush_and_submit(); + 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:?}"); + } + let t_swap = ms(t4); + + self.last_frame_ms = ms(t0); + self.bd = [ + t_resolve_build, + 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+list {:.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_build, 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_frame = Some(product); + } + + // ── panels (egui) ─────────────────────────────────────────────── + + 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)); + 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); + // 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) { + // 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 = self.resolve_live(); + + 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 {} · damage {} · journal {}", + self.last_frame_ms, + self.camera.zoom * 100.0, + self.undo.len(), + self.redo.len(), + self.last_damage, + self.journal.len(), + )) + .monospace() + .weak(), + ); + ui.label( + egui::RichText::new(format!( + " resolve+list {:.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(), + ); + 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); + // 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, + } + }; + 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..b20a40e4ed --- /dev/null +++ b/model-v2/a/spike-canvas/src/shell/hud.rs @@ -0,0 +1,247 @@ +//! 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 anchor_engine::paint::PaintCtx; + +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, + ctx: &PaintCtx, +) { + paint_hud_dpr(canvas, doc, resolved, cam, selection, hover, ctx, 1.0); +} + +#[allow(clippy::too_many_arguments)] +pub fn paint_hud_dpr( + canvas: &Canvas, + doc: &Document, + resolved: &Resolved, + cam: &Camera, + selection: Option, + hover: Option, + ctx: &PaintCtx, + 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) = ctx.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..a44095719d --- /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/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/ANIMATION.md b/model-v2/engine/ANIMATION.md new file mode 100644 index 0000000000..8e8525d57e --- /dev/null +++ b/model-v2/engine/ANIMATION.md @@ -0,0 +1,698 @@ +# ANIMATION — explicit time through the anchor engine + +**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). +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. + +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, +typed sampling stage**: + +```text +authored Document + authored animation model + | + v + compile / validate (not per frame) + | + v +Document + AnimationProgram + SampleTime + declared environment + | + v + sample + | + v + immutable typed PropertyValues + | + 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, partial repaint, and +compositor-only execution are later optimizations, each differential-tested +against that permanent reference path. + +## What exists today + +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 + 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` 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. + +None of those types sample time or describe animation. There is still no +animation program, sampler, interpolation, composition, or playback 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 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 + 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 · animation extends the typed property registry `[PROPOSED]` + +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. + +Animation must add, for each admitted animatable key: + +- interpolation and discrete-fallback rules; +- 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. + +### 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. Existing sparse `PropertyValues` + +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) == PropertyValues::default() +resolve(ValueView::new(document, empty_values)) == resolve(document) +``` + +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. + +## Node identity is decided; subobject identity remains bounded + +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. + +Animation still must answer: + +- whether nested animation may target only its containing node; +- whether cross-target references exist; +- 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. + +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 + +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. + +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 + +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 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: + +- before and after visual bounds; +- drawlist item changes and ordering; +- resource readiness changes; +- disappearance and appearance at timing boundaries. + +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, +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 +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. + +### Pre-animation foundation — no time semantics + +- 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; + 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 + +- 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 + +- 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 + +- 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. + +### 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` | +| 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 +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 source syntax carries local and cross-node targets? +3. What is the format-neutral authored animation model? +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. 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 + +- **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. diff --git a/model-v2/engine/Cargo.lock b/model-v2/engine/Cargo.lock new file mode 100644 index 0000000000..3258afe18b --- /dev/null +++ b/model-v2/engine/Cargo.lock @@ -0,0 +1,964 @@ +# 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 = [ + "kurbo", + "quick-xml", + "serde", + "svgtypes", + "taffy", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[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", + "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 = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[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 = "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 = "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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "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-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 = "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" +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 = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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" +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/DATA-MODEL.md b/model-v2/engine/DATA-MODEL.md new file mode 100644 index 0000000000..5f0e2b09ab --- /dev/null +++ b/model-v2/engine/DATA-MODEL.md @@ -0,0 +1,129 @@ +# 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) | +| **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 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) + +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. **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 + +- 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/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/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/README.md b/model-v2/engine/README.md new file mode 100644 index 0000000000..5e53fd4b39 --- /dev/null +++ b/model-v2/engine/README.md @@ -0,0 +1,344 @@ +# anchor-engine — the phase-4 canvas engine skeleton + +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. + +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, and a pinned +production text oracle) 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 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–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 +image strings from different source units receive different runtime keys +without changing the node, layout, paint, or renderer model. + +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`. Their width projects the +production `StrokeWidth` union: one uniform value or four concrete +top/right/bottom/left values on containers and rectangles. Containers and +rectangles also carry the production-shaped per-corner elliptical radius +record and normalized corner smoothing. Text uses canonical `font-size` plus +flat direct-child `tspan` runs. Those runs materialize as complete UTF-8 byte +ranges with numeric weight, normal/italic style, and optional ordered +`Paints` overrides; omission still means node-fill fallback. + +`path` uses the complete SVG path-data grammar in a fixed unit reference box +and retains `nonzero` or `evenodd` fill identity. Resolution maps its analyzed +command stream into the final declared box exactly once. Tight bounds, damage, +drawlist fills, and every repeated stroke share that box-mapped artifact; +gradients and image paints still use the full declared paint box. Path children +remain ordinary box-local content between the parent fill and strokes. + +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. Per-side box strokes materialize as independently +normalized outer-minus-inner rounded rings; overconsumed inner extents +saturate instead of inverting, and dashed rings keep one continuous contour +phase. Each edge contributes its own conservative visual outset. A rounded or +smoothed container/rectangle projects one shared outline into fill coverage, +descendant clipping, and every repeated stroke while non-solid paint +coordinates retain the full rectangular paint box. Degenerate paint-box axes +use Draft 0's centered one-pixel coordinate fallback. With a host font, +resolution uses Skia Paragraph to produce one immutable `Arc` after +the text node's final width is known. That artifact records its oracle and +environment, input width constraint, final assigned box, line-break kinds, +UTF-8 cluster starts, semantic font identities, glyph IDs and positions, and +logical and ink bounds. Empty source owns one terminal line with default-font +metrics but no invented source or ink. The drawlist shares the same `Arc` with +the fill and every stroke and owns the exact per-resolution fonts behind the +artifact's local replay keys; paint never reshapes or reconstructs them. Text +world bounds start from glyph ink, and damage compares the complete text +artifact even when its box is unchanged. Every run paint still uses the +resolved full text-node box. Variable fonts receive the authored `wght` axis; +single-face hosts use documented synthetic fallbacks only when needed. + +This remains a proving engine rather than a claim that every future RFD area is +complete. Current limits are: + +- the host-font path is a Skia Paragraph bridge, not the deterministic oracle + still open in DEC-4: its font environment identity is process-local, its + constraint input is width-only, font fallback is deliberately disabled, and + paragraph direction is fixed to LTR. Unresolved glyphs produce an explicit + resolver report, but this proving resolver still returns the diagnostic + artifact rather than the RFD's final typed-failure API. Complete bidi, + source/cluster/caret mapping, paragraph controls, and cross-platform identity + remain open. Fontless probes explicitly use `stub@lab-0`; it emits line + metrics but no glyph runs or text pixels; +- 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 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 + from ordinary rounded-box overlap normalization; +- production's per-side ring path has no corner-smoothing input and does not + honor non-miter join state; Draft 0 therefore rejects nonuniform widths with + nonzero smoothing or nondefault join/miter geometry instead of silently + 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. 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–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, +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. +`rig/examples/rounded-surfaces.grida.xml` concentrates the rounded-box slice: +asymmetric circular corners, elliptical axes, continuous smoothing, clipped +descendants, rich fills, and repeated strokes. +`rig/examples/per-side-strokes.grida.xml` demonstrates asymmetric and zero +side widths, ordinary elliptical corners, continuous dashes, repeated rings, +and rich paints without duplicating scene geometry. +`rig/examples/rich-text.grida.xml` is the attributed-text specimen: mixed +sizes, weights, italic style, solid and gradient run fills, exact whitespace, +and derived UTF-8 ranges in one inspectable string. +`rig/fixtures/unit-path.grida.xml` is the focused path oracle: an even-odd unit +path with ordered fills and repeated strokes. The complete editorial showcase +also includes a nested, rich-painted path specimen. +`rig/fixtures/component-program/entry.grida.xml` and its sibling component +library are the focused Version 2 source-program oracle: one external boxed +component, two independently specialized uses, ordinary-scene lowering, and +component-blind pixel output. +`rig/fixtures/slot-program/entry.grida.xml` and its sibling Version 3 component +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, +and message dock. Reusable stories and suggestions plus two post instances +share one complete fixed-size social-post shell while caller-owned media trees +project original checked-in image paints through the same named slot. Compact +viewports therefore demonstrate continuous anchor response and honest clipping, +not an unimplemented breakpoint system. + +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/examples/rounded-surfaces.grida.xml \ + target/grida-xml-rounded-surfaces.png 1440 900 + +cargo run --bin grida_xml_render -- \ + rig/examples/per-side-strokes.grida.xml \ + target/grida-xml-per-side-strokes.png 1200 760 + +cargo run --bin grida_xml_render -- \ + rig/examples/rich-text.grida.xml \ + target/grida-xml-rich-text.png 1280 800 + +cargo run --bin grida_xml_render -- \ + rig/fixtures/nested-rects.grida.xml target/grida-xml-nested-rects.png 96 80 + +cargo run --bin grida_xml_render -- \ + rig/fixtures/unit-path.grida.xml target/grida-xml-unit-path.png 96 80 + +cargo run --bin grida_xml_render -- \ + rig/fixtures/component-program/entry.grida.xml \ + target/grida-xml-component-program.png 96 40 + +cargo run --bin grida_xml_render -- \ + rig/fixtures/slot-program/entry.grida.xml \ + target/grida-xml-slot-program.png 112 48 + +cargo run --bin grida_xml_render -- \ + rig/examples/social-feed/entry.grida.xml \ + target/grida-xml-social-feed-desktop.png 1920 1080 + +# Diagnostic viewport sweep (these renders are not reftest or golden oracles). +mkdir -p target/social-feed-responsive +for size in 1920x1080 1440x900 1280x800 1024x768 768x1024 390x844; do + width=${size%x*} + height=${size#*x} + cargo run --quiet --bin grida_xml_render -- \ + rig/examples/social-feed/entry.grida.xml \ + "target/social-feed-responsive/social-feed@${size}.png" \ + "$width" "$height" +done +``` + +The CLI owns filesystem access, resource bases, image decoding, a +platform-default typeface, the white raster background, and PNG encoding. +Relative component references and image RIDs resolve against the source unit +that authored them. Every visible image referenced by a node fill, run fill, +or stroke is decoded before the first frame; missing or invalid resources fail +with authored source and resolved location. The host materializes the source +program exactly once and renders its ordinary document through +`frame::render`. It refuses resolver error/ignored reports instead of writing +a fallback image; replay remains on its existing wire contract. + +This proving binary is a trusted local-file host, not a sandbox. It follows +absolute paths and `..` segments supplied by the source. Locations are decoded +XML path strings rather than file URIs, so percent escapes are not decoded. +Any application or server host must supply its own capability root, symlink and +network policy, and byte/decode limits. + +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). + +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 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 + +| concern | module | contract | guarding test | +| ------------------------------------ | ---------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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 | `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`, `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 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` 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) + +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`) +· pinned text oracle and complete fallback/bidi/caret mapping (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 --- + + + + + + revenue + + + + + + + + + + + + + + + + anchor spike — E10 + + +--- 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 --- + + + + + + revenue + + + + + + + + + + + + + + + + anchor spike — E10 + + +--- 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 --- + + + + + + revenue + + + + + + + + + + + + + + + + anchor spike — E10 + + +--- ops --- +{"Ungroup":{"id":12}} 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..27f5bc877b --- /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/nocturne-transit.grida.xml b/model-v2/engine/rig/examples/nocturne-transit.grida.xml new file mode 100644 index 0000000000..080b7d98f3 --- /dev/null +++ b/model-v2/engine/rig/examples/nocturne-transit.grida.xml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + NOCTURNE / SIGNAL MAP + NIGHT NETWORK · SERVICE WINDOW 23:00—05:00 · LIVE TOPOLOGY + + + + + + + + + + + + N7 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WEST ARCHIVE + + + + + HARBOR 04 + + + + + + + + + + + CX + + + + CENTRAL EXCHANGE + + + + + + NIGHT GARDEN + + + + + ORBIT SOUTH + + + + + VIOLET MARKET + + + + + + TERMINAL N9 + + + + + + + TRANSFER WINDOW · 4 MIN + + + GRID REF 37.5665N / 126.9780E + UPDATED 23:48:12 / LIVE + + + + + + + + + + + + + + NIGHT SERVICE + 4 LINES · 18 STATIONS · 2 TRANSFERS + + + + + Aqua / all night + + + + Violet / express + + + + Lime / orbital + + + + Amber / shuttle + + + + + SERVICE STATUS + + All routes operational + + Central: 4 min transfer + + + + + + + + NEXT NETWORK RESET + 04:42:18 + + + + NOCTURNE TRANSIT AUTHORITY · MAP IS DIAGRAMMATIC · NOT TO SCALE + N7 / CONTROL ROOM + + diff --git a/model-v2/engine/rig/examples/per-side-strokes.grida.xml b/model-v2/engine/rig/examples/per-side-strokes.grida.xml new file mode 100644 index 0000000000..30a4cdd6af --- /dev/null +++ b/model-v2/engine/rig/examples/per-side-strokes.grida.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + GRIDA / RECTANGULAR STROKE GEOMETRY + One outline. Four independent sides. + Each repeated stroke owns one geometry and one ordered paint stack. + + + + + + + + + + + + + + + + + + + + + TOP 4 · RIGHT 18 · BOTTOM 28 · LEFT 8 + Paint order survives side geometry. + The gradient still uses the card box; the ring only controls coverage. + + + + + + + + + + + + + + + ZERO-WIDTH SIDES + Right + bottom only + 0 10 18 0 + + + + + + ONE DASH PHASE + Continuous contour + 4 12 7 16 · 18 10 + + + + + + + + + + + + REPEATED GEOMETRY + Independent rings + OUTSIDE / INSIDE + + + width="top right bottom left" · ordinary elliptical corners · painter-ordered paints · no duplicate scene nodes + + diff --git a/model-v2/engine/rig/examples/prism-launch.grida.xml b/model-v2/engine/rig/examples/prism-launch.grida.xml new file mode 100644 index 0000000000..4c6d632cb0 --- /dev/null +++ b/model-v2/engine/rig/examples/prism-launch.grida.xml @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PRISM / 2027 + DESIGN SYSTEMS FOR LIVING SOFTWARE + SEOUL · 09:30 KST + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OPENING KEYNOTE · STAGE 01 + + + MAKE THE +SOURCE VISIBLE. + A new medium for interfaces, motion, and authored intelligence—built from one inspectable tree. + + + + + + + + + + + + + + + AK + + + ARI KIM + FOUNDER · SYSTEMS DESIGNER + + + + + + + + + + + + + + + + + + + + + + + + RESERVE A SEAT > + + + + + + + + + + VIEW PROGRAM + + + + + + + + + + + + + + + + + + + + + + + + + + LIVE SIGNAL / 04 + A scene you can read. + + + + + + + + + + + + + + + TREE > INTENT > PIXELS + + + + + + + LAYERS + ORDERED + + + + LAYOUT + RESPONSIVE + + + + SOURCE + INSPECTABLE + + + + STATUS + LIVE + + + + + + + + + ONE FILE · ONE TREE · NO HIDDEN STYLE LANGUAGE + + + diff --git a/model-v2/engine/rig/examples/pulse-analytics.grida.xml b/model-v2/engine/rig/examples/pulse-analytics.grida.xml new file mode 100644 index 0000000000..30099ebcf7 --- /dev/null +++ b/model-v2/engine/rig/examples/pulse-analytics.grida.xml @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PULSE + OPERATIONS / 7A + + + + + + + + + + + + + Overview + + + + Revenue + + + + Audience + + + + Automations + + + + Settings + + + + + + + + + + + + ACTIVE WORKSPACE + Northstar + 12 collaborators + + + + All systems live + + + + + + + Good morning, Maya. + Here is the network pulse for Monday. + + + + + / Search signals + + + + •• + + + + + + + + + MK + + + + + + + + + + + + + + + NET REVENUE + $284.6K + + +18.4% vs last cycle + + + + ACTIVE SIGNALS + 18,429 + + 1,204 newly qualified + + + + CONVERSION + 42.8% + + +3.1 points this week + + + + + + + + + + + AUTOMATION HOURS + 1,906 + + Saved across 32 flows + + + + + + + Signal velocity + Qualified events · trailing 8 weeks + ● +24.8% + + + + + + + + + + + + + + + + + + W18 + W19 + W20 + W21 + W22 + W23 + W24 + NOW + + + + + + + System health + + + 99 + + Ingestion + + Automation + + Delivery + + No incidents in the last 14 days + + + + + Live activity + + Northstar score recalculated + 12 seconds ago + + 24 contacts qualified + 3 minutes ago + + Campaign orbit completed + 18 minutes ago + + + + + + + + + + + + + + AI INSIGHT / 09:42 + Expansion signals are clustering 18% earlier. + Shift the enterprise nurture trigger from day 21 to day 17 to meet the new behavioral curve. + + + REVIEW ACTION > + + + + + diff --git a/model-v2/engine/rig/examples/rfc-only-dashboard.grida.xml b/model-v2/engine/rig/examples/rfc-only-dashboard.grida.xml new file mode 100644 index 0000000000..c94abd18d7 --- /dev/null +++ b/model-v2/engine/rig/examples/rfc-only-dashboard.grida.xml @@ -0,0 +1,271 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + O + + ORBIT + + + WORKSPACE + + + + + + + + + + + + + + Overview + + + + + Revenue + + + + + Audience + + + + + Reports + + + + + + + + + + + + + + Data synced + Updated 2 minutes ago + + + + + + + + Analytics overview + Performance across your active workspace + + + + + + + + + LIVE + + + + + + + + + + + + RK + + + + + + + + + + + + + + + + + + + + + NET REVENUE + $84.2K + +12.4% this month + + + + + + + + + + + CONVERSION + 7.86% + +0.8 pts vs prior + + + + + + + + + + ACTIVE USERS + 12,480 + 2,104 online now + + + + + + + + + + + + + + Revenue momentum + Daily net revenue in USD + + + + + LAST 7 DAYS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + M + T + W + T + F + S + S + + + + + + + + System health + + API + 99.99% + + Events + Healthy + + Exports + Queued + + + + + + + Top channels + + Direct + 48% + + + + Paid search + 31% + + + + Social + 21% + + + + + + + + diff --git a/model-v2/engine/rig/examples/rfc-only-keynote.grida.xml b/model-v2/engine/rig/examples/rfc-only-keynote.grida.xml new file mode 100644 index 0000000000..bed4f4c936 --- /dev/null +++ b/model-v2/engine/rig/examples/rfc-only-keynote.grida.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 27 + SEOUL + + + + + + + + + + + + + + + + + + NEON / NATIVE — CREATIVE TECHNOLOGY SUMMIT + + + + + + + + + + + MAKE + + + + + + + + + + + + + + + THE UNSEEN + + Where design, code, sound, and synthetic media become one instrument. + OPENING KEYNOTE / MINA VALE + + + + + + + + + + + + + DATE + 17—19 OCTOBER + + + + + + + + + + PLACE + SEOUL / KR + + + + + + + + + + SESSION + STAGE 01 / 09:30 + + + + diff --git a/model-v2/engine/rig/examples/rfc-only-transit.grida.xml b/model-v2/engine/rig/examples/rfc-only-transit.grida.xml new file mode 100644 index 0000000000..18888e3aa3 --- /dev/null +++ b/model-v2/engine/rig/examples/rfc-only-transit.grida.xml @@ -0,0 +1,492 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ORBITAL NIGHT GRID + NIGHT TRANSIT FIELD • 03 ACTIVE LINES • 18 STATIONS + 37.5665° N / 126.9780° E + SECTOR 07 — SERVICE MAP + + + + + + + + + + + + + ● NETWORK LIVE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A1 + + + + + + + + V2 + + + + + + + + S3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HARBOR GATE + HARBOR GATE + GLASSWORKS + GLASSWORKS + CENTRAL ARC + CENTRAL ARC + MOONRISE + MOONRISE + OBSERVATORY + OBSERVATORY + + NOVA YARD + NOVA YARD + VIOLET JUNCTION + VIOLET JUNCTION + AFTERGLOW + AFTERGLOW + + NORTH STACK + NORTH STACK + SUNLINE DEPOT + SUNLINE DEPOT + + + + + + + + + + + + + + + SYSTEM LEGEND + LINES / TRANSFERS / FIELD DATA + + + + + + + A1 AQUA + EAST LOOP + + + + + + + V2 VIOLET + NIGHT ARC + + + + + + + S3 SOLAR + MERIDIAN + + + + + + + + + + LOCAL STATION + SINGLE-LINE STOP + + + + + + + INTERCHANGE + MULTI-LINE TRANSFER + + MAP REV 07.11 / 23:48 KST + CONTROL ID: O-NG-620 + + + + + + + + + + HEADWAY 04:20 + SIGNAL 98.7% + PLATFORM TEMP 17°C + NIGHT WINDOW 23:30—05:10 + ALL CLEAR + + + A DYNAMIC 2D SCENE — LOCAL COORDINATES / ORDERED PAINT / MULTI-STROKE NETWORK GEOMETRY + GRIDA XML DRAFT 0 + + 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..e9062a6645 --- /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..5243bd1ccf --- /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/rich-text.grida.xml b/model-v2/engine/rig/examples/rich-text.grida.xml new file mode 100644 index 0000000000..ab805ef35e --- /dev/null +++ b/model-v2/engine/rig/examples/rich-text.grida.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + GRIDA XML / ONE STRING · COMPLETE RUNS · EXACT WHITESPACE + Type has one string, +many voices. + Flat runs. Styles stay inspectable, offsets stay derived, and authored spacing stays exactly where it was written. + + + + + + + + + + + + + + + CANONICAL SOURCE + <text font-size="32">Ship <tspan font-weight="700" fill="#67E8F9">boldly</tspan>.</text> + UTF-8 ranges are derived after XML decoding: café · naïve · λ / no byte offsets in source. + + NEUTRAL TAGS, SEMANTIC DATA · NO <b>, <i>, OR style="…" MINI-LANGUAGE + + diff --git a/model-v2/engine/rig/examples/rounded-surfaces.grida.xml b/model-v2/engine/rig/examples/rounded-surfaces.grida.xml new file mode 100644 index 0000000000..973276d5f5 --- /dev/null +++ b/model-v2/engine/rig/examples/rounded-surfaces.grida.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + GRIDA / ROUNDED SURFACES + ONE OUTLINE · EVERY PAINT · EVERY STROKE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + THE BOX IS STILL THE PAINT SPACE + Corners become +first-class geometry. + Fill coverage, clipped descendants, and repeated strokes all derive from one authored outline. + + + + + + + + + + + + + + + + + 01 / FOUR CORNERS + Asymmetric, still semantic. + 44 12 44 12 · TL TR BR BL + + + + + + + + + + + + + + + + + + + + 02 / ELLIPTICAL + Independent rx / ry. + Horizontal / vertical radii remain source. + + + + + + + + + + + + + + + + + + + + 03 / CONTINUOUS + Surgical smoothing. + One scalar; shape identity survives. + + + + CIRCULAR · ELLIPTICAL · CONTINUOUS + AUTHORED VALUES STAY UNRESOLVED + DRAFT 0 / RENDERED + + + corner-radius + corner-smoothing · local geometry before transforms · rectangular paint coordinates + + diff --git a/model-v2/engine/rig/examples/social-feed/assets/post-coastal-cabin.webp b/model-v2/engine/rig/examples/social-feed/assets/post-coastal-cabin.webp new file mode 100644 index 0000000000..48c9834cc1 Binary files /dev/null and b/model-v2/engine/rig/examples/social-feed/assets/post-coastal-cabin.webp differ 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 0000000000..2927f28c9b Binary files /dev/null and b/model-v2/engine/rig/examples/social-feed/assets/post-orange-studio.webp differ diff --git a/model-v2/engine/rig/examples/social-feed/entry.grida.xml b/model-v2/engine/rig/examples/social-feed/entry.grida.xml new file mode 100644 index 0000000000..74ce346965 --- /dev/null +++ b/model-v2/engine/rig/examples/social-feed/entry.grida.xml @@ -0,0 +1,268 @@ + + + + + + + + + + + + + + + + + + {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/examples/source-becomes-surface.grida.xml b/model-v2/engine/rig/examples/source-becomes-surface.grida.xml new file mode 100644 index 0000000000..f91bf9c053 --- /dev/null +++ b/model-v2/engine/rig/examples/source-becomes-surface.grida.xml @@ -0,0 +1,419 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + + PATH × BOXED + + + + 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/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/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/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/rig/fixtures/unit-path.grida.xml b/model-v2/engine/rig/fixtures/unit-path.grida.xml new file mode 100644 index 0000000000..2dd2248ea2 --- /dev/null +++ b/model-v2/engine/rig/fixtures/unit-path.grida.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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/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() + ); +} diff --git a/model-v2/engine/src/bin/gate.rs b/model-v2/engine/src/bin/gate.rs new file mode 100644 index 0000000000..a06924d296 --- /dev/null +++ b/model-v2/engine/src/bin/gate.rs @@ -0,0 +1,360 @@ +//! 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, frame, 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).expect("corpus oracle must be supported"); + let (d2, res2) = replay::play(&rep).expect("corpus oracle must be supported"); + 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 — 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)"); + let opts = ResolveOptions { + viewport: (2000.0, 1400.0), + rotation_in_flow: RotationInFlow::VisualOnly, + }; + 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; + 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).expect("corpus oracle must be supported"); + 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 {}", + 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) + .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 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!( + " {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(); + // The replay benchmark deliberately measures the deterministic lab + // oracle. Shaped rendering enters through `frame::resolve_and_build`. + 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); + } + (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/bin/grida_xml_render.rs b/model-v2/engine/src/bin/grida_xml_render.rs new file mode 100644 index 0000000000..b1a879ecea --- /dev/null +++ b/model-v2/engine/src/bin/grida_xml_render.rs @@ -0,0 +1,658 @@ +//! Thin local-file host for the versioned `.grida.xml` proof. +//! +//! 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::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +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}; +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]"; + +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::() + .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 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, + 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())); + } + } + if let Payload::AttributedText { + attributed_string, .. + } = &node.payload + { + for paint in attributed_string + .runs + .iter() + .filter_map(|run| run.fills.as_ref()) + .flat_map(|fills| 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.renderable_for(&node.payload, node.corner_smoothing)) + { + 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. 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( + program: &MaterializedProgram, + paint_ctx: &mut PaintCtx, +) -> Result<(), String> { + 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 runtime_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) + )); + } + }; + 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 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 `{authored}` authored in {source} resolved to {}: {error}", + node_label(doc, node), + resolved.display() + ) + })?; + paint_ctx + .insert_encoded(runtime_rid.clone(), &bytes) + .map_err(|error| { + format!( + "{} image `{authored}` authored in {source} 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( + 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 } + | Report::ErrorByRule { node, field, rule } => (*node, *field, *rule), + Report::Clamped { .. } => continue, + }; + return Err(format!( + "{} could not resolve `{field}`: {rule}{}", + node_label(doc, node), + provenance_suffix(program, 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 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"))?; + 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(&program, &mut paint_ctx)?; + let (product, _) = frame::render( + surface.canvas(), + doc, + &options, + &Affine::IDENTITY, + &paint_ctx, + ) + .map_err(|error| format!("frame construction failed: {error}"))?; + ensure_resolved_without_errors(&program, product.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 std::sync::atomic::{AtomicU64, Ordering}; + + 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 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() + } + + 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 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 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("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] + fn image_resources_resolve_from_the_document_directory() { + let rid = "../../../../fixtures/images/border-diamonds.png"; + let source = format!( + r#""# + ); + 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(&program, &mut ctx).unwrap(); + assert!(ctx.contains_image(&runtime_rid)); + } + + #[test] + fn image_resources_are_discovered_in_authored_strokes() { + let rid = "../../../../fixtures/images/border-diamonds.png"; + let source = format!( + r#""# + ); + let program = materialize_at_fixture(&source); + let runtime_rid = program.resources[0].runtime_rid.clone(); + let mut ctx = PaintCtx::new(None); + 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 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(&program, &mut ctx).unwrap(); + assert!(ctx.contains_image(&runtime_rid)); + } + + #[test] + fn missing_attributed_run_image_reports_its_authored_resource() { + 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}" + ); + } + + #[test] + fn image_resource_errors_name_authored_and_resolved_locations() { + let missing = materialize_at_fixture( + r#""#, + ); + let mut ctx = PaintCtx::new(None); + 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 = materialize_at_fixture( + r#""#, + ); + let error = load_image_resources(&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") + ); + } + + #[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, + ) + .expect("valid multi-source image frame"); + 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/bin/probe.rs b/model-v2/engine/src/bin/probe.rs new file mode 100644 index 0000000000..9128165000 --- /dev/null +++ b/model-v2/engine/src/bin/probe.rs @@ -0,0 +1,636 @@ +//! probe — the autonomous render-perf harness (deterministic CPU raster). +//! +//! 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 = +//! 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, resolve_and_build}, + 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::{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).fills = Paints::solid(format!("#{}", palette[i % palette.len()]).into()); + } + 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 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" { + 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 previous = reference.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, + previous.resolved(), + &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, + previous.resolved(), + &Op::SetRotation { id: target, deg }, + ); + } + "mutate_color" => { + // 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()); + } + _ => {} + }, + 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, previous.resolved(), &Op::SetRotation { id, deg }); + } + } else { + let hex = ["4A90D9", "E2574C", "57B894"][frame % 3]; + for &id in &ids[..k] { + doc.get_mut(id).fills = Paints::solid(format!("#{hex}").into()); + } + } + } + } + + // ── the measured frame: clear + render (resolve+build+execute) ── + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let t = Instant::now(); + 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(product.drawlist().items.len()); + black_box(product.resolved().resolved_count()); + + let dmg = damage::diff_frame(&previous, &product); + + 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()); + + 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_frame(&reference, &product).is_empty() { + 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; + } + } + } + + previous = product; + } + + // ── 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) + .expect("benchmark scene must pass paint preflight"); + 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::new(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..ef5540b185 --- /dev/null +++ b/model-v2/engine/src/bin/probe_gpu.rs @@ -0,0 +1,354 @@ +//! 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::new(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) + .expect("GPU probe scene must pass paint preflight"); + } + 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 + ); + } + } +} diff --git a/model-v2/engine/src/cache.rs b/model-v2/engine/src/cache.rs new file mode 100644 index 0000000000..e8cac6185d --- /dev/null +++ b/model-v2/engine/src/cache.rs @@ -0,0 +1,329 @@ +//! 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 +//! 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 +//! 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, 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_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, + viewport_height: u32, + rotation_in_flow: RotationInFlow, +} + +impl From<&ResolveOptions> for ResolveOptionsKey { + fn from(options: &ResolveOptions) -> Self { + Self { + viewport_width: options.viewport.0.to_bits(), + viewport_height: options.viewport.1.to_bits(), + rotation_in_flow: options.rotation_in_flow, + } + } +} + +/// 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 + /// camera re-rasters (pan-out / zoom). Semantic input changes rebuild it. + list: Option, + ref_view: Affine, + vw: i32, + vh: i32, + /// 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. + 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 { + pub fn new(vw: i32, vh: i32) -> Self { + SceneCache { + image: None, + list: None, + ref_view: Affine::IDENTITY, + vw, + vh, + environment_key: None, + opts_key: None, + scene_key: None, + values_key: PropertyValues::default(), + } + } + + /// 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, + ) -> 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 + && view.b == self.ref_view.b + && view.c == self.ref_view.c + && view.d == self.ref_view.d; + let rebuild_list = doc_dirty + || self.list.is_none() + || 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 + || dx.abs() > MARGIN + || dy.abs() > MARGIN; + + if reraster { + self.raster(canvas, values, values_key, opts, view, ctx, rebuild_list)?; + } + + // 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); + Ok(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, + values: &ValueView<'_>, + values_key: &PropertyValues, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, + rebuild_list: bool, + ) -> 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); + let mut off = canvas + .new_surface(&info, None) + .expect("backend-matched offscreen surface"); + let oc = off.canvas(); + oc.clear(Color::WHITE); + 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(); + + // 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.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_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( + cache: &mut SceneCache, + doc: &Document, + 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)?; + 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_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 new file mode 100644 index 0000000000..c2fb389165 --- /dev/null +++ b/model-v2/engine/src/damage.rs @@ -0,0 +1,203 @@ +//! 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}; + +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, + 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 = BTreeSet::new(); + for id in 0..n as NodeId { + if slot_changed(prev, next, id) { + 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.into_iter().collect(), + 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) + // Text pixels can change without moving the node or changing its ink + // envelope: a different exact font, glyph id, cluster topology, or + // positioned run is still material damage. Compare the complete + // backend-independent artifact rather than guessing from geometry. + || prev.text_layout_opt(id) != next.text_layout_opt(id) + // A path can change commands or fill rule without changing its box or + // even its tight bounds. Its shared box-mapped artifact is therefore + // part of resolved visual identity, just like shaped text. + || path_changed(prev, next, id) +} + +fn path_changed(prev: &Resolved, next: &Resolved, id: NodeId) -> bool { + match (prev.resolved_path_opt(id), next.resolved_path_opt(id)) { + (None, None) => false, + (Some(before), Some(after)) => !before.same_visual_geometry(after), + _ => true, + } +} + +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..b9a5c56e7c --- /dev/null +++ b/model-v2/engine/src/drawlist.rs @@ -0,0 +1,618 @@ +//! 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_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. + +use anchor_lab::math::Affine; +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; + +use crate::text_layout::TextFontRegistry; + +/// 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, +} + +/// Paint ownership for one resolved text layout. +/// +/// Shaping ignores paint values, but attributed run boundaries remain indexed +/// in the resolved glyph runs. An outer `None` denotes uniform text, while an +/// attributed entry of `None` inherits node paints and `Some([])` means +/// explicit no ink. Keeping those states distinct lets invalid attributed run +/// ownership fail closed instead of being mistaken for uniform-text fallback. +#[derive(Debug, Clone, PartialEq)] +pub struct TextPaints { + pub node: Paints, + pub runs: Option>>, +} + +impl TextPaints { + /// Resolve paints only when glyph ownership matches the authored topology. + pub fn for_source_run(&self, source_run: Option) -> Option<&Paints> { + match (&self.runs, source_run) { + (None, None) => Some(&self.node), + (Some(runs), Some(index)) => runs + .get(index) + .map(|paints| paints.as_ref().unwrap_or(&self.node)), + _ => None, + } + } + + fn has_visible_ink(&self) -> bool { + match &self.runs { + None => !self.node.is_empty(), + Some(runs) => runs + .iter() + .any(|paints| !paints.as_ref().unwrap_or(&self.node).is_empty()), + } + } +} + +/// 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, + corner_radius: RectangularCornerRadius, + corner_smoothing: CornerSmoothing, + }, + EndClip, + RectFill { + w: f32, + h: f32, + corner_radius: RectangularCornerRadius, + corner_smoothing: CornerSmoothing, + paints: Paints, + }, + OvalFill { + w: f32, + h: f32, + paints: Paints, + }, + PathFill { + w: f32, + h: f32, + path: Arc, + paints: Paints, + }, + TextFill { + layout: Arc, + paints: TextPaints, + paint_w: f32, + paint_h: f32, + }, + RectStroke { + w: f32, + h: f32, + corner_radius: RectangularCornerRadius, + corner_smoothing: CornerSmoothing, + stroke: Stroke, + }, + OvalStroke { + w: f32, + h: f32, + stroke: Stroke, + }, + LineStroke { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + paint_w: f32, + paint_h: f32, + stroke: Stroke, + }, + PathStroke { + w: f32, + h: f32, + path: Arc, + stroke: Stroke, + }, + TextStroke { + layout: Arc, + paint_w: f32, + paint_h: f32, + stroke: Stroke, + }, +} + +/// The whole scene as an ordered primitive stream, in paint order +/// (node fill, clipped children, then authored strokes). Diffable by `==`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DrawList { + pub items: Vec, + /// Exact fonts referenced by glyph-bearing text items. Kept opaque outside + /// the engine: display-list consumers replay keys only through this list. + text_fonts: Option>, +} + +impl DrawList { + pub(crate) fn text_fonts(&self) -> &TextFontRegistry { + self.text_fonts + .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 +/// 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, + payload: &Payload, + corner_smoothing: CornerSmoothing, +) -> Option { + if !stroke.renderable_for(payload, corner_smoothing) { + 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_paints(payload: &Payload, node_fills: &Paints) -> TextPaints { + let text = payload + .as_text() + .expect("text paint materialization requires text"); + let runs = text.runs.map(|runs| { + runs.iter() + .map(|run| run.fills.as_ref().map(visible_paints)) + .collect() + }); + TextPaints { + node: visible_paints(node_fills), + runs, + } +} + +/// Project a glyphless lab-resolved tier into an ordered primitive stream. +/// +/// Real rendering must enter through [`crate::frame::resolve_and_build`] or +/// [`crate::frame::render`]. 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. +/// 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_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( + doc: &Document, + resolved: &Resolved, + text_fonts: Arc, +) -> DrawList { + build_inner(doc, resolved, Some(text_fonts)) +} + +/// 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(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() + } + _ => false, + }); + assert!( + !has_glyphs || text_fonts.is_some(), + "glyph-bearing resolved text requires its exact font registry" + ); + DrawList { + items, + text_fonts: if has_glyphs { text_fonts } else { None }, + } +} + +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 + .payload + .as_text() + .map(|_| Arc::clone(resolved.text_layout_of(id))); + let text_paints = node + .payload + .as_text() + .map(|_| materialize_text_paints(&node.payload, values.fills(id))); + + let opacity = values.opacity(id); + let opacity_scope = opacity != 1.0; + if opacity_scope { + push(items, id, world, ItemKind::BeginOpacity { 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() { + match &node.payload { + Payload::Frame { .. } => { + 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, + world, + ItemKind::RectFill { + w: b.w, + h: b.h, + 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(values.fills(id)); + if !paints.is_empty() { + let kind = match desc { + ShapeDesc::Rect => Some(ItemKind::RectFill { + w: b.w, + h: b.h, + corner_radius: values.corner_radius(id), + corner_smoothing: values.corner_smoothing(id), + paints, + }), + ShapeDesc::Ellipse => Some(ItemKind::OvalFill { + w: b.w, + h: b.h, + paints, + }), + ShapeDesc::Path(_) => { + resolved + .resolved_path_opt(id) + .map(|path| ItemKind::PathFill { + w: b.w, + h: b.h, + path: Arc::clone(path), + paints, + }) + } + ShapeDesc::Line => unreachable!("line matched before fill lookup"), + }; + if let Some(kind) = kind { + push(items, id, world, kind); + } + } + } + Payload::Text { .. } | Payload::AttributedText { .. } => { + let paints = text_paints + .as_ref() + .expect("text fill has a paint fallback table"); + if paints.has_visible_ink() { + push( + items, + id, + world, + ItemKind::TextFill { + layout: text_layout + .as_ref() + .expect("text fill has resolved layout") + .clone(), + paints: paints.clone(), + paint_w: b.w, + paint_h: b.h, + }, + ); + } + } + // Excluded by the box_is_derived guard above. + Payload::Group | Payload::Lens { .. } => unreachable!(), + } + } + + 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, + world, + ItemKind::BeginClipRect { + w: b.w, + h: b.h, + corner_radius, + corner_smoothing, + }, + ); + } + + for &c in &node.children { + emit(values, resolved, c, items); + } + + if clip_scope { + push(items, id, world, ItemKind::EndClip); + } + + if id != doc.root && !node.payload.box_is_derived() { + 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, corner_smoothing)) + { + if matches!( + &node.payload, + Payload::Shape { + desc: ShapeDesc::Path(_) + } + ) && resolved.resolved_path_opt(id).is_none() + { + continue; + } + let kind = match &node.payload { + Payload::Frame { .. } + | Payload::Shape { + desc: ShapeDesc::Rect, + } => ItemKind::RectStroke { + w: b.w, + h: b.h, + corner_radius: values.corner_radius(id), + corner_smoothing, + 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::Shape { + desc: ShapeDesc::Path(_), + } => ItemKind::PathStroke { + w: b.w, + h: b.h, + path: Arc::clone(resolved.resolved_path_of(id)), + stroke, + }, + Payload::Text { .. } | Payload::AttributedText { .. } => ItemKind::TextStroke { + layout: text_layout + .as_ref() + .expect("visible text stroke has resolved layout") + .clone(), + 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); + } +} + +#[cfg(test)] +mod text_paint_tests { + use super::TextPaints; + use anchor_lab::model::{Color, Paints}; + + #[test] + fn uniform_text_uses_node_paints_only_for_uniform_ownership() { + let node = Paints::solid(Color::BLACK); + let paints = TextPaints { + node: node.clone(), + runs: None, + }; + + assert_eq!(paints.for_source_run(None), Some(&node)); + assert_eq!(paints.for_source_run(Some(0)), None); + } + + #[test] + fn attributed_text_fails_closed_without_valid_run_ownership() { + let node = Paints::solid(Color::BLACK); + let override_paints = Paints::solid("#FF0000".into()); + let paints = TextPaints { + node: node.clone(), + runs: Some(vec![ + None, + Some(override_paints.clone()), + Some(Paints::default()), + ]), + }; + + assert_eq!(paints.for_source_run(Some(0)), Some(&node)); + assert_eq!(paints.for_source_run(Some(1)), Some(&override_paints)); + assert!(paints + .for_source_run(Some(2)) + .expect("valid explicit-empty run") + .is_empty()); + assert_eq!(paints.for_source_run(None), None); + assert_eq!(paints.for_source_run(Some(3)), None); + } +} diff --git a/model-v2/engine/src/frame.rs b/model-v2/engine/src/frame.rs new file mode 100644 index 0000000000..400d70b28e --- /dev/null +++ b/model-v2/engine/src/frame.rs @@ -0,0 +1,330 @@ +//! ENG-2.4 socket · the one frame entry point. `render(...)` (step 6) +//! 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::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, 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. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FrameStats { + pub resolve_ns: u128, + pub build_ns: u128, + pub execute_ns: u128, +} + +/// Resolve a document with the host text environment and build a replayable +/// drawlist that owns every exact font selected during shaping. +/// +/// 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_unchecked`]. +pub fn resolve_and_build( + doc: &Document, + opts: &ResolveOptions, + ctx: &PaintCtx, +) -> 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, +) -> 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(); + 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 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( + canvas: &skia_safe::Canvas, + doc: &Document, + opts: &ResolveOptions, + view: &Affine, + ctx: &PaintCtx, +) -> 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(); + product.execute(canvas, view, ctx)?; + stats.execute_ns = t0.elapsed().as_nanos(); + Ok((product, stats)) +} diff --git a/model-v2/engine/src/ident.rs b/model-v2/engine/src/ident.rs new file mode 100644 index 0000000000..2e1f06b81c --- /dev/null +++ b/model-v2/engine/src/ident.rs @@ -0,0 +1,16 @@ +//! 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}; + +/// The single arena-scoped, generation-stamped runtime node identity. +pub use anchor_lab::model::NodeKey as Key; + +/// 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/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..bb475780b0 --- /dev/null +++ b/model-v2/engine/src/lib.rs @@ -0,0 +1,28 @@ +//! 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 + 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 +//! 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. Raster access is confined to [`paint`]; +//! [`text_layout`] may use Skia Paragraph only as an explicit shaping oracle. + +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; +mod text_layout; +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..876be03a50 --- /dev/null +++ b/model-v2/engine/src/oracle.rs @@ -0,0 +1,32 @@ +//! 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 before +//! execution, so a tier measured under one oracle can never be silently +//! reinterpreted by another. The deterministic lab/replay path uses an +//! explicitly glyphless stub; host-font frames use a separately identified +//! Skia Paragraph bridge. The production oracle choice remains DEC-4/OS-4a. + +/// Deterministic, glyphless lab text metric used by legacy replays. +pub const TEXT_STUB: &str = "text=stub@lab-0"; + +/// First real text-layout oracle hosted by the model-v2 engine. +/// +/// This identifies the shaping/layout result, not a persisted document value. +/// A promoted deterministic oracle will receive a new version rather than +/// silently reinterpreting existing resolved artifacts. +pub const TEXT_SKPARAGRAPH: &str = "skparagraph@skia-0.93.1"; + +/// The oracle version set stamped into the current lab 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..ff0957a70b --- /dev/null +++ b/model-v2/engine/src/paint.rs @@ -0,0 +1,1814 @@ +//! 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_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. 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}; + +use anchor_lab::math::Affine; +use anchor_lab::model::{ + Alignment, BlendMode, BoxFit, DiamondGradientPaint, GradientStop, ImageFilters, ImagePaint, + ImagePaintFit, LinearGradientPaint, Paint as ModelPaint, Paints, RadialGradientPaint, + RectangularCornerRadius, RectangularStrokeWidth, ResourceRef, Stroke, StrokeAlign, StrokeCap, + 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::{ + image::CachingHint, path_effect::PathEffect, shaders, stroke_rec::InitStyle, Blender, Canvas, + ClipOp, Color, CubicResampler, Data, Font, Image, ImageInfo, Matrix, Paint, PaintCap, + PaintJoin, PaintStyle, Path, PathBuilder, PathDirection, PathFillType, PathOp, Point, RRect, + Rect, SamplingOptions, Shader, StrokeRec, +}; + +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 +/// 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, + revision: u64, + font: Option, + images: BTreeMap, +} + +impl PaintCtx { + pub fn new(font: Option) -> Self { + Self { + id: fresh_paint_context_id(), + revision: 0, + font, + images: BTreeMap::new(), + } + } + + /// 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_revision(&mut self) { + self.revision = self + .revision + .checked_add(1) + .expect("paint context revision exhausted"); + } + + /// Typeface offered to text resolution. The value is immutable through a + /// shared reference so a cache cannot miss an environment change. + pub fn font(&self) -> Option<&skia_safe::Typeface> { + self.font.as_ref() + } + + /// 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; + } + + /// 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); + 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`. + 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) + } +} + +impl Default for PaintCtx { + fn default() -> Self { + Self::new(None) + } +} + +#[cfg(test)] +mod paint_ctx_tests { + use super::{sk_paint, PaintBox, PaintCtx}; + 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() { + 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 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; + 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 +/// 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) +} + +fn with_local_transform(canvas: &Canvas, view: &Affine, world: &Affine, draw: impl FnOnce()) { + let total = view.then(world); + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + draw(); + canvas.restore(); +} + +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 +} + +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. +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) + .expect("preflighted linear gradient shader construction failed"), + ); + } + ModelPaint::RadialGradient(model) => { + 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) + .expect("preflighted sweep gradient shader construction failed"), + ); + } + ModelPaint::DiamondGradient(model) => { + 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)?); + } + } + // 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) +} + +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) +} + +fn uniform_stroke_width(stroke: &Stroke) -> Option { + match stroke.width.normalized() { + StrokeWidth::None => None, + StrokeWidth::Uniform(width) => Some(width), + StrokeWidth::Rectangular(_) => None, + } +} + +/// 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 Some(width) = uniform_stroke_width(stroke) else { + return Path::new(); + }; + let align = if source.is_last_contour_closed() { + stroke.align + } else { + StrokeAlign::Center + }; + let stroke_width = match align { + StrokeAlign::Center => width, + StrokeAlign::Inside | StrokeAlign::Outside => 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 ordinary_rrect_path_at(rect: Rect, radius: &RectangularCornerRadius) -> Path { + let rrect = RRect::new_rect_radii( + rect, + &[ + (radius.tl.rx, radius.tl.ry).into(), + (radius.tr.rx, radius.tr.ry).into(), + (radius.br.rx, radius.br.ry).into(), + (radius.bl.rx, radius.bl.ry).into(), + ], + ); + // Point 0 is where the top-left curve joins the top edge. Keeping the + // authored clockwise origin makes dash traversal deterministic. + Path::rrect_with_start_index(rrect, PathDirection::CW, 0) +} + +fn ordinary_rrect_path(w: f32, h: f32, radius: &RectangularCornerRadius) -> Path { + ordinary_rrect_path_at(Rect::from_wh(w, h), radius) +} + +/// Mirror the production engine's orthogonal smooth-corner construction. +/// +/// That construction is circular-only today, so the XML boundary rejects +/// nonzero smoothing when any authored `rx != ry`. The defensive `min` here +/// retains production behavior for programmatically-built documents that do +/// not pass through the XML validator. +fn smooth_rrect_path(w: f32, h: f32, radius: &RectangularCornerRadius, smoothing: f32) -> Path { + let shortest_side = w.min(h); + let tl = smooth_corner_params(radius.tl.rx.min(radius.tl.ry), smoothing, shortest_side); + let tr = smooth_corner_params(radius.tr.rx.min(radius.tr.ry), smoothing, shortest_side); + let br = smooth_corner_params(radius.br.rx.min(radius.br.ry), smoothing, shortest_side); + let bl = smooth_corner_params(radius.bl.rx.min(radius.bl.ry), smoothing, shortest_side); + let mut builder = PathBuilder::new(); + + // Start where the top-left curve joins the top edge, then wind clockwise. + // This preserves the documented dash origin while tracing the same curve + // as the production path, which starts midway along that top edge. + builder.move_to((tl.extent.min(w / 2.0), 0.0)); + builder.line_to(((w - tr.extent).max(w / 2.0), 0.0)); + + if tr.radius > 0.0 { + builder.cubic_to( + (w - (tr.extent - tr.a), 0.0), + (w - (tr.extent - tr.a - tr.b), 0.0), + (w - (tr.extent - tr.a - tr.b - tr.c), tr.d), + ); + builder.arc_to( + Rect::from_xywh(w - tr.radius * 2.0, 0.0, tr.radius * 2.0, tr.radius * 2.0), + 270.0 + tr.bezier_angle, + 90.0 - 2.0 * tr.bezier_angle, + false, + ); + builder.cubic_to( + (w, tr.extent - tr.a - tr.b), + (w, tr.extent - tr.a), + (w, tr.extent.min(h / 2.0)), + ); + } + + builder.line_to((w, (h - br.extent).max(h / 2.0))); + if br.radius > 0.0 { + builder.cubic_to( + (w, h - (br.extent - br.a)), + (w, h - (br.extent - br.a - br.b)), + (w - br.d, h - (br.extent - br.a - br.b - br.c)), + ); + builder.arc_to( + Rect::from_xywh( + w - br.radius * 2.0, + h - br.radius * 2.0, + br.radius * 2.0, + br.radius * 2.0, + ), + br.bezier_angle, + 90.0 - 2.0 * br.bezier_angle, + false, + ); + builder.cubic_to( + (w - (br.extent - br.a - br.b), h), + (w - (br.extent - br.a), h), + ((w - br.extent).max(w / 2.0), h), + ); + } + + builder.line_to((bl.extent.min(w / 2.0), h)); + if bl.radius > 0.0 { + builder.cubic_to( + (bl.extent - bl.a, h), + (bl.extent - bl.a - bl.b, h), + (bl.extent - bl.a - bl.b - bl.c, h - bl.d), + ); + builder.arc_to( + Rect::from_xywh(0.0, h - bl.radius * 2.0, bl.radius * 2.0, bl.radius * 2.0), + 90.0 + bl.bezier_angle, + 90.0 - 2.0 * bl.bezier_angle, + false, + ); + builder.cubic_to( + (0.0, h - (bl.extent - bl.a - bl.b)), + (0.0, h - (bl.extent - bl.a)), + (0.0, (h - bl.extent).max(h / 2.0)), + ); + } + + builder.line_to((0.0, tl.extent.min(h / 2.0))); + if tl.radius > 0.0 { + builder.cubic_to( + (0.0, tl.extent - tl.a), + (0.0, tl.extent - tl.a - tl.b), + (tl.d, tl.extent - tl.a - tl.b - tl.c), + ); + builder.arc_to( + Rect::from_xywh(0.0, 0.0, tl.radius * 2.0, tl.radius * 2.0), + 180.0 + tl.bezier_angle, + 90.0 - 2.0 * tl.bezier_angle, + false, + ); + builder.cubic_to( + (tl.extent - tl.a - tl.b, 0.0), + (tl.extent - tl.a, 0.0), + (tl.extent.min(w / 2.0), 0.0), + ); + } + + builder.close(); + builder.snapshot() +} + +fn rounded_rect_path(w: f32, h: f32, radius: &RectangularCornerRadius, smoothing: f32) -> Path { + if radius.is_zero() { + rect_path(w, h) + } else if smoothing == 0.0 { + ordinary_rrect_path(w, h, radius) + } else { + smooth_rrect_path(w, h, radius, smoothing) + } +} + +fn expand_rect_by_widths(rect: Rect, widths: RectangularStrokeWidth, fraction: f32) -> Rect { + Rect::from_ltrb( + rect.left - widths.stroke_left_width * fraction, + rect.top - widths.stroke_top_width * fraction, + rect.right + widths.stroke_right_width * fraction, + rect.bottom + widths.stroke_bottom_width * fraction, + ) +} + +/// Insets without allowing Skia's `Rect::from_ltrb` normalization to turn an +/// overconsumed inner box inside out. Once either axis is exhausted the inner +/// contour is empty and the ring saturates to its outer contour. +fn inset_rect_by_widths(rect: Rect, widths: RectangularStrokeWidth, fraction: f32) -> Option { + let left = rect.left + widths.stroke_left_width * fraction; + let top = rect.top + widths.stroke_top_width * fraction; + let right = rect.right - widths.stroke_right_width * fraction; + let bottom = rect.bottom - widths.stroke_bottom_width * fraction; + (left < right && top < bottom).then(|| Rect::from_ltrb(left, top, right, bottom)) +} + +fn offset_radii_by_widths( + radius: &RectangularCornerRadius, + widths: RectangularStrokeWidth, + fraction: f32, +) -> RectangularCornerRadius { + let mut adjusted = *radius; + adjusted.tl.rx = (adjusted.tl.rx + widths.stroke_left_width * fraction).max(0.0); + adjusted.tl.ry = (adjusted.tl.ry + widths.stroke_top_width * fraction).max(0.0); + adjusted.tr.rx = (adjusted.tr.rx + widths.stroke_right_width * fraction).max(0.0); + adjusted.tr.ry = (adjusted.tr.ry + widths.stroke_top_width * fraction).max(0.0); + adjusted.br.rx = (adjusted.br.rx + widths.stroke_right_width * fraction).max(0.0); + adjusted.br.ry = (adjusted.br.ry + widths.stroke_bottom_width * fraction).max(0.0); + adjusted.bl.rx = (adjusted.bl.rx + widths.stroke_left_width * fraction).max(0.0); + adjusted.bl.ry = (adjusted.bl.ry + widths.stroke_bottom_width * fraction).max(0.0); + adjusted +} + +fn rectangular_stroke_contours( + w: f32, + h: f32, + widths: RectangularStrokeWidth, + radius: &RectangularCornerRadius, + align: StrokeAlign, +) -> (Path, Option) { + let base = Rect::from_wh(w, h); + let (outward, inward) = match align { + StrokeAlign::Inside => (0.0, 1.0), + StrokeAlign::Center => (0.5, 0.5), + StrokeAlign::Outside => (1.0, 0.0), + }; + let outer_rect = expand_rect_by_widths(base, widths, outward); + let outer_radius = offset_radii_by_widths(radius, widths, outward); + let outer = ordinary_rrect_path_at(outer_rect, &outer_radius); + let inner = inset_rect_by_widths(base, widths, inward).map(|inner_rect| { + let inner_radius = offset_radii_by_widths(radius, widths, -inward); + ordinary_rrect_path_at(inner_rect, &inner_radius) + }); + (outer, inner) +} + +fn rectangular_stroke_ring(outer: &Path, inner: Option<&Path>) -> Path { + match inner { + Some(inner) => skia_safe::op(outer, inner, PathOp::Difference).unwrap_or_default(), + None => outer.clone(), + } +} + +fn rectangular_stroke_centerline( + w: f32, + h: f32, + widths: RectangularStrokeWidth, + radius: &RectangularCornerRadius, + align: StrokeAlign, +) -> Option { + let base = Rect::from_wh(w, h); + let (rect, radius) = match align { + StrokeAlign::Inside => ( + inset_rect_by_widths(base, widths, 0.5)?, + offset_radii_by_widths(radius, widths, -0.5), + ), + StrokeAlign::Center => (base, *radius), + StrokeAlign::Outside => ( + expand_rect_by_widths(base, widths, 0.5), + offset_radii_by_widths(radius, widths, 0.5), + ), + }; + Some(ordinary_rrect_path_at(rect, &radius)) +} + +/// Project Grida's rectangular stroke-width union into one filled ring. +/// Solid strokes use the exact outer-minus-inner ring. Dashed strokes advance +/// once around a shared centerline at the maximum side width, then intersect +/// that outline with the ring so zero/thin sides suppress coverage without +/// resetting dash phase. +fn rectangular_stroke_geometry( + w: f32, + h: f32, + radius: &RectangularCornerRadius, + widths: RectangularStrokeWidth, + stroke: &Stroke, +) -> Path { + if widths.is_none() { + return Path::new(); + } + let (outer, inner) = rectangular_stroke_contours(w, h, widths, radius, stroke.align); + let ring = rectangular_stroke_ring(&outer, inner.as_ref()); + let Some(values) = stroke.dash_array.as_deref() else { + return ring; + }; + if values.is_empty() { + return ring; + } + let Some(intervals) = normalized_dash_array(values) else { + return Path::new(); + }; + let centerline = + rectangular_stroke_centerline(w, h, widths, radius, stroke.align).unwrap_or(outer); + 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(¢erline, &filter_rec, centerline.bounds()) + else { + return Path::new(); + }; + let mut record = StrokeRec::new(InitStyle::Hairline); + record.set_stroke_style(widths.max(), false); + record.set_stroke_params(PaintCap::Butt, PaintJoin::Miter, stroke.miter_limit); + let mut builder = PathBuilder::new(); + if !record.apply_to_path(&mut builder, &dashed.snapshot()) { + return Path::new(); + } + skia_safe::op(&builder.snapshot(), &ring, PathOp::Intersect).unwrap_or_default() +} + +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() +} + +/// Project the already box-mapped, backend-independent command stream into +/// Skia. Resolution performed the only coordinate mapping, so bounds and +/// rasterization consume bit-identical f32 geometry. +fn backend_path(path: &ResolvedPathArtifact) -> Path { + let fill_type = match path.fill_rule { + FillRule::NonZero => PathFillType::Winding, + FillRule::EvenOdd => PathFillType::EvenOdd, + }; + let mut builder = PathBuilder::new_with_fill_type(fill_type); + for command in path.commands.iter() { + match *command { + PathCommand::MoveTo { x, y } => { + builder.move_to((x, y)); + } + PathCommand::LineTo { x, y } => { + builder.line_to((x, y)); + } + PathCommand::QuadTo { x1, y1, x, y } => { + builder.quad_to((x1, y1), (x, y)); + } + PathCommand::CubicTo { + x1, + y1, + x2, + y2, + x, + y, + } => { + builder.cubic_to((x1, y1), (x2, y2), (x, y)); + } + PathCommand::ConicTo { + x1, + y1, + x, + y, + weight, + } => { + builder.conic_to((x1, y1), (x, y), weight); + } + PathCommand::Close => { + builder.close(); + } + } + } + builder.snapshot() +} + +#[cfg(test)] +mod backend_path_tests { + use std::sync::Arc; + + use super::backend_path; + use anchor_lab::path::{analyze, materialize, FillRule}; + + #[test] + fn analytical_arc_bounds_match_the_materialized_conics() { + let cases = [ + "M .5 0 A .5 .5 0 0 1 1 .5 A .5 .5 0 0 1 .5 1 A .5 .5 0 0 1 0 .5 A .5 .5 0 0 1 .5 0 Z", + "M .2 .5 A .35 .2 37 0 1 .8 .5", + "M .2 .2 A .4 .3 25 0 1 .8 .7", + "M .5 .5 A .000001 .000001 0 0 1 .500001 .500001", + ]; + for d in cases { + let artifact = analyze(d, FillRule::NonZero) + .unwrap_or_else(|error| panic!("arc corpus must be valid: {d}: {error}")); + let (width, height) = (137.0, 83.0); + let resolved = materialize(Arc::clone(&artifact), width, height) + .expect("arc corpus must fit its finite resolved box"); + let actual = backend_path(&resolved).compute_tight_bounds(); + let expected = resolved.local_bounds; + let epsilon = 2.0e-4; + assert!(actual.left >= expected.x - epsilon, "{d}: left escaped"); + assert!(actual.top >= expected.y - epsilon, "{d}: top escaped"); + assert!( + actual.right <= expected.x + expected.w + epsilon, + "{d}: right escaped" + ); + assert!( + actual.bottom <= expected.y + expected.h + epsilon, + "{d}: bottom escaped" + ); + } + } +} + +fn draw_stroke( + canvas: &Canvas, + source: &Path, + stroke: &Stroke, + paint_box: PaintBox, + ctx: &PaintCtx, +) { + let geometry = stroke_geometry(source, stroke); + draw_painted_geometry(canvas, &geometry, &stroke.paints, paint_box, ctx); +} + +fn draw_painted_geometry( + canvas: &Canvas, + geometry: &Path, + paints: &Paints, + paint_box: PaintBox, + ctx: &PaintCtx, +) { + if geometry.is_empty() { + return; + } + for model in paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + canvas.draw_path(geometry, &paint); + } + } +} + +fn draw_rectangular_stroke( + canvas: &Canvas, + w: f32, + h: f32, + radius: &RectangularCornerRadius, + widths: RectangularStrokeWidth, + stroke: &Stroke, + paint_box: PaintBox, + ctx: &PaintCtx, +) { + let geometry = rectangular_stroke_geometry(w, h, radius, widths, stroke); + draw_painted_geometry(canvas, &geometry, &stroke.paints, paint_box, ctx); +} + +/// 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 width = uniform_stroke_width(stroke)?; + let mut paint = sk_paint(model, paint_box, ctx)?; + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(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); + } + } +} + +#[derive(Default)] +struct GlyphScratch { + ids: Vec, + positions: Vec, +} + +impl GlyphScratch { + fn with_run( + &mut self, + run: &anchor_lab::text_layout::TextGlyphRun, + list: &DrawList, + mut use_run: impl FnMut(&Font, &[u16], &[Point]), + ) { + self.ids.clear(); + self.positions.clear(); + self.ids.extend(run.glyphs.iter().map(|glyph| glyph.id)); + self.positions + .extend(run.glyphs.iter().map(|glyph| Point::new(glyph.x, glyph.y))); + let font = list.text_fonts().font(run.font); + use_run(&font, &self.ids, &self.positions); + } +} + +fn text_path( + layout: &anchor_lab::text_layout::TextLayout, + list: &DrawList, + scratch: &mut GlyphScratch, +) -> Path { + let mut builder = PathBuilder::new(); + for run in &layout.glyph_runs { + scratch.with_run(run, list, |font, glyphs, positions| { + for (glyph, position) in glyphs.iter().zip(positions) { + if let Some(path) = font.get_path(*glyph) { + let path = path.make_transform(&Matrix::translate((position.x, position.y))); + builder.add_path(&path); + } + } + }); + } + builder.snapshot() +} + +/// 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, + Clip, + } + + let initial_save_count = canvas.save_count(); + let mut scopes = Vec::new(); + let mut glyph_scratch = GlyphScratch::default(); + for item in &list.items { + match &item.kind { + 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::BeginClipRect { + w, + h, + corner_radius, + corner_smoothing, + } => { + let total = view.then(&item.world); + canvas.save(); + canvas.set_matrix(&skia_matrix(&total).into()); + if corner_radius.is_zero() { + canvas.clip_rect(Rect::from_wh(*w, *h), None, false); + } else { + let path = rounded_rect_path(*w, *h, corner_radius, corner_smoothing.value()); + canvas.clip_path(&path, ClipOp::Intersect, true); + } + 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, + corner_radius, + corner_smoothing, + paints, + } => { + with_local_transform(canvas, view, &item.world, || { + let paint_box = PaintBox::from_size(*w, *h); + if corner_radius.is_zero() { + for model in paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + canvas.draw_rect(Rect::from_wh(*w, *h), &paint); + } + } + } else { + let path = + rounded_rect_path(*w, *h, corner_radius, corner_smoothing.value()); + for model in paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + canvas.draw_path(&path, &paint); + } + } + } + }); + } + ItemKind::OvalFill { w, h, paints } => { + with_local_transform(canvas, view, &item.world, || { + 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); + } + } + }); + } + ItemKind::PathFill { w, h, path, paints } => { + with_local_transform(canvas, view, &item.world, || { + let paint_box = PaintBox::from_size(*w, *h); + let geometry = backend_path(path); + draw_painted_geometry(canvas, &geometry, paints, paint_box, ctx); + }); + } + ItemKind::TextFill { + layout, + paints, + paint_w, + paint_h, + } => { + with_local_transform(canvas, view, &item.world, || { + let paint_box = PaintBox::from_size(*paint_w, *paint_h); + for run in &layout.glyph_runs { + glyph_scratch.with_run(run, list, |font, glyphs, positions| { + if let Some(run_paints) = paints.for_source_run(run.source_run) { + for model in run_paints.iter() { + if let Some(paint) = sk_paint(model, paint_box, ctx) { + canvas.draw_glyphs_at( + glyphs, + positions, + Point::new(0.0, 0.0), + font, + &paint, + ); + } + } + } + }); + } + }); + } + ItemKind::RectStroke { + w, + h, + corner_radius, + corner_smoothing, + stroke, + } => { + with_local_transform(canvas, view, &item.world, || { + let paint_box = PaintBox::from_size(*w, *h); + match stroke.width.normalized() { + StrokeWidth::None => {} + StrokeWidth::Rectangular(widths) => draw_rectangular_stroke( + canvas, + *w, + *h, + corner_radius, + widths, + stroke, + paint_box, + ctx, + ), + StrokeWidth::Uniform(_) => { + if corner_radius.is_zero() && stroke.align == StrokeAlign::Center { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + canvas.draw_rect(Rect::from_wh(*w, *h), paint); + }); + } else { + let path = rounded_rect_path( + *w, + *h, + corner_radius, + corner_smoothing.value(), + ); + if stroke.align == StrokeAlign::Center { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + canvas.draw_path(&path, paint); + }); + } else { + draw_stroke(canvas, &path, stroke, paint_box, ctx); + } + } + } + } + }); + } + ItemKind::OvalStroke { w, h, stroke } => { + with_local_transform(canvas, view, &item.world, || { + 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); + } + }); + } + ItemKind::LineStroke { + x1, + y1, + x2, + y2, + paint_w, + paint_h, + stroke, + } => { + with_local_transform(canvas, view, &item.world, || { + 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, + ); + } + }); + } + ItemKind::PathStroke { w, h, path, stroke } => { + with_local_transform(canvas, view, &item.world, || { + let paint_box = PaintBox::from_size(*w, *h); + let geometry = backend_path(path); + if stroke.align == StrokeAlign::Center { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + canvas.draw_path(&geometry, paint); + }); + } else { + draw_stroke(canvas, &geometry, stroke, paint_box, ctx); + } + }); + } + ItemKind::TextStroke { + layout, + paint_w, + 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 { + draw_native_centered_stroke(stroke, paint_box, ctx, |paint| { + for run in &layout.glyph_runs { + glyph_scratch.with_run(run, list, |font, glyphs, positions| { + canvas.draw_glyphs_at( + glyphs, + positions, + Point::new(0.0, 0.0), + font, + paint, + ); + }); + } + }); + } else { + let source = text_path(layout, list, &mut glyph_scratch); + draw_stroke(canvas, &source, stroke, paint_box, ctx); + } + }); + } + } + } + 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 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_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_unchecked(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..74d77f2f8a --- /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::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 resolved: &'a Resolved, +} + +impl<'a> Query<'a> { + /// 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.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..8094073286 --- /dev/null +++ b/model-v2/engine/src/replay.rs @@ -0,0 +1,385 @@ +//! 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::path::{PathCommand, ResolvedPathArtifact}; +use anchor_lab::resolve::{resolve, ResolveOptions, Resolved, RotationInFlow}; +use anchor_lab::text_layout::TextLayout; +use anchor_lab::textir; + +use crate::oracle::{OracleTags, TEXT_STUB}; + +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 under its recorded oracle and options. +/// +/// Replay v0 can reproduce only the deterministic lab text metric: it carries +/// no font manifest capable of reconstructing a Skia Paragraph environment. +/// Any other tag therefore fails closed instead of silently resolving under +/// the stub and claiming faithful replay. +pub fn play(replay: &Replay) -> Result<(Document, Vec), String> { + if replay.tags.text != TEXT_STUB { + return Err(format!( + "unsupported replay text oracle {:?}; replay v0 supports only {:?}", + replay.tags.text, TEXT_STUB + )); + } + 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)); + } + Ok((doc, results)) +} + +/// 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; + } + 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; + } + if !opt_text_layout_eq(a.text_layout_opt(id), b.text_layout_opt(id)) { + return false; + } + if !opt_resolved_path_eq(a.resolved_path_opt(id), b.resolved_path_opt(id)) { + return false; + } + } + a.query_snapshot_bits_eq(b) +} + +fn scalar_eq(a: f32, b: f32) -> bool { + a.to_bits() == b.to_bits() +} + +fn rect_eq(a: RectF, b: RectF) -> bool { + scalar_eq(a.x, b.x) && scalar_eq(a.y, b.y) && scalar_eq(a.w, b.w) && scalar_eq(a.h, b.h) +} + +fn opt_rect_eq(a: Option, b: Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => rect_eq(a, b), + _ => false, + } +} + +fn opt_scalar_eq(a: Option, b: Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => scalar_eq(a, b), + _ => false, + } +} + +fn opt_text_layout_eq( + a: Option<&std::sync::Arc>, + b: Option<&std::sync::Arc>, +) -> bool { + let (Some(a), Some(b)) = (a, b) else { + return a.is_none() && b.is_none(); + }; + a.oracle == b.oracle + && a.environment == b.environment + && opt_scalar_eq(a.width_constraint, b.width_constraint) + && rect_eq(a.assigned_box, b.assigned_box) + && scalar_eq(a.width, b.width) + && scalar_eq(a.height, b.height) + && a.lines.len() == b.lines.len() + && a.lines.iter().zip(&b.lines).all(|(a, b)| { + a.text == b.text + && a.byte_range == b.byte_range + && a.source_range == b.source_range + && a.end == b.end + && scalar_eq(a.left, b.left) + && scalar_eq(a.width, b.width) + && scalar_eq(a.top, b.top) + && scalar_eq(a.height, b.height) + && scalar_eq(a.baseline, b.baseline) + && scalar_eq(a.ascent, b.ascent) + && scalar_eq(a.descent, b.descent) + }) + && a.glyph_runs.len() == b.glyph_runs.len() + && a.glyph_runs.iter().zip(&b.glyph_runs).all(|(a, b)| { + a.line_index == b.line_index + && a.source_run == b.source_run + && a.font == b.font + && a.font_identity == b.font_identity + && a.glyphs.len() == b.glyphs.len() + && a.glyphs.iter().zip(&b.glyphs).all(|(a, b)| { + a.id == b.id + && a.cluster == b.cluster + && scalar_eq(a.x, b.x) + && scalar_eq(a.y, b.y) + && opt_rect_eq(a.bounds, b.bounds) + }) + }) + && opt_rect_eq(a.logical_bounds, b.logical_bounds) + && opt_rect_eq(a.ink_bounds, b.ink_bounds) + && a.unresolved_glyphs == b.unresolved_glyphs +} + +fn opt_resolved_path_eq( + a: Option<&std::sync::Arc>, + b: Option<&std::sync::Arc>, +) -> bool { + let (Some(a), Some(b)) = (a, b) else { + return a.is_none() && b.is_none(); + }; + a.fill_rule == b.fill_rule + && a.all_contours_closed == b.all_contours_closed + && rect_eq(a.local_bounds, b.local_bounds) + && a.commands.len() == b.commands.len() + && a.commands + .iter() + .zip(b.commands.iter()) + .all(|(a, b)| path_command_eq(*a, *b)) +} + +fn path_command_eq(a: PathCommand, b: PathCommand) -> bool { + match (a, b) { + (PathCommand::MoveTo { x: ax, y: ay }, PathCommand::MoveTo { x: bx, y: by }) + | (PathCommand::LineTo { x: ax, y: ay }, PathCommand::LineTo { x: bx, y: by }) => { + scalar_eq(ax, bx) && scalar_eq(ay, by) + } + ( + PathCommand::QuadTo { + x1: ax1, + y1: ay1, + x: ax, + y: ay, + }, + PathCommand::QuadTo { + x1: bx1, + y1: by1, + x: bx, + y: by, + }, + ) => scalar_eq(ax1, bx1) && scalar_eq(ay1, by1) && scalar_eq(ax, bx) && scalar_eq(ay, by), + ( + PathCommand::CubicTo { + x1: ax1, + y1: ay1, + x2: ax2, + y2: ay2, + x: ax, + y: ay, + }, + PathCommand::CubicTo { + x1: bx1, + y1: by1, + x2: bx2, + y2: by2, + x: bx, + y: by, + }, + ) => { + scalar_eq(ax1, bx1) + && scalar_eq(ay1, by1) + && scalar_eq(ax2, bx2) + && scalar_eq(ay2, by2) + && scalar_eq(ax, bx) + && scalar_eq(ay, by) + } + ( + PathCommand::ConicTo { + x1: ax1, + y1: ay1, + x: ax, + y: ay, + weight: aw, + }, + PathCommand::ConicTo { + x1: bx1, + y1: by1, + x: bx, + y: by, + weight: bw, + }, + ) => { + scalar_eq(ax1, bx1) + && scalar_eq(ay1, by1) + && scalar_eq(ax, bx) + && scalar_eq(ay, by) + && scalar_eq(aw, bw) + } + (PathCommand::Close, PathCommand::Close) => true, + _ => 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/text_layout.rs b/model-v2/engine/src/text_layout.rs new file mode 100644 index 0000000000..89bad4b016 --- /dev/null +++ b/model-v2/engine/src/text_layout.rs @@ -0,0 +1,544 @@ +//! The engine's first real text-layout oracle. +//! +//! Text shaping belongs to resolution, not rasterization. This module turns +//! authored text plus a width constraint and an explicit host font into the +//! backend-neutral [`anchor_lab::text_layout::TextLayout`] carried by the +//! resolved tier. The painter later replays its glyph ids and positions; it +//! never asks Skia to shape the string a second time. + +use std::cell::RefCell; +use std::sync::Arc; + +use anchor_lab::math::RectF; +use anchor_lab::model::{TextPayloadRef, TextStyleRec}; +use anchor_lab::text_layout::{ + StubTextLayoutOracle, TextFontKey, TextGlyph, TextGlyphRun, TextLayout, TextLayoutOracle, + TextLine, TextLineBreak, +}; +use skia_safe::font_arguments::{variation_position::Coordinate, VariationPosition}; +use skia_safe::font_style::{Slant, Weight, Width}; +use skia_safe::textlayout::{ + FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextDirection, TextStyle, + TypefaceFontProvider, +}; +use skia_safe::{Font, FontArguments, FontStyle, FourByteTag, Rect}; + +use crate::oracle::TEXT_SKPARAGRAPH; +use crate::paint::PaintCtx; + +const HOST_FAMILY: &str = "grida-host"; + +/// Immutable exact font instances used by one resolved drawlist. +/// +/// Keys are deliberately local to this snapshot. The shaping oracle collects +/// fonts for one resolution and the drawlist retains an immutable snapshot, so +/// a key can neither be reinterpreted by another [`PaintCtx`] nor accumulate in +/// a long-lived host context while font size is scrubbed. +#[derive(Debug, Default)] +pub(crate) struct TextFontRegistry { + fonts: Vec, +} + +#[derive(Debug, Clone)] +struct TextFontEntry { + identity: String, + font: Font, +} + +impl TextFontRegistry { + pub(crate) fn font(&self, key: TextFontKey) -> Font { + self.fonts + .get(key.get() as usize) + .map(|entry| entry.font.clone()) + .unwrap_or_else(|| { + panic!( + "text font key {} is absent from its drawlist registry", + key.get() + ) + }) + } +} + +impl PartialEq for TextFontRegistry { + fn eq(&self, other: &Self) -> bool { + self.fonts.len() == other.fonts.len() + && self + .fonts + .iter() + .zip(&other.fonts) + .all(|(font, other)| font.identity == other.identity) + } +} + +#[derive(Debug, Clone, PartialEq)] +struct ShapeRunKey { + start: u32, + end: u32, + style: TextStyleRec, +} + +#[derive(Debug, Clone, PartialEq)] +struct LayoutKey { + text: String, + default_style: TextStyleRec, + runs: Option>, + max_width_bits: Option, +} + +impl LayoutKey { + fn new(text: TextPayloadRef<'_>, max_width: Option) -> Self { + Self { + text: text.text.to_owned(), + default_style: text.default_style, + runs: text.runs.map(|runs| { + runs.iter() + .map(|run| ShapeRunKey { + start: run.start, + end: run.end, + style: run.style, + }) + .collect() + }), + max_width_bits: max_width.map(f32::to_bits), + } + } +} + +/// A per-resolution Skia Paragraph oracle. +/// +/// The small linear cache is deliberate for the proving engine: resolution +/// may request the same `(text, styles, width)` several times while negotiating +/// flex slots. Each distinct input is shaped once and all stages share the +/// resulting `Arc`. A promoted engine can replace the storage without changing +/// the oracle or resolved-artifact contracts. +pub(crate) struct SkiaTextLayoutOracle<'a> { + ctx: &'a PaintCtx, + fonts: RefCell>, + cache: RefCell)>>, +} + +impl<'a> SkiaTextLayoutOracle<'a> { + pub(crate) fn new(ctx: &'a PaintCtx) -> Self { + Self { + ctx, + fonts: RefCell::new(Vec::new()), + cache: RefCell::new(Vec::new()), + } + } + + pub(crate) fn font_registry(&self) -> Arc { + Arc::new(TextFontRegistry { + fonts: self.fonts.borrow().clone(), + }) + } + + fn register_font(&self, identity: &str, font: Font) -> TextFontKey { + let mut fonts = self.fonts.borrow_mut(); + if let Some(index) = fonts + .iter() + .position(|candidate| candidate.identity == identity) + { + return TextFontKey::new( + u32::try_from(index).expect("text font registry exceeds u32 keys"), + ); + } + let index = u32::try_from(fonts.len()).expect("text font registry exceeds u32 keys"); + fonts.push(TextFontEntry { + identity: identity.to_owned(), + font, + }); + TextFontKey::new(index) + } + + fn build(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc { + let Some(typeface) = self.ctx.font() else { + // Font-less probes deliberately retain the hand-computable model + // metric. Its layout is still a single shared resolved artifact; + // it simply has no glyph runs and therefore emits no text pixels. + return StubTextLayoutOracle.layout(text, max_width); + }; + let environment_face_id = typeface.unique_id(); + // Skia's typeface id identifies this in-process face instance only. + // Label that limitation in the value: this proving backend does not + // yet claim the durable font-content identity required for portable + // replay by the universal contract. + let environment = format!("process-local:skia-typeface-{}", typeface.unique_id()); + + let mut provider = TypefaceFontProvider::new(); + provider.register_typeface(typeface.clone(), Some(HOST_FAMILY)); + let mut fonts = FontCollection::new(); + fonts.set_asset_font_manager(Some(provider.into())); + fonts.disable_font_fallback(); + + let default_style = sk_text_style(text.default_style, None); + let mut paragraph_style = ParagraphStyle::new(); + paragraph_style.set_text_direction(TextDirection::LTR); + paragraph_style.set_text_align(TextAlign::Left); + paragraph_style.set_apply_rounding_hack(false); + paragraph_style.set_fake_missing_font_styles(true); + paragraph_style.set_text_style(&default_style); + + let mut builder = ParagraphBuilder::new(¶graph_style, &fonts); + let synthetic_empty = text.text.is_empty(); + if synthetic_empty { + // SkParagraph gives a truly empty paragraph zero block extent. + // Resolve one synthetic space solely to obtain the selected + // default font's line metrics, then discard its advance, glyphs, + // and source mapping below. The published artifact still has no + // invented source character and no ink. + builder.push_style(&default_style); + builder.add_text(" "); + builder.pop(); + } else { + match text.runs { + Some(runs) => { + for (run_index, run) in runs.iter().enumerate() { + // A paint boundary is a real glyph-run boundary. Give each + // authored run a distinct inert Paragraph color so Skia + // cannot merge equal typography across two paint stacks and + // form a ligature that cannot be painted losslessly. + let style = sk_text_style(run.style, Some(run_index)); + builder.push_style(&style); + builder.add_text(&text.text[run.start as usize..run.end as usize]); + builder.pop(); + } + } + None => { + builder.push_style(&default_style); + builder.add_text(text.text); + builder.pop(); + } + } + } + + let mut paragraph = builder.build(); + let layout_width = match max_width { + Some(width) => width.max(0.0), + None => { + paragraph.layout(f32::INFINITY); + paragraph.max_intrinsic_width().max(0.0) + } + }; + // Extraction is always from a finite, final layout. Infinite-width + // formatting may discard visual lines for non-left effective alignment. + paragraph.layout(layout_width); + + let height = paragraph.height(); + let unresolved_glyphs = if synthetic_empty { + 0 + } else { + paragraph.unresolved_glyphs().unwrap_or(0) + }; + let measured_width = if synthetic_empty { + 0.0 + } else if max_width.is_some() { + paragraph.longest_line() + } else { + paragraph.max_intrinsic_width() + }; + + let line_metrics = paragraph.get_line_metrics(); + let mut lines = Vec::with_capacity(line_metrics.len().max(1)); + let mut consumed_cursor = 0usize; + for (line_index, line) in line_metrics.iter().enumerate() { + // SkParagraph line metrics use UTF-16 code-unit offsets while the + // authored model and visitor cluster table use UTF-8 bytes. Convert + // every boundary before slicing; never clamp into the middle of a + // multibyte scalar. A trailing empty line may point at the newline + // already consumed by the preceding line, so source coverage is + // normalized monotonically with `consumed_cursor`. + let start = utf16_to_utf8(text.text, line.start_index) + .max(consumed_cursor) + .min(text.text.len()); + let source_end = utf16_to_utf8(text.text, line.end_including_newline) + .max(start) + .min(text.text.len()); + let visible_end = + utf16_to_utf8(text.text, line.end_excluding_whitespaces).clamp(start, source_end); + let explicit_break = text.text[visible_end..source_end].chars().any(|character| { + matches!( + character, + '\n' | '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) + }); + lines.push(TextLine { + text: text.text[start..visible_end].to_owned(), + byte_range: start as u32..visible_end as u32, + source_range: start as u32..source_end as u32, + end: if explicit_break { + TextLineBreak::Explicit + } else if line_index + 1 == line_metrics.len() { + TextLineBreak::Terminal + } else { + TextLineBreak::Soft + }, + left: line.left as f32, + width: if synthetic_empty { + 0.0 + } else { + line.width as f32 + }, + top: (line.baseline - line.ascent) as f32, + height: line.height as f32, + baseline: line.baseline as f32, + ascent: line.ascent as f32, + descent: line.descent as f32, + }); + consumed_cursor = source_end; + } + if lines.is_empty() { + // Keep the empty attributed-string sentinel inspectable by caret + // and selection consumers even when Skia emits no visitor line. + lines.push(TextLine { + text: String::new(), + byte_range: 0..0, + source_range: 0..0, + end: TextLineBreak::Terminal, + left: 0.0, + width: 0.0, + top: 0.0, + height, + baseline: paragraph.alphabetic_baseline(), + ascent: paragraph.alphabetic_baseline(), + descent: (height - paragraph.alphabetic_baseline()).max(0.0), + }); + } + + let mut glyph_runs: Vec = Vec::new(); + let mut ink_bounds: Option = None; + if !synthetic_empty { + paragraph.visit(|line_index, info| { + let Some(info) = info else { + return; + }; + let font = info.font().clone(); + let font_identity = process_font_identity(&font, environment_face_id); + let font_key = self.register_font(&font_identity, font.clone()); + let glyph_ids = info.glyphs(); + let positions = info.positions(); + let starts = info.utf8_starts(); + let origin = info.origin(); + let mut bounds = vec![Rect::default(); glyph_ids.len()]; + font.get_bounds(glyph_ids, &mut bounds, None); + + for (index, glyph_id) in glyph_ids.iter().copied().enumerate() { + let cluster = starts.get(index).copied().unwrap_or(0); + let source_run = source_run_at(text, cluster); + let position = positions[index]; + let x = position.x + origin.x; + let y = position.y + origin.y; + let bound = bounds[index]; + let glyph_bounds = (!bound.is_empty()).then_some(RectF { + x: bound.left + x, + y: bound.top + y, + w: bound.width(), + h: bound.height(), + }); + if let Some(bound) = glyph_bounds { + ink_bounds = Some(match ink_bounds { + Some(current) => current.union(&bound), + None => bound, + }); + } + + let starts_new_run = glyph_runs.last().is_none_or(|run| { + run.line_index != line_index + || run.source_run != source_run + || run.font_identity != font_identity + || run.font != font_key + }); + if starts_new_run { + glyph_runs.push(TextGlyphRun { + line_index, + source_run, + font_identity: font_identity.clone(), + font: font_key, + glyphs: Vec::new(), + }); + } + glyph_runs + .last_mut() + .expect("glyph run was just ensured") + .glyphs + .push(TextGlyph { + id: glyph_id, + cluster, + x, + y, + bounds: glyph_bounds, + }); + } + }); + } + + let logical_bounds = lines + .iter() + .map(|line| RectF { + x: line.left, + y: line.top, + w: line.width, + h: line.height, + }) + .reduce(|current, line| current.union(&line)); + + Arc::new(TextLayout { + oracle: TEXT_SKPARAGRAPH, + environment, + width_constraint: max_width, + assigned_box: RectF { + x: 0.0, + y: 0.0, + w: measured_width, + h: height, + }, + width: measured_width, + height, + lines, + glyph_runs, + logical_bounds, + ink_bounds, + unresolved_glyphs, + }) + } +} + +impl TextLayoutOracle for SkiaTextLayoutOracle<'_> { + fn layout(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc { + let key = LayoutKey::new(text, max_width); + if let Some(layout) = self + .cache + .borrow() + .iter() + .find_map(|(candidate, layout)| (candidate == &key).then(|| Arc::clone(layout))) + { + return layout; + } + let layout = self.build(text, max_width); + self.cache.borrow_mut().push((key, Arc::clone(&layout))); + layout + } +} + +fn source_run_at(text: TextPayloadRef<'_>, cluster: u32) -> Option { + let runs = text.runs?; + let byte = cluster.min(text.text.len() as u32); + runs.iter() + .position(|run| run.start <= byte && byte < run.end) + .or_else(|| text.text.is_empty().then_some(0)) +} + +fn utf16_to_utf8(text: &str, target: usize) -> usize { + if target == 0 { + return 0; + } + let mut utf16 = 0usize; + for (byte, character) in text.char_indices() { + if utf16 == target { + return byte; + } + let next = utf16 + character.len_utf16(); + if target < next { + // Line metrics must land on scalar boundaries. Refuse to create an + // invalid Rust slice if an upstream oracle ever violates that law. + return byte; + } + utf16 = next; + } + text.len() +} + +/// Process-local identity for the exact Skia font instance returned by the +/// paragraph visitor. The Typeface id is intentionally scoped to this process; +/// the remaining fields keep semantically distinct sizes, synthetic styles, +/// raster-facing font flags, and variable instances distinct in drawlist and +/// damage comparisons even when their registry slot happens to match. +fn process_font_identity(font: &Font, environment_face_id: u32) -> String { + let typeface = font.typeface(); + let style = typeface.font_style(); + let mut variations = typeface.variation_design_position().unwrap_or_default(); + variations.sort_by_key(|coordinate| *coordinate.axis); + let variations = variations + .iter() + .map(|coordinate| { + format!( + "{:08x}={:08x}", + *coordinate.axis, + coordinate.value.to_bits() + ) + }) + .collect::>() + .join(","); + let flags = (font.is_force_auto_hinting() as u8) + | ((font.is_embedded_bitmaps() as u8) << 1) + | ((font.is_subpixel() as u8) << 2) + | ((font.is_linear_metrics() as u8) << 3) + | ((font.is_embolden() as u8) << 4) + | ((font.is_baseline_snap() as u8) << 5); + + format!( + "skia-process:env-face={environment_face_id}:style={}/{}/{:?}:size={:08x}:scale={:08x}:skew={:08x}:flags={flags:02x}:edging={:?}:hinting={:?}:vars=[{variations}]", + *style.weight(), + *style.width(), + style.slant(), + font.size().to_bits(), + font.scale_x().to_bits(), + font.skew_x().to_bits(), + font.edging(), + font.hinting(), + ) +} + +fn sk_text_style(style: TextStyleRec, source_run: Option) -> TextStyle { + let mut text_style = TextStyle::new(); + text_style.set_font_families(&[HOST_FAMILY]); + text_style.set_font_size(style.font_size); + text_style.set_font_style(FontStyle::new( + Weight::from(style.font_weight as i32), + Width::NORMAL, + if style.font_style_italic { + Slant::Italic + } else { + Slant::Upright + }, + )); + + // Paint is not read from the Paragraph, but a unique inert color keeps a + // paint-only authored boundary from being merged into one shaped ligature. + if let Some(index) = source_run { + let encoded = (index as u32).wrapping_add(1); + text_style.set_color(skia_safe::Color::from_argb( + 0xFF, + (encoded >> 16) as u8, + (encoded >> 8) as u8, + encoded as u8, + )); + } + + let coordinates = [ + Coordinate { + axis: FourByteTag::from_chars('w', 'g', 'h', 't'), + value: style.font_weight as f32, + }, + Coordinate { + axis: FourByteTag::from_chars('o', 'p', 's', 'z'), + value: style.font_size, + }, + ]; + let arguments = FontArguments::new().set_variation_design_position(VariationPosition { + coordinates: &coordinates, + }); + text_style.set_font_arguments(&arguments); + text_style +} + +#[cfg(test)] +mod registry_tests { + use super::TextFontRegistry; + use anchor_lab::text_layout::TextFontKey; + + #[test] + #[should_panic(expected = "text font key 0 is absent from its drawlist registry")] + fn missing_font_key_fails_loudly() { + TextFontRegistry::default().font(TextFontKey::new(0)); + } +} 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..d8e4925540 --- /dev/null +++ b/model-v2/engine/tests/cache.rs @@ -0,0 +1,559 @@ +//! 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::damage::diff_frame; +use anchor_engine::drawlist::build_glyphless_unchecked; +use anchor_engine::frame; +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}; + +const W: i32 = 1360; +const H: i32 = 900; +const INTER: &[u8] = + include_bytes!("../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); + +fn opts() -> ResolveOptions { + ResolveOptions { + viewport: (2000.0, 1400.0), + rotation_in_flow: RotationInFlow::VisualOnly, + } +} + +fn ctx() -> PaintCtx { + PaintCtx::new(None) +} + +fn font_ctx() -> PaintCtx { + let typeface = FontMgr::new() + .new_from_data(INTER, None) + .expect("bundled Inter typeface"); + PaintCtx::new(Some(typeface)) +} + +/// 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).fills = Paints::solid("#4A90D9".into()); + doc.get_mut(c).fills = Paints::solid("#E2574C".into()); + doc.get_mut(e).fills = Paints::solid("#57B894".into()); + doc +} + +fn text_scene() -> Document { + let mut builder = DocBuilder::new(); + let mut header = Header::new(SizeIntent::Fixed(260.0), SizeIntent::Auto); + header.x = AxisBinding::start(80.0); + header.y = AxisBinding::start(90.0); + let text = builder.add( + 0, + header, + Payload::Text { + content: "Cache office AV".into(), + font_size: 32.0, + }, + ); + let mut doc = builder.build(); + doc.get_mut(text).fills = Paints::solid(Color::BLACK); + 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_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).expect("valid fresh frame"); + anchor_engine::paint::read_pixels(&mut surface, W, H) +} + +fn cached_frame_bytes( + cache: &mut SceneCache, + doc: &Document, + view: &Affine, + ctx: &PaintCtx, + doc_dirty: bool, +) -> (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) + .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, + ) +} + +#[test] +fn cache_cold_matches_fresh() { + let doc = scene(); + let view = Affine::translate(200.0, 150.0); + 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) + .expect("valid cached frame"); + 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); + let context = ctx(); + // Prime at ref_view (cold), then pan: the second frame is a pure blit. + let (_, cold_reraster) = cached_frame_bytes(&mut cache, &doc, &ref_view, &context, false); + let (got, panned_reraster) = cached_frame_bytes(&mut cache, &doc, &panned, &context, false); + assert!(cold_reraster, "the first frame must populate the cache"); + assert!( + !panned_reraster, + "an in-margin pan with one stable context must be a pure blit" + ); + + 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 context = ctx(); + 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) + .expect("valid cached frame"); + 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 context = ctx(); + 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()); + 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, &context, true, W, H) + .expect("valid dirty cached frame"); + assert_eq!( + got, + fresh_bytes(&doc, &view), + "a dirty re-raster must reflect the mutation" + ); +} + +#[test] +fn cache_builds_text_with_the_same_shaping_oracle_as_a_fresh_frame() { + let doc = text_scene(); + let context = font_ctx(); + 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) + .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)); +} + +#[test] +fn changing_the_context_font_invalidates_cached_text_without_document_dirtiness() { + let doc = text_scene(); + let view = Affine::IDENTITY; + let mut cache = SceneCache::new(W, H); + let mut context = font_ctx(); + + let (shaped, cold_reraster) = cached_frame_bytes(&mut cache, &doc, &view, &context, false); + let (_, clean_reraster) = cached_frame_bytes(&mut cache, &doc, &view, &context, false); + assert!(cold_reraster); + assert!( + !clean_reraster, + "an unchanged environment must reuse its image" + ); + assert!(shaped.chunks_exact(4).any(|pixel| pixel[0] < 100)); + + context.set_font(None); + let (fontless, font_reraster) = cached_frame_bytes(&mut cache, &doc, &view, &context, false); + assert!( + font_reraster, + "changing the shaping environment must rebuild the drawlist" + ); + assert_eq!(fontless, fresh_frame_bytes(&doc, &view, &context)); + assert_ne!(fontless, shaped); +} + +#[test] +fn changing_resolve_options_invalidates_the_cached_resolved_scene() { + let doc = scene(); + let context = ctx(); + let view = Affine::IDENTITY; + let mut cache = SceneCache::new(W, H); + 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) + .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, + ) + .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(), + &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, + [( + 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/corners.rs b/model-v2/engine/tests/corners.rs new file mode 100644 index 0000000000..9c68a14b28 --- /dev/null +++ b/model-v2/engine/tests/corners.rs @@ -0,0 +1,278 @@ +//! Focused rounded-box materialization and raster probes. +//! +//! Native `.grida.xml` has no independent browser oracle for continuous +//! corners, so these tests assert stable model-to-drawlist projection and +//! high-contrast pixels away from antialiased boundaries. + +mod support; + +use anchor_engine::drawlist::ItemKind; +use anchor_engine::paint::PaintCtx; +use anchor_lab::model::{CornerSmoothing, Radius, RectangularCornerRadius}; +use support::render_xml; + +fn expected_asymmetric_radius() -> RectangularCornerRadius { + RectangularCornerRadius { + tl: Radius { rx: 24.0, ry: 18.0 }, + tr: Radius { rx: 12.0, ry: 10.0 }, + br: Radius { rx: 8.0, ry: 6.0 }, + bl: Radius { rx: 4.0, ry: 2.0 }, + } +} + +#[test] +fn fill_clip_and_repeated_strokes_share_the_authored_outline() { + let source = r##" + + + + + + + + + + +"##; + let (_, list) = render_xml(source, 100, 80, &PaintCtx::new(None)); + let parent = list.items[0].node; + let expected_radius = expected_asymmetric_radius(); + let expected_smoothing = CornerSmoothing::default(); + let mut projected = 0; + + for item in list.items.iter().filter(|item| item.node == parent) { + let geometry = match &item.kind { + ItemKind::RectFill { + corner_radius, + corner_smoothing, + .. + } + | ItemKind::BeginClipRect { + corner_radius, + corner_smoothing, + .. + } + | ItemKind::RectStroke { + corner_radius, + corner_smoothing, + .. + } => Some((*corner_radius, *corner_smoothing)), + _ => None, + }; + if let Some((radius, smoothing)) = geometry { + assert_eq!(radius, expected_radius); + assert_eq!(smoothing, expected_smoothing); + projected += 1; + } + } + + assert_eq!(projected, 4, "fill + clip + two strokes use one outline"); +} + +#[test] +fn elliptical_axes_remain_independent() { + let source = r##" + + + + + +"##; + let (image, _) = render_xml(source, 220, 100, &PaintCtx::new(None)); + + assert_eq!(image.at(12, 19), [239, 68, 68, 255]); + assert_eq!(image.at(122, 19), [255, 255, 255, 255]); + assert_eq!(image.at(50, 35), [239, 68, 68, 255]); + assert_eq!(image.at(160, 35), [239, 68, 68, 255]); +} + +#[test] +fn smoothing_changes_only_corner_coverage() { + let source = r##" + + + + + +"##; + let (image, list) = render_xml(source, 200, 100, &PaintCtx::new(None)); + + let mut different_pixels = 0; + for y in 0..80 { + for x in 0..80 { + different_pixels += usize::from(image.at(10 + x, 10 + y) != image.at(110 + x, 10 + y)); + } + } + assert!( + different_pixels > 40, + "continuous smoothing must materially change corner coverage; diff={different_pixels}" + ); + assert_eq!(image.at(50, 50), [124, 58, 237, 255]); + assert_eq!(image.at(150, 50), [124, 58, 237, 255]); + assert!(list.items.iter().any(|item| matches!( + item.kind, + ItemKind::RectFill { + corner_smoothing: CornerSmoothing(value), + .. + } if value == 0.8 + ))); +} + +#[test] +fn rounded_container_clip_contains_descendants_but_not_its_stroke() { + let source = r##" + + + + + + + + + +"##; + let (image, _) = render_xml(source, 100, 100, &PaintCtx::new(None)); + + assert_eq!(image.at(12, 12), [255, 255, 255, 255]); + assert_eq!(image.at(50, 12), [34, 197, 94, 255]); + assert_eq!(image.at(50, 7), [239, 68, 68, 255]); + assert_eq!(image.at(50, 50), [34, 197, 94, 255]); +} + +#[test] +fn rounded_clip_antialiases_like_the_same_filled_outline() { + let source = r##" + + + + + + + +"##; + let (image, _) = render_xml(source, 220, 100, &PaintCtx::new(None)); + + let mut max_delta = 0; + let mut partially_covered = 0; + for y in 0..80 { + for x in 0..80 { + let fill = image.at(10 + x, 10 + y); + let clip = image.at(120 + x, 10 + y); + partially_covered += + usize::from(clip != [34, 197, 94, 255] && clip != [255, 255, 255, 255]); + for channel in 0..4 { + max_delta = max_delta.max(fill[channel].abs_diff(clip[channel])); + } + } + } + assert!( + partially_covered > 0, + "rounded descendant clip must retain fractional edge coverage" + ); + assert!( + max_delta <= 8, + "rounded descendant clip must share the fill's antialiased edge; max delta={max_delta}" + ); +} + +#[test] +fn oversized_radii_follow_ordinary_and_smoothed_resolution_profiles() { + let source = r##" + + + + + + + +"##; + let (image, list) = render_xml(source, 600, 120, &PaintCtx::new(None)); + + let mut ordinary_max_delta = 0; + for y in 0..60 { + for x in 0..100 { + let oversized = image.at(10 + x, 30 + y); + let resolved = image.at(130 + x, 30 + y); + for channel in 0..4 { + ordinary_max_delta = + ordinary_max_delta.max(oversized[channel].abs_diff(resolved[channel])); + } + } + } + assert!( + ordinary_max_delta <= 8, + "ordinary oversized radii must match their proportional result; max delta={ordinary_max_delta}" + ); + + let mut smoothed_max_delta = 0; + for y in 0..100 { + for x in 0..160 { + let oversized = image.at(250 + x, 10 + y); + let resolved = image.at(430 + x, 10 + y); + for channel in 0..4 { + smoothed_max_delta = + smoothed_max_delta.max(oversized[channel].abs_diff(resolved[channel])); + } + } + } + assert!( + smoothed_max_delta <= 1, + "smoothed oversized radii must match the production cap; max delta={smoothed_max_delta}" + ); + + let authored: Vec = list + .items + .iter() + .filter_map(|item| match item.kind { + ItemKind::RectFill { corner_radius, .. } if !corner_radius.is_zero() => { + Some(corner_radius.tl.rx) + } + _ => None, + }) + .collect(); + assert_eq!(authored, [80.0, 30.0, 80.0, 50.0]); +} + +#[test] +fn rounded_dash_origin_is_the_top_left_curve_join() { + let source = r##" + + + + + + + + + +"##; + let (image, _) = render_xml(source, 160, 100, &PaintCtx::new(None)); + + // extent = (1 + 0.5) * 20 = 30, so the first dash starts at x=50 and + // advances toward the top-right. + assert_eq!(image.at(54, 20), [239, 68, 68, 255]); + assert_eq!(image.at(68, 20), [255, 255, 255, 255]); +} + +#[test] +fn repeated_strokes_follow_the_rounded_source_instead_of_a_square_box() { + let source = r##" + + + + + + + + + + + +"##; + let (image, _) = render_xml(source, 120, 100, &PaintCtx::new(None)); + + assert_eq!(image.at(16, 16), [255, 255, 255, 255]); + assert_eq!(image.at(60, 16), [239, 68, 68, 255]); + assert_eq!(image.at(60, 23), [59, 130, 246, 255]); + assert_eq!(image.at(60, 32), [17, 24, 39, 255]); +} diff --git a/model-v2/engine/tests/damage.rs b/model-v2/engine/tests/damage.rs new file mode 100644 index 0000000000..8e0a5ce0c4 --- /dev/null +++ b/model-v2/engine/tests/damage.rs @@ -0,0 +1,221 @@ +//! 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, 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}; +use anchor_lab::resolve::{resolve, resolve_with_text_layout, ResolveOptions, RotationInFlow}; +use anchor_lab::text_layout::{ + TextFontKey, TextGlyph, TextGlyphRun, TextLayout, TextLayoutOracle, TextLine, TextLineBreak, +}; + +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"); +} + +#[derive(Clone, Copy)] +enum ArtifactVariant { + Base, + Environment, + Glyph, + Font, + Layout, +} + +struct ArtifactOracle(ArtifactVariant); + +impl TextLayoutOracle for ArtifactOracle { + fn layout(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc { + let glyph_id = match self.0 { + ArtifactVariant::Glyph => 2, + _ => 1, + }; + let font = TextFontKey::new(1); + let font_identity = match self.0 { + ArtifactVariant::Font => "font@test-2", + _ => "font@test-1", + }; + let baseline = match self.0 { + ArtifactVariant::Layout => 9.0, + _ => 8.0, + }; + let ink = RectF { + x: 1.0, + y: 2.0, + w: 8.0, + h: 10.0, + }; + Arc::new(TextLayout { + oracle: "damage@test-1", + environment: match self.0 { + ArtifactVariant::Environment => "damage-fonts@test-2", + _ => "damage-fonts@test-1", + } + .into(), + width_constraint: max_width, + assigned_box: RectF { + x: 0.0, + y: 0.0, + w: 10.0, + h: 12.0, + }, + width: 10.0, + height: 12.0, + lines: vec![TextLine { + text: text.text.to_owned(), + byte_range: 0..text.text.len() as u32, + source_range: 0..text.text.len() as u32, + end: TextLineBreak::Terminal, + left: 0.0, + width: 10.0, + top: 0.0, + height: 12.0, + baseline, + ascent: baseline, + descent: 12.0 - baseline, + }], + glyph_runs: vec![TextGlyphRun { + line_index: 0, + source_run: None, + font_identity: font_identity.into(), + font, + glyphs: vec![TextGlyph { + id: glyph_id, + cluster: 0, + x: 1.0, + y: baseline, + bounds: Some(ink), + }], + }], + logical_bounds: Some(RectF { + x: 0.0, + y: 0.0, + w: 10.0, + h: 12.0, + }), + ink_bounds: Some(ink), + unresolved_glyphs: 0, + }) + } +} + +#[test] +fn material_text_artifact_changes_damage_even_when_geometry_is_identical() { + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(20.0)), + Payload::Text { + content: "A".into(), + font_size: 12.0, + }, + ); + let document = builder.build(); + let base = resolve_with_text_layout(&document, &opts(), &ArtifactOracle(ArtifactVariant::Base)); + + for variant in [ + ArtifactVariant::Environment, + ArtifactVariant::Glyph, + ArtifactVariant::Font, + ArtifactVariant::Layout, + ] { + let changed = resolve_with_text_layout(&document, &opts(), &ArtifactOracle(variant)); + assert_eq!(base.box_of(text), changed.box_of(text)); + assert_eq!(base.world_of(text), changed.world_of(text)); + assert_eq!(base.aabb_of(text), changed.aabb_of(text)); + + let damage = diff(&base, &changed); + assert_eq!(damage.changed, vec![text]); + 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 new file mode 100644 index 0000000000..de3a04a4c6 --- /dev/null +++ b/model-v2/engine/tests/drawlist.rs @@ -0,0 +1,356 @@ +//! 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_glyphless_unchecked, 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::BeginOpacity { .. } => "opacity-begin", + ItemKind::EndOpacity => "opacity-end", + ItemKind::BeginClipRect { .. } => "clip-begin", + ItemKind::EndClip => "clip-end", + ItemKind::RectFill { .. } => "rectfill", + ItemKind::RectStroke { .. } => "rectstroke", + ItemKind::OvalFill { .. } => "ovalfill", + ItemKind::OvalStroke { .. } => "ovalstroke", + ItemKind::LineStroke { .. } => "linestroke", + ItemKind::PathFill { .. } => "pathfill", + ItemKind::PathStroke { .. } => "pathstroke", + ItemKind::TextFill { .. } => "textfill", + ItemKind::TextStroke { .. } => "textstroke", + } +} + +fn solid_stroke(color: Color, width: f32, align: StrokeAlign) -> Stroke { + Stroke { + paints: Paints::solid(color), + width: StrokeWidth::Uniform(width), + align, + cap: StrokeCap::Butt, + join: StrokeJoin::Miter, + miter_limit: 4.0, + dash_array: None, + } +} + +/// 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, + }, + ); + let ellipse = b.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(40.0)), + Payload::Shape { + desc: ShapeDesc::Ellipse, + }, + ); + let line = 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, + ); + let group_child = b.add( + g, + Header::new(SizeIntent::Fixed(20.0), SizeIntent::Fixed(20.0)), + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + let text = 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; + let hidden = b.add( + 0, + hidden_h, + Payload::Shape { + desc: ShapeDesc::Rect, + }, + ); + + let mut doc = b.build(); + 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) +} + +#[test] +fn traversal_order_and_pruning() { + let (doc, _f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + 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; + // root & group emit no ink, hidden shape is pruned. + assert_eq!( + tags, + [ + "rectfill", + "rectstroke", + "rectfill", + "ovalfill", + "linestroke", + "rectfill", + "textfill" + ] + ); +} + +#[test] +fn paint_stacks_materialize_without_kind_fallbacks() { + let (doc, _f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + let list = build_glyphless_unchecked(&doc, &resolved); + + // Frame fill = the node's #ff0000, opaque. + let ItemKind::RectFill { ref paints, .. } = list.items[0].kind else { + panic!("expected frame fill first"); + }; + assert_eq!(*paints, Paints::solid("#FF0000".into())); + + // 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!(stroke.paints, Paints::solid("#C9CED4".into())); + + // 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!(*paints, Paints::solid("#4A90D9".into())); +} + +#[test] +fn world_is_copied_verbatim_not_recomputed() { + let (doc, f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + 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)); +} + +#[test] +fn build_is_deterministic() { + let (doc, _f, _r) = scene(); + let resolved = resolve(&doc, &opts()); + assert_eq!( + build_glyphless_unchecked(&doc, &resolved), + build_glyphless_unchecked(&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_glyphless_unchecked(&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_glyphless_unchecked(&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_glyphless_unchecked(&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_glyphless_unchecked(&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())); +} + +#[test] +fn unsupported_rectangular_stroke_states_emit_no_draw_item() { + for desc in [ShapeDesc::Ellipse, ShapeDesc::Rect] { + let mut builder = DocBuilder::new(); + let payload = Payload::Shape { desc: desc.clone() }; + let shape = builder.add( + 0, + Header::new(SizeIntent::Fixed(40.0), SizeIntent::Fixed(30.0)), + payload.clone(), + ); + let mut stroke = Stroke::default_for(&payload).unwrap(); + stroke.paints = Paints::solid(Color::BLACK); + 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, + }); + builder.node_mut(shape).strokes.push(stroke); + if desc == ShapeDesc::Rect { + builder.node_mut(shape).corner_smoothing = CornerSmoothing(0.5); + } + + let doc = builder.build(); + let resolved = resolve(&doc, &opts()); + assert!( + 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 new file mode 100644 index 0000000000..3ef4750ec4 --- /dev/null +++ b/model-v2/engine/tests/grida_xml.rs @@ -0,0 +1,81 @@ +//! 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. + +mod support; + +use anchor_engine::drawlist::{build_glyphless_unchecked, Item, ItemKind}; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::model::{Color as ModelColor, CornerSmoothing, Paints, RectangularCornerRadius}; +use anchor_lab::resolve::{resolve, ResolveOptions}; + +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, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing::default(), + paints: Paints::solid(ModelColor(argb)), + } + ); +} + +#[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()); + // 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_unchecked(&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 paint_ctx = PaintCtx::new(None); + let (image, list) = support::render_xml(SOURCE, WIDTH, HEIGHT, &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. + assert_eq!(image.at(8, 8), [255, 255, 255, 255]); + assert_eq!(image.at(20, 20), [255, 0, 0, 255]); + assert_eq!(image.at(40, 36), [0, 0, 255, 255]); + assert_eq!(image.at(70, 52), [255, 0, 0, 255]); + assert_eq!(image.at(88, 72), [255, 255, 255, 255]); +} 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..ca157561db --- /dev/null +++ b/model-v2/engine/tests/grida_xml_examples.rs @@ -0,0 +1,209 @@ +//! 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, Payload, ResourceRef}; +use anchor_lab::resolve::{Report, ResolveOptions}; +use skia_safe::{surfaces, Color}; +use std::path::Path; + +type Example = (&'static str, &'static str, &'static str, (i32, i32)); + +const CANONICAL_EXAMPLES: [Example; 8] = [ + ( + "nested-rects", + "rig/fixtures/nested-rects.grida.xml", + include_str!("../rig/fixtures/nested-rects.grida.xml"), + (96, 80), + ), + ( + "unit-path", + "rig/fixtures/unit-path.grida.xml", + include_str!("../rig/fixtures/unit-path.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), + ), + ( + "rounded-surfaces", + "rig/examples/rounded-surfaces.grida.xml", + include_str!("../rig/examples/rounded-surfaces.grida.xml"), + (1440, 900), + ), + ( + "per-side-strokes", + "rig/examples/per-side-strokes.grida.xml", + include_str!("../rig/examples/per-side-strokes.grida.xml"), + (1200, 760), + ), +]; + +// These remain in natural authored form on purpose. They prove the strict +// reader accepts useful, non-writer-normalized source and that ambitious +// compositions materialize through the same engine path. The three +// `rfc-only-*` files began as RFD-only drafts without implementation or +// example access; the dashboard makes three parent axes explicit to work +// around the current flex/descendant-binding resolver seam. The other three +// are curated product-quality demonstrations. +const AUTHORED_SHOWCASES: [Example; 7] = [ + ( + "rich-text", + "rig/examples/rich-text.grida.xml", + include_str!("../rig/examples/rich-text.grida.xml"), + (1280, 800), + ), + ( + "prism-launch", + "rig/examples/prism-launch.grida.xml", + include_str!("../rig/examples/prism-launch.grida.xml"), + (1440, 900), + ), + ( + "pulse-analytics", + "rig/examples/pulse-analytics.grida.xml", + include_str!("../rig/examples/pulse-analytics.grida.xml"), + (1440, 960), + ), + ( + "nocturne-transit", + "rig/examples/nocturne-transit.grida.xml", + include_str!("../rig/examples/nocturne-transit.grida.xml"), + (1400, 900), + ), + ( + "rfc-only-keynote", + "rig/examples/rfc-only-keynote.grida.xml", + include_str!("../rig/examples/rfc-only-keynote.grida.xml"), + (960, 540), + ), + ( + "rfc-only-dashboard", + "rig/examples/rfc-only-dashboard.grida.xml", + include_str!("../rig/examples/rfc-only-dashboard.grida.xml"), + (960, 640), + ), + ( + "rfc-only-transit", + "rig/examples/rfc-only-transit.grida.xml", + include_str!("../rig/examples/rfc-only-transit.grida.xml"), + (1000, 620), + ), +]; + +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 mut paints = node + .fills + .iter() + .chain(node.strokes.iter().flat_map(|stroke| stroke.paints.iter())) + .collect::>(); + if let Payload::AttributedText { + attributed_string, .. + } = &node.payload + { + paints.extend( + attributed_string + .runs + .iter() + .filter_map(|run| run.fills.as_ref()) + .flat_map(|fills| fills.iter()), + ); + } + for paint in paints.into_iter().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 CANONICAL_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 + CANONICAL_EXAMPLES.into_iter().chain(AUTHORED_SHOWCASES) + { + 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 (product, _) = frame::render(surface.canvas(), &doc, &options, &Affine::IDENTITY, &ctx) + .expect("valid checked-in example frame"); + assert!( + !product.resolved().reports.iter().any(|report| matches!( + report, + Report::IgnoredByRule { .. } | Report::ErrorByRule { .. } + )), + "{name}: unresolved intent: {:?}", + product.resolved().reports + ); + assert!( + !product.drawlist().items.is_empty(), + "{name}: empty drawlist" + ); + assert_eq!( + surface.canvas().save_count(), + 1, + "{name}: leaked canvas state" + ); + } +} 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..695cfadb9f --- /dev/null +++ b/model-v2/engine/tests/grida_xml_slots.rs @@ -0,0 +1,228 @@ +//! 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_unchecked; +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_unchecked(&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 (product, _) = frame::render( + surface.canvas(), + &output.document, + &options(), + &Affine::IDENTITY, + &paint_ctx, + ) + .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()); + 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..89f80f325f --- /dev/null +++ b/model-v2/engine/tests/grida_xml_social_feed.rs @@ -0,0 +1,266 @@ +//! 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 (product, _) = frame::render( + surface.canvas(), + &output.document, + &options(), + &Affine::IDENTITY, + &paint_ctx, + ) + .expect("valid social-feed frame"); + assert!(!product.drawlist().items.is_empty()); + let mut drawn_image_rids = BTreeSet::new(); + for item in &product.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..0a4763c8ca --- /dev/null +++ b/model-v2/engine/tests/grida_xml_source.rs @@ -0,0 +1,269 @@ +//! 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_unchecked, Item, ItemKind}; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +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/"; +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_unchecked(&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 (product, _) = frame::render( + surface.canvas(), + &output.document, + &options(), + &Affine::IDENTITY, + &paint_ctx, + ) + .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()); + 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]); +} + +#[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 new file mode 100644 index 0000000000..c51d7be3e4 --- /dev/null +++ b/model-v2/engine/tests/ident.rs @@ -0,0 +1,50 @@ +//! 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; +use anchor_lab::model::*; + +#[test] +fn key_of_mints_only_live_model_keys() { + 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(); + + 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), 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/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/paints.rs b/model-v2/engine/tests/paints.rs new file mode 100644 index 0000000000..192133c707 --- /dev/null +++ b/model-v2/engine/tests/paints.rs @@ -0,0 +1,253 @@ +//! 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. + +mod support; + +use anchor_engine::drawlist::ItemKind; +use anchor_engine::paint::PaintCtx; +use anchor_lab::model::Paint; +use support::render_xml as render; + +#[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 = image.at(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!(image.at(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 = image.at(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 = image.at(5, 20); + let right = image.at(95, 20); + assert!(left[0] > left[2], "left={left:?}"); + assert!(right[2] > right[0], "right={right:?}"); + + let center = image.at(50, 100); + let edge = image.at(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 = image.at(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!( + image.at(x, 20), + image.at(x, 70), + "unit-space transform diverged at x={x}" + ); + } +} + +#[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##" + + + + + + + + + + + + + + +"##; + let (image, _) = render(source, 100, 80, &PaintCtx::new(None)); + let clamp = image.at(12, 10); + let repeated = image.at(12, 30); + let mirror = image.at(12, 50); + let decal = image.at(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!(image.at(20, 20), [0, 255, 0, 255]); + let multiplied = image.at(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!(image.at(45, 30), image.at(30, 45)); + let center = image.at(100, 30); + let edge = image.at(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!(image.at(10, 45), [0, 255, 0, 255]); + assert_ne!(image.at(200, 45), [0, 255, 0, 255]); +} diff --git a/model-v2/engine/tests/paths.rs b/model-v2/engine/tests/paths.rs new file mode 100644 index 0000000000..cf2c168346 --- /dev/null +++ b/model-v2/engine/tests/paths.rs @@ -0,0 +1,172 @@ +//! Unit-reference path materialization: one parsed artifact feeds bounds, +//! drawlist, damage, and raster paint. Pixel probes stay well away from AA +//! except where they deliberately distinguish the two authored stroke bands. + +mod support; + +use std::sync::Arc; + +use anchor_engine::damage; +use anchor_engine::drawlist::ItemKind; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::path::FillRule; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{surfaces, Color as SkColor, Paint as SkPaint, Rect}; + +const SOURCE: &str = include_str!("../rig/fixtures/unit-path.grida.xml"); +const WIDTH: i32 = 96; +const HEIGHT: i32 = 80; + +fn options() -> ResolveOptions { + ResolveOptions { + viewport: (WIDTH as f32, HEIGHT as f32), + ..Default::default() + } +} + +#[test] +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_unchecked(&document, &resolved); + + assert_eq!(list.items.len(), 4, "container, fill, then two strokes"); + let ItemKind::PathFill { + w, + h, + path: fill_path, + .. + } = &list.items[1].kind + else { + panic!("second item is the path fill"); + }; + assert_eq!((*w, *h), (80.0, 64.0)); + assert_eq!(fill_path.fill_rule, FillRule::EvenOdd); + assert!((fill_path.source.unit_bounds.x - 0.05).abs() < 1.0e-6); + assert!((fill_path.source.unit_bounds.y - 0.05).abs() < 1.0e-6); + assert!((fill_path.source.unit_bounds.w - 0.9).abs() < 1.0e-6); + assert!((fill_path.source.unit_bounds.h - 0.9).abs() < 1.0e-6); + assert!((fill_path.local_bounds.x - 4.0).abs() < 1.0e-5); + assert!((fill_path.local_bounds.y - 3.2).abs() < 1.0e-5); + assert!((fill_path.local_bounds.w - 72.0).abs() < 1.0e-5); + assert!((fill_path.local_bounds.h - 57.6).abs() < 1.0e-5); + assert!(fill_path.all_contours_closed); + + for item in &list.items[2..] { + let ItemKind::PathStroke { path, .. } = &item.kind else { + panic!("path children paint before repeated path strokes"); + }; + assert!( + Arc::ptr_eq(fill_path, path), + "display-list items reuse the resolved parse" + ); + } +} + +#[test] +fn evenodd_fill_and_repeated_strokes_render_in_painter_order() { + let (image, _) = support::render_xml(SOURCE, WIDTH, HEIGHT, &PaintCtx::default()); + + assert_eq!(image.at(4, 40), [255, 255, 255, 255]); // backdrop + assert_eq!(image.at(10, 40), [37, 99, 235, 255]); // wide blue stroke + assert_eq!(image.at(12, 40), [255, 255, 255, 255]); // top white stroke + assert_eq!(image.at(20, 20), [239, 68, 68, 255]); // path fill + assert_eq!(image.at(48, 40), [255, 255, 255, 255]); // even-odd hole + + let nonzero = SOURCE.replace("fill-rule=\"evenodd\"", "fill-rule=\"nonzero\""); + let (nonzero_image, _) = support::render_xml(&nonzero, WIDTH, HEIGHT, &PaintCtx::default()); + assert_eq!(nonzero_image.at(48, 40), [239, 68, 68, 255]); +} + +#[test] +fn same_box_path_edit_is_material_damage() { + let before = grida_xml::parse(SOURCE).unwrap(); + let edited_source = SOURCE.replace("M .35 .35 H .65", "M .45 .35 H .65"); + let after = grida_xml::parse(&edited_source).unwrap(); + let before_resolved = resolve(&before, &options()); + let after_resolved = resolve(&after, &options()); + + let result = damage::diff(&before_resolved, &after_resolved); + assert_eq!(result.changed, vec![2]); + assert!(result.union_world.is_some()); +} + +#[test] +fn equivalent_path_spelling_is_not_visual_damage() { + let before = grida_xml::parse(SOURCE).unwrap(); + let equivalent_source = SOURCE.replace( + "M .05 .05 H .95 V .95 H .05 Z M .35 .35 H .65 V .65 H .35 Z", + "m .05 .05 h .9 v .9 h -.9 z m .3 .3 h .3 v .3 h -.3 z", + ); + let after = grida_xml::parse(&equivalent_source).unwrap(); + + let before_resolved = resolve(&before, &options()); + let after_resolved = resolve(&after, &options()); + let result = damage::diff(&before_resolved, &after_resolved); + assert!(result.is_empty()); + assert_eq!( + 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" + ); +} + +#[test] +fn path_children_paint_between_the_fill_and_parent_strokes() { + let source = r##""##; + let document = grida_xml::parse(source).unwrap(); + let resolved = resolve( + &document, + &ResolveOptions { + viewport: (32.0, 32.0), + ..Default::default() + }, + ); + 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 { .. })); + assert!(matches!(list.items[2].kind, ItemKind::PathStroke { .. })); +} + +#[test] +fn svg_arcs_materialize_before_nonuniform_box_scaling() { + let source = r##""##; + let (image, _) = support::render_xml(source, 100, 60, &PaintCtx::default()); + + assert_eq!(image.at(50, 30), [239, 68, 68, 255]); + assert_eq!(image.at(15, 15), [255, 255, 255, 255]); + assert_eq!(image.at(85, 45), [255, 255, 255, 255]); +} + +#[test] +fn path_gradient_and_image_paints_use_the_declared_box() { + let source = r##""##; + + let mut stripe = surfaces::raster_n32_premul((100, 1)).unwrap(); + stripe.canvas().clear(SkColor::RED); + let mut blue = SkPaint::default(); + blue.set_color(SkColor::BLUE); + stripe + .canvas() + .draw_rect(Rect::from_xywh(50.0, 0.0, 50.0, 1.0), &blue); + let mut context = PaintCtx::default(); + context.insert_image("stripe", stripe.image_snapshot()); + + let (image, _) = support::render_xml(source, 100, 40, &context); + let gradient = image.at(40, 10); + assert!( + (95..=110).contains(&gradient[0]) + && gradient[0] == gradient[1] + && gradient[1] == gradient[2], + "gradient must be evaluated at u=0.4 of the declared box: {gradient:?}" + ); + let image_paint = image.at(40, 30); + assert!( + image_paint[0] >= 250 && image_paint[1] < 50 && image_paint[2] < 50, + "image fit must be evaluated against the full declared box: {image_paint:?}" + ); + assert_eq!(image.at(60, 10), [255, 255, 255, 255]); + assert_eq!(image.at(60, 30), [255, 255, 255, 255]); +} diff --git a/model-v2/engine/tests/query.rs b/model-v2/engine/tests/query.rs new file mode 100644 index 0000000000..0b2bcd2699 --- /dev/null +++ b/model-v2/engine/tests/query.rs @@ -0,0 +1,159 @@ +//! 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::{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 { + 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(&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(&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(&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"); +} + +#[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/rectangular_strokes.rs b/model-v2/engine/tests/rectangular_strokes.rs new file mode 100644 index 0000000000..d398908f32 --- /dev/null +++ b/model-v2/engine/tests/rectangular_strokes.rs @@ -0,0 +1,176 @@ +//! Focused data and raster probes for Grida's rectangular stroke-width union. +//! +//! Native `.grida.xml` has no independent visual oracle, so these tests use +//! high-contrast probes away from antialiased boundaries and assert the +//! model-to-drawlist projection separately. + +mod support; + +use anchor_engine::drawlist::ItemKind; +use anchor_engine::paint::PaintCtx; +use anchor_lab::model::{ + Radius, RectangularCornerRadius, RectangularStrokeWidth, StrokeAlign, StrokeWidth, +}; +use support::render_xml; + +#[test] +fn drawlist_preserves_top_right_bottom_left_widths() { + let source = r##" + + + + + + +"##; + let (_, list) = render_xml(source, 80, 70, &PaintCtx::new(None)); + let (corner_radius, stroke) = list + .items + .iter() + .find_map(|item| match &item.kind { + ItemKind::RectStroke { + corner_radius, + stroke, + .. + } => Some((corner_radius, stroke)), + _ => None, + }) + .expect("rectangular stroke draw item"); + assert_eq!( + *corner_radius, + RectangularCornerRadius { + tl: Radius { rx: 12.0, ry: 4.0 }, + tr: Radius { rx: 8.0, ry: 8.0 }, + br: Radius { rx: 4.0, ry: 12.0 }, + bl: Radius { rx: 2.0, ry: 16.0 }, + } + ); + assert_eq!(stroke.align, StrokeAlign::Outside); + assert_eq!( + stroke.width, + StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 2.0, + stroke_right_width: 8.0, + stroke_bottom_width: 12.0, + stroke_left_width: 4.0, + }) + ); +} + +#[test] +fn inside_widths_cover_each_side_independently() { + let source = r##" + + + + + + +"##; + let (image, _) = render_xml(source, 80, 80, &PaintCtx::new(None)); + + assert_eq!(image.at(40, 22), [239, 68, 68, 255], "top"); + assert_eq!(image.at(56, 40), [239, 68, 68, 255], "right"); + assert_eq!(image.at(40, 54), [239, 68, 68, 255], "bottom"); + assert_eq!(image.at(24, 40), [239, 68, 68, 255], "left"); + assert_eq!(image.at(40, 40), [34, 197, 94, 255], "interior"); + assert_eq!(image.at(18, 40), [255, 255, 255, 255], "outside"); +} + +#[test] +fn outside_widths_expand_each_side_without_changing_the_fill_box() { + let source = r##" + + + + + + +"##; + let (image, _) = render_xml(source, 90, 90, &PaintCtx::new(None)); + + assert_eq!(image.at(45, 22), [37, 99, 235, 255], "top"); + assert_eq!(image.at(70, 45), [37, 99, 235, 255], "right"); + assert_eq!(image.at(45, 72), [37, 99, 235, 255], "bottom"); + assert_eq!(image.at(12, 45), [37, 99, 235, 255], "left"); + assert_eq!(image.at(45, 45), [34, 197, 94, 255], "fill box"); +} + +#[test] +fn zero_width_sides_suppress_coverage_without_inventing_segments() { + let source = r##" + + + + + + +"##; + let (image, _) = render_xml(source, 80, 80, &PaintCtx::new(None)); + + assert_eq!(image.at(56, 40), [245, 158, 11, 255], "right side"); + assert_eq!(image.at(40, 21), [17, 24, 39, 255], "zero top"); + assert_eq!(image.at(40, 58), [17, 24, 39, 255], "zero bottom"); + assert_eq!(image.at(21, 40), [17, 24, 39, 255], "zero left"); +} + +#[test] +fn overconsumed_inner_extent_saturates_instead_of_inverting() { + let source = r##" + + + + + + + +"##; + let (image, _) = render_xml(source, 60, 60, &PaintCtx::new(None)); + + assert_eq!(image.at(30, 30), [220, 38, 38, 255]); + assert_eq!(image.at(19, 30), [255, 255, 255, 255]); +} + +#[test] +fn dashed_per_side_stroke_keeps_one_contour_phase() { + let source = r##" + + + + + + + + + +"##; + let (image, _) = render_xml(source, 100, 80, &PaintCtx::new(None)); + + // Dash origin is the top-left curve join at local x=10 and advances + // clockwise along the top edge. The huge gap suppresses later sides. + assert_eq!(image.at(34, 20), [124, 58, 237, 255]); + assert_eq!(image.at(58, 20), [255, 255, 255, 255]); + assert_eq!(image.at(80, 40), [255, 255, 255, 255]); +} + +#[test] +fn image_paint_uses_the_same_per_side_ring_and_node_paint_box() { + const RID: &str = "fixture://rectangular-stroke-image"; + 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_xml(&source, 100, 100, &ctx); + + assert_ne!(image.at(50, 8), [255, 255, 255, 255]); + assert_eq!(image.at(50, 50), [255, 255, 255, 255]); +} diff --git a/model-v2/engine/tests/replay.rs b/model-v2/engine/tests/replay.rs new file mode 100644 index 0000000000..da47375669 --- /dev/null +++ b/model-v2/engine/tests/replay.rs @@ -0,0 +1,205 @@ +//! 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, TEXT_SKPARAGRAPH}; +use anchor_engine::replay::{parse_string, play, resolved_bits_eq, write_string}; +use anchor_lab::grida_xml; +use anchor_lab::model::*; +use anchor_lab::ops::{apply, Axis, Op, ResizeDrag}; +use anchor_lab::resolve::{resolve, resolve_with_text_layout, ResolveOptions, RotationInFlow}; +use anchor_lab::text_layout::{StubTextLayoutOracle, TextLayout, TextLayoutOracle}; +use anchor_lab::textir; +use std::sync::Arc; + +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 replay_fails_closed_when_its_recorded_text_oracle_is_not_reproducible() { + let (doc, ops) = recorded(); + let tags = OracleTags { + text: TEXT_SKPARAGRAPH.to_owned(), + }; + let replay = parse_string(&write_string(&doc, &ops, &tags, &opts())).unwrap(); + + let error = play(&replay).expect_err("replay v0 has no persisted font environment"); + assert!(error.contains(TEXT_SKPARAGRAPH)); + assert!(error.contains("supports only")); +} + +#[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).unwrap(); + let (d2, res2) = play(&replay).unwrap(); + + 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).unwrap(); + + // 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)); +} + +struct EnvironmentOracle(&'static str); + +impl TextLayoutOracle for EnvironmentOracle { + fn layout(&self, text: TextPayloadRef<'_>, max_width: Option) -> Arc { + let layout = StubTextLayoutOracle.layout(text, max_width); + Arc::new(TextLayout { + environment: self.0.to_owned(), + ..layout.as_ref().clone() + }) + } +} + +#[test] +fn resolved_bit_equality_includes_the_text_layout_column() { + let mut builder = DocBuilder::new(); + let text = builder.add( + 0, + Header::new(SizeIntent::Fixed(100.0), SizeIntent::Auto), + Payload::Text { + content: "same geometry".into(), + font_size: 12.0, + }, + ); + let document = builder.build(); + let a = resolve_with_text_layout(&document, &opts(), &EnvironmentOracle("font-a@test")); + let b = resolve_with_text_layout(&document, &opts(), &EnvironmentOracle("font-b@test")); + + assert_eq!(a.box_of(text), b.box_of(text)); + assert_eq!(a.aabb_of(text), b.aabb_of(text)); + assert!( + !resolved_bits_eq(&a, &b), + "a replay comparison must not discard text resolution identity" + ); +} + +#[test] +fn resolved_bit_equality_includes_path_geometry_and_fill_rule_not_source_spelling() { + let source = |d: &str, fill_rule: &str| { + grida_xml::parse(&format!( + r#""# + )) + .unwrap() + }; + let horizontal = source("M 0 0 H 1 V 1 Z", ""); + let equivalent = source("m 0 0 h 1 v 1 z", ""); + let vertical = source("M 0 0 V 1 H 1 Z", ""); + let evenodd = source("M 0 0 H 1 V 1 Z", r#" fill-rule="evenodd""#); + + let horizontal = resolve(&horizontal, &opts()); + let equivalent = resolve(&equivalent, &opts()); + let vertical = resolve(&vertical, &opts()); + let evenodd = resolve(&evenodd, &opts()); + assert_eq!(horizontal.aabb_of(2), vertical.aabb_of(2)); + assert!(resolved_bits_eq(&horizontal, &equivalent)); + assert!(!resolved_bits_eq(&horizontal, &vertical)); + assert!(!resolved_bits_eq(&horizontal, &evenodd)); +} diff --git a/model-v2/engine/tests/strokes.rs b/model-v2/engine/tests/strokes.rs new file mode 100644 index 0000000000..417f1d4dd0 --- /dev/null +++ b/model-v2/engine/tests/strokes.rs @@ -0,0 +1,310 @@ +//! 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. + +mod support; + +use anchor_engine::drawlist::ItemKind; +use anchor_engine::paint::PaintCtx; +use anchor_lab::model::{Paint, StrokeAlign, StrokeJoin, StrokeWidth}; +use skia_safe::{Color, FontMgr}; +use support::{render_xml as render, render_xml_on as render_on}; + +#[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, StrokeWidth::Uniform(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, StrokeWidth::Uniform(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!(image.at(2, 40), [255, 255, 255, 255]); + assert_eq!(image.at(17, 40), [255, 0, 0, 255]); + assert_eq!(image.at(22, 40), [0, 0, 255, 255]); + assert_eq!(image.at(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 = image.at(10, 20); + let overlap = image.at(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 = image.at(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 = image.at(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!(image.at(18, 30), [255, 255, 255, 255]); + assert_eq!(image.at(22, 30), [255, 0, 0, 255]); + assert_eq!(image.at(58, 30), [0, 0, 255, 255]); + assert_eq!(image.at(65, 30), [0, 255, 0, 255]); + assert_eq!(image.at(98, 30), [255, 0, 255, 255]); + assert_eq!(image.at(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!(image.at(16, 15), [255, 255, 255, 255]); + assert_eq!(image.at(16, 40), [0, 0, 255, 255]); + assert_eq!(image.at(15, 65), [0, 170, 0, 255]); + assert_eq!(image.at(25, 65), [255, 255, 255, 255]); + assert_eq!(image.at(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!(image.at(87, 55), [255, 0, 0, 255]); + assert_eq!(image.at(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!(image.at(16, 16), [255, 0, 0, 255]); + assert_eq!(image.at(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 = image.at(50, 12); + let wide = image.at(50, 52); + for channel in 0..4 { + assert!( + thin[channel].abs_diff(wide[channel]) <= 1, + "thin={thin:?} wide={wide:?}" + ); + } + let left = image.at(15, 12); + let right = image.at(105, 12); + assert!(left[0] > left[2]); + assert!(right[2] > right[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 = image.at(15, 20); + let right = image.at(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!(image.at(50, 8), [255, 255, 255, 255]); + assert_eq!(image.at(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, _] = image.at(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/support/mod.rs b/model-v2/engine/tests/support/mod.rs new file mode 100644 index 0000000000..8e1ca7c3e9 --- /dev/null +++ b/model-v2/engine/tests/support/mod.rs @@ -0,0 +1,93 @@ +use anchor_engine::drawlist::DrawList; +use anchor_engine::frame; +use anchor_engine::paint::PaintCtx; +use anchor_lab::grida_xml; +use anchor_lab::math::Affine; +use anchor_lab::resolve::ResolveOptions; +use skia_safe::{ + image::CachingHint, surfaces, AlphaType, Color, ColorType, IPoint, Image, ImageInfo, +}; + +/// RGBA8888, unpremultiplied readback for stable raster probes. +/// +/// Read the whole image once: probe-heavy tests should not cross Skia's +/// readback boundary once per pixel. +pub struct RgbaImage { + width: i32, + height: i32, + pixels: Vec, +} + +impl RgbaImage { + pub fn from_image(image: &Image) -> Self { + let width = image.width(); + let height = image.height(); + assert!(width > 0 && height > 0, "positive raster dimensions"); + let info = ImageInfo::new( + (width, height), + ColorType::RGBA8888, + AlphaType::Unpremul, + None, + ); + let row_bytes = width as usize * 4; + let mut pixels = vec![0; row_bytes * height as usize]; + assert!( + image.read_pixels( + &info, + &mut pixels, + row_bytes, + IPoint::new(0, 0), + CachingHint::Disallow, + ), + "read RGBA raster" + ); + Self { + width, + height, + pixels, + } + } + + pub fn at(&self, x: i32, y: i32) -> [u8; 4] { + assert!( + (0..self.width).contains(&x) && (0..self.height).contains(&y), + "RGBA probe ({x}, {y}) outside {}x{} raster", + self.width, + self.height + ); + let offset = ((y * self.width + x) * 4) as usize; + self.pixels[offset..offset + 4] + .try_into() + .expect("four-byte RGBA pixel") + } +} + +pub fn render_xml(source: &str, width: i32, height: i32, ctx: &PaintCtx) -> (RgbaImage, DrawList) { + render_xml_on(source, width, height, Color::WHITE, ctx) +} + +pub fn render_xml_on( + source: &str, + width: i32, + height: i32, + clear: Color, + ctx: &PaintCtx, +) -> (RgbaImage, DrawList) { + let doc = grida_xml::parse(source).expect("Grida XML 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 (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()); + let (_, drawlist, _) = product.into_parts(); + (image, drawlist) +} diff --git a/model-v2/engine/tests/text.rs b/model-v2/engine/tests/text.rs new file mode 100644 index 0000000000..b23154e33c --- /dev/null +++ b/model-v2/engine/tests/text.rs @@ -0,0 +1,451 @@ +//! 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. + +mod support; + +use anchor_engine::drawlist::{build_glyphless_unchecked, 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::{ + AttributedString, Color, DocBuilder, Header, Paint as ModelPaint, Paints, Payload, SizeIntent, + StyledTextRun, TextStyleRec, +}; +use anchor_lab::resolve::Resolved; +use anchor_lab::resolve::{resolve, ResolveOptions}; +use skia_safe::{surfaces, Color as SkColor, FontMgr}; +use support::RgbaImage; + +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) -> (RgbaImage, DrawList) { + support::render_xml(source, width, height, &paint_ctx()) +} + +fn render_document( + document: &anchor_lab::model::Document, + width: i32, + height: i32, +) -> (RgbaImage, DrawList) { + let (image, _, list) = render_document_full(document, width, height); + (image, list) +} + +fn render_document_full( + document: &anchor_lab::model::Document, + width: i32, + height: i32, +) -> (RgbaImage, Resolved, DrawList) { + let mut surface = surfaces::raster_n32_premul((width, height)).expect("raster surface"); + surface.canvas().clear(SkColor::WHITE); + let options = ResolveOptions { + viewport: (width as f32, height as f32), + ..Default::default() + }; + 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, + list, + ) +} + +fn attributed_document( + text: &str, + runs: Vec, + default_style: TextStyleRec, + width: f32, +) -> (anchor_lab::model::Document, u32) { + let mut builder = DocBuilder::new(); + let text_id = builder.add( + 0, + Header::new(SizeIntent::Fixed(width), SizeIntent::Auto), + Payload::AttributedText { + attributed_string: AttributedString::from_runs(text, runs).unwrap(), + default_style, + }, + ); + let mut document = builder.build(); + document.get_mut(text_id).fills = Paints::solid(Color::BLACK); + (document, text_id) +} + +fn count_pixels( + image: &RgbaImage, + width: i32, + ys: std::ops::Range, + predicate: impl Fn([u8; 4]) -> bool, +) -> usize { + ys.flat_map(|y| (0..width).map(move |x| image.at(x, y))) + .filter(|pixel| predicate(*pixel)) + .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_glyphless_unchecked(&doc, &resolved); + let lines = list + .items + .iter() + .find_map(|item| match &item.kind { + ItemKind::TextFill { layout, .. } => Some(&layout.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).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_glyphless_unchecked(&doc, &resolved); + let topologies = list + .items + .iter() + .filter_map(|item| match &item.kind { + ItemKind::TextFill { layout, .. } | ItemKind::TextStroke { layout, .. } => Some(layout), + _ => None, + }) + .collect::>(); + assert_eq!(topologies.len(), 3); + assert!(topologies[1..] + .iter() + .all(|layout| std::sync::Arc::ptr_eq(topologies[0], layout))); +} + +#[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 { layout, .. } => Some(&layout.lines), + _ => None, + }) + .expect("text stroke item"); + assert_eq!( + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + ["I", "", "I"] + ); +} + +#[test] +fn attributed_drawlist_preserves_run_metrics_style_and_fill_fallback() { + let small = TextStyleRec::from_font_size(10.0); + let large = TextStyleRec { + font_size: 20.0, + font_weight: 700, + font_style_italic: true, + }; + let (document, _) = attributed_document( + "ABCD", + vec![ + StyledTextRun { + start: 0, + end: 1, + style: small, + fills: None, + }, + StyledTextRun { + start: 1, + end: 2, + style: large, + fills: Some(Paints::solid("#FF0000".into())), + }, + StyledTextRun { + start: 2, + end: 3, + style: large, + fills: Some(Paints::default()), + }, + StyledTextRun { + start: 3, + end: 4, + style: small, + fills: Some(Paints::solid("#0000FF".into())), + }, + ], + small, + 80.0, + ); + let resolved = resolve(&document, &ResolveOptions::default()); + let list = build_glyphless_unchecked(&document, &resolved); + let (layout, paints, paint_w, paint_h) = list + .items + .iter() + .find_map(|item| match &item.kind { + ItemKind::TextFill { + layout, + paints, + paint_w, + paint_h, + } => Some((layout, paints, *paint_w, *paint_h)), + _ => None, + }) + .expect("attributed fill item"); + assert_eq!((paint_w, paint_h), (80.0, 24.0)); + assert_eq!(layout.lines.len(), 1); + assert_eq!(layout.lines[0].text, "ABCD"); + assert!( + paints + .for_source_run(Some(2)) + .expect("valid attributed run") + .is_empty(), + "explicit empty run fill suppresses fallback" + ); + let colors = (0..4) + .map(|index| { + match paints + .for_source_run(Some(index)) + .expect("valid attributed run") + .as_slice() + { + [ModelPaint::Solid(paint)] => Some(paint.color), + [] => None, + _ => panic!("expected singleton solid or explicit empty fill"), + } + }) + .collect::>(); + assert_eq!( + colors, + [ + Some(Color::BLACK), + Some("#FF0000".into()), + None, + Some("#0000FF".into()) + ] + ); +} + +#[test] +fn attributed_wrap_and_auto_height_share_run_aware_line_metrics() { + let small = TextStyleRec::from_font_size(10.0); + let large = TextStyleRec::from_font_size(20.0); + let (document, text_id) = attributed_document( + "AA bb", + vec![ + StyledTextRun { + start: 0, + end: 3, + style: small, + fills: None, + }, + StyledTextRun { + start: 3, + end: 5, + style: large, + fills: None, + }, + ], + small, + 30.0, + ); + let resolved = resolve(&document, &ResolveOptions::default()); + assert_eq!(resolved.box_of(text_id).h, 36.0); + let list = build_glyphless_unchecked(&document, &resolved); + let lines = list + .items + .iter() + .find_map(|item| match &item.kind { + ItemKind::TextFill { layout, .. } => Some(&layout.lines), + _ => None, + }) + .unwrap(); + assert_eq!( + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + ["AA", "bb"] + ); + assert_eq!( + lines.iter().map(|line| line.baseline).collect::>(), + [8.5, 29.0] + ); +} + +#[test] +fn attributed_painter_renders_mixed_sizes_and_colors() { + let small = TextStyleRec::from_font_size(12.0); + let large = TextStyleRec::from_font_size(30.0); + let (document, _) = attributed_document( + "I I", + vec![ + StyledTextRun { + start: 0, + end: 2, + style: small, + fills: None, + }, + StyledTextRun { + start: 2, + end: 3, + style: large, + fills: Some(Paints::solid("#FF0000".into())), + }, + ], + small, + 80.0, + ); + let (image, _) = render_document(&document, 80, 40); + let dark = |[r, g, b, _]: [u8; 4]| r < 80 && g < 80 && b < 80; + let red = |[r, g, b, _]: [u8; 4]| r > 160 && g < 100 && b < 100; + assert!(count_pixels(&image, 80, 0..40, dark) > 0); + assert!(count_pixels(&image, 80, 0..40, red) > 0); +} + +#[test] +fn frame_shares_one_shaped_layout_with_fill_and_every_stroke() { + let source = r##"office AV"##; + let document = grida_xml::parse(source).unwrap(); + let container = document.get(document.root).children[0]; + let text_id = document.get(container).children[0]; + let (_, resolved, list) = render_document_full(&document, 180, 80); + let resolved_layout = resolved.text_layout_of(text_id); + + assert_eq!( + resolved_layout.oracle, + anchor_engine::oracle::TEXT_SKPARAGRAPH + ); + assert!(!resolved_layout.glyph_runs.is_empty()); + assert!(resolved_layout.ink_bounds.is_some()); + assert!(resolved_layout.logical_bounds.unwrap().w <= resolved.box_of(text_id).w); + assert!((resolved.box_of(text_id).h - resolved_layout.height).abs() < 0.001); + + let item_layouts = list + .items + .iter() + .filter_map(|item| match &item.kind { + ItemKind::TextFill { layout, .. } | ItemKind::TextStroke { layout, .. } => Some(layout), + _ => None, + }) + .collect::>(); + assert_eq!(item_layouts.len(), 3); + assert!(item_layouts + .iter() + .all(|layout| std::sync::Arc::ptr_eq(resolved_layout, layout))); +} + +#[test] +fn real_shaping_drives_auto_width_for_proportional_glyphs() { + let mut builder = DocBuilder::new(); + let narrow = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: "iiii".into(), + font_size: 24.0, + }, + ); + let wide = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: "WWWW".into(), + font_size: 24.0, + }, + ); + let document = builder.build(); + let (_, resolved, _) = render_document_full(&document, 200, 80); + + assert!( + resolved.box_of(wide).w > resolved.box_of(narrow).w * 2.0, + "a real proportional font must replace equal-character-count estimates" + ); + assert_eq!( + resolved.box_of(narrow).w.to_bits(), + resolved.text_layout_of(narrow).width.to_bits() + ); + assert_eq!( + resolved.box_of(wide).w.to_bits(), + resolved.text_layout_of(wide).width.to_bits() + ); +} + +#[test] +fn paint_only_run_boundary_survives_shaping_without_ambiguous_glyph_ownership() { + let style = TextStyleRec::from_font_size(30.0); + let (document, text_id) = attributed_document( + "fi", + vec![ + StyledTextRun { + start: 0, + end: 1, + style, + fills: None, + }, + StyledTextRun { + start: 1, + end: 2, + style, + fills: Some(Paints::solid("#FF0000".into())), + }, + ], + style, + 80.0, + ); + let (_, resolved, _) = render_document_full(&document, 80, 48); + let layout = resolved.text_layout_of(text_id); + assert_eq!( + layout + .glyph_runs + .iter() + .map(|run| run.source_run) + .collect::>(), + [Some(0), Some(1)] + ); + assert!(layout.glyph_runs.iter().all(|run| !run.glyphs.is_empty())); +} diff --git a/model-v2/engine/tests/text_layout.rs b/model-v2/engine/tests/text_layout.rs new file mode 100644 index 0000000000..a2f9798031 --- /dev/null +++ b/model-v2/engine/tests/text_layout.rs @@ -0,0 +1,430 @@ +//! Integration tests for the shaped text-layout artifact produced by the +//! complete frame pipeline. These are semantic data tests: the bundled Inter +//! face fixes the font input, while assertions avoid backend glyph ids and +//! raster pixels except where the face itself declares its `.notdef` id. + +use std::collections::BTreeMap; +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_unchecked, PaintCtx}; +use anchor_lab::math::Affine; +use anchor_lab::model::{ + AttributedString, Color, DocBuilder, Document, Header, Paints, Payload, SizeIntent, Stroke, + StyledTextRun, TextStyleRec, +}; +use anchor_lab::resolve::{Report, ResolveOptions, Resolved}; +use anchor_lab::text_layout::TextLineBreak; +use skia_safe::{surfaces, FontMgr, Typeface}; + +const INTER: &[u8] = + include_bytes!("../../../fixtures/fonts/Inter/Inter-VariableFont_opsz,wght.ttf"); + +fn inter_typeface() -> Typeface { + FontMgr::new() + .new_from_data(INTER, None) + .expect("bundled Inter typeface") +} + +fn uniform_text_document(text: &str, width: f32, font_size: f32) -> (Document, u32) { + let mut builder = DocBuilder::new(); + let text_id = builder.add( + 0, + Header::new(SizeIntent::Fixed(width), SizeIntent::Auto), + Payload::Text { + content: text.to_owned(), + font_size, + }, + ); + let mut document = builder.build(); + document.get_mut(text_id).fills = Paints::solid(Color::BLACK); + (document, text_id) +} + +fn render(document: &Document) -> (Resolved, DrawList) { + render_with_typeface(document, inter_typeface()) +} + +fn render_with_typeface(document: &Document, typeface: Typeface) -> (Resolved, DrawList) { + let context = PaintCtx::new(Some(typeface)); + render_with_context(document, &context) +} + +fn render_with_context(document: &Document, context: &PaintCtx) -> (Resolved, DrawList) { + let mut surface = surfaces::raster_n32_premul((640, 480)).expect("raster surface"); + let options = ResolveOptions { + viewport: (640.0, 480.0), + ..Default::default() + }; + 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) +} + +#[test] +fn frame_materializes_explicit_and_trailing_empty_lines() { + let source = "first\n\nlast\n"; + let (document, text_id) = uniform_text_document(source, 320.0, 18.0); + let (resolved, _) = render(&document); + let layout = resolved.text_layout_of(text_id); + + assert_eq!(layout.oracle, TEXT_SKPARAGRAPH); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + ["first", "", "last", ""], + "each authored newline establishes the following visual line" + ); + assert_eq!( + layout.lines.len(), + source.bytes().filter(|b| *b == b'\n').count() + 1 + ); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.byte_range.clone()) + .collect::>(), + [0..5, 6..6, 7..11, 12..12] + ); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.source_range.clone()) + .collect::>(), + [0..6, 6..7, 7..12, 12..12] + ); + assert_eq!( + layout.lines.iter().map(|line| line.end).collect::>(), + [ + TextLineBreak::Explicit, + TextLineBreak::Explicit, + TextLineBreak::Explicit, + TextLineBreak::Terminal, + ] + ); + assert!(layout + .lines + .windows(2) + .all(|pair| pair[0].top < pair[1].top && pair[0].baseline < pair[1].baseline)); + assert!(layout + .glyph_runs + .iter() + .all(|run| matches!(run.line_index, 0 | 2))); +} + +#[test] +fn empty_source_uses_default_font_metrics_without_inventing_source_or_ink() { + let (document, text_id) = uniform_text_document("", 320.0, 18.0); + let (resolved, _) = render(&document); + let layout = resolved.text_layout_of(text_id); + + assert_eq!(layout.lines.len(), 1); + let line = &layout.lines[0]; + assert_eq!(line.text, ""); + assert_eq!(line.byte_range, 0..0); + assert_eq!(line.source_range, 0..0); + assert_eq!(line.end, TextLineBreak::Terminal); + assert!(line.height > 0.0); + assert!(line.baseline > 0.0); + assert_eq!(layout.width, 0.0); + assert_eq!(layout.height, line.height); + assert_eq!(layout.assigned_box.w, 320.0); + assert_eq!(layout.assigned_box.h, layout.height); + assert!(layout.glyph_runs.is_empty()); + assert_eq!(layout.ink_bounds, None); +} + +#[test] +fn auto_sized_empty_source_keeps_one_terminal_line_at_zero_inline_extent() { + let mut builder = DocBuilder::new(); + let text_id = builder.add( + 0, + Header::new(SizeIntent::Auto, SizeIntent::Auto), + Payload::Text { + content: String::new(), + font_size: 18.0, + }, + ); + let (resolved, _) = render(&builder.build()); + let layout = resolved.text_layout_of(text_id); + + assert_eq!(resolved.box_of(text_id).w, 0.0); + assert!(resolved.box_of(text_id).h > 0.0); + assert_eq!(layout.width_constraint, Some(0.0)); + assert_eq!(layout.lines.len(), 1); + assert_eq!(layout.lines[0].end, TextLineBreak::Terminal); + assert_eq!(layout.lines[0].source_range, 0..0); + assert!(layout.glyph_runs.is_empty()); +} + +#[test] +fn glyph_clusters_are_utf8_byte_offsets() { + let source = "AéΩB"; + let (document, text_id) = uniform_text_document(source, 320.0, 24.0); + let (resolved, _) = render(&document); + let layout = resolved.text_layout_of(text_id); + let clusters = layout + .glyph_runs + .iter() + .flat_map(|run| run.glyphs.iter().map(|glyph| glyph.cluster)) + .collect::>(); + let expected = source + .char_indices() + .map(|(byte, _)| byte as u32) + .collect::>(); + + assert_eq!(clusters, expected); + assert!(clusters + .iter() + .all(|byte| source.is_char_boundary(*byte as usize))); + assert_eq!(layout.lines[0].byte_range, 0..source.len() as u32); +} + +#[test] +fn non_ascii_line_ranges_remain_utf8_after_wrap_and_explicit_break() { + let source = "éé éé\nΩΩ"; + let (document, text_id) = uniform_text_document(source, 32.0, 20.0); + let (resolved, _) = render(&document); + let lines = &resolved.text_layout_of(text_id).lines; + + assert!(lines.len() >= 2); + assert_eq!(lines.first().unwrap().source_range.start, 0); + assert_eq!(lines.last().unwrap().source_range.end, source.len() as u32); + assert!(lines.iter().any(|line| line.end == TextLineBreak::Explicit)); + assert_eq!(lines.last().unwrap().end, TextLineBreak::Terminal); + + for line in lines { + let byte_start = line.byte_range.start as usize; + let byte_end = line.byte_range.end as usize; + let source_start = line.source_range.start as usize; + let source_end = line.source_range.end as usize; + assert!(source.is_char_boundary(byte_start)); + assert!(source.is_char_boundary(byte_end)); + assert!(source.is_char_boundary(source_start)); + assert!(source.is_char_boundary(source_end)); + assert_eq!(line.text, source[byte_start..byte_end]); + assert!(source_start <= byte_start && byte_end <= source_end); + } + assert!(lines + .windows(2) + .all(|pair| pair[0].source_range.end == pair[1].source_range.start)); +} + +#[test] +fn width_constraint_changes_line_topology_without_changing_source_order() { + let source = "alpha beta gamma delta"; + let (wide_document, wide_id) = uniform_text_document(source, 400.0, 20.0); + let (narrow_document, narrow_id) = uniform_text_document(source, 76.0, 20.0); + let (wide_resolved, _) = render(&wide_document); + let (narrow_resolved, _) = render(&narrow_document); + let wide = wide_resolved.text_layout_of(wide_id); + let narrow = narrow_resolved.text_layout_of(narrow_id); + + assert_eq!(wide.lines.len(), 1); + assert!(narrow.lines.len() > wide.lines.len()); + assert!(narrow.height > wide.height); + assert_eq!(wide.lines[0].end, TextLineBreak::Terminal); + assert!(narrow.lines[..narrow.lines.len() - 1] + .iter() + .all(|line| line.end == TextLineBreak::Soft)); + assert_eq!(narrow.lines.last().unwrap().end, TextLineBreak::Terminal); + assert!(narrow.lines.iter().all(|line| line.width <= 76.0)); + assert_eq!(narrow.lines.first().unwrap().byte_range.start, 0); + assert_eq!( + narrow.lines.last().unwrap().byte_range.end, + source.len() as u32 + ); + assert!(narrow + .lines + .windows(2) + .all(|pair| pair[0].byte_range.end <= pair[1].byte_range.start)); +} + +#[test] +fn resolved_artifact_replays_exact_font_instances_and_positioned_glyphs() { + let source = "aBb"; + let small = TextStyleRec::from_font_size(16.0); + let large = TextStyleRec { + font_size: 30.0, + font_weight: 700, + font_style_italic: false, + }; + let runs = vec![ + StyledTextRun { + start: 0, + end: 1, + style: small, + fills: None, + }, + StyledTextRun { + start: 1, + end: 2, + style: large, + fills: None, + }, + StyledTextRun { + start: 2, + end: 3, + style: small, + fills: None, + }, + ]; + let attributed = AttributedString::from_runs(source, runs).unwrap(); + let mut builder = DocBuilder::new(); + let text_id = builder.add( + 0, + Header::new(SizeIntent::Fixed(200.0), SizeIntent::Auto), + Payload::AttributedText { + attributed_string: attributed, + default_style: small, + }, + ); + let mut document = builder.build(); + let node = document.get_mut(text_id); + node.fills = Paints::solid(Color::BLACK); + let mut stroke = Stroke::default_for(&node.payload).expect("text supports an outline"); + stroke.paints = Paints::solid("#FF0000".into()); + node.strokes.push(stroke); + + let (resolved, list) = render(&document); + let layout = resolved.text_layout_of(text_id); + let fonts_by_source = layout + .glyph_runs + .iter() + .map(|run| { + assert!(!run.glyphs.is_empty()); + assert!(run.line_index < layout.lines.len()); + assert!(run.glyphs.iter().all(|glyph| { + source.is_char_boundary(glyph.cluster as usize) + && glyph.x.is_finite() + && glyph.y.is_finite() + })); + (run.source_run.expect("attributed source run"), run.font) + }) + .collect::>(); + + assert_eq!(fonts_by_source.len(), 3); + assert_eq!(fonts_by_source[&0], fonts_by_source[&2]); + assert_ne!(fonts_by_source[&0], fonts_by_source[&1]); + assert!(layout.ink_bounds.is_some()); + + let replay_layouts = list + .items + .iter() + .filter_map(|item| match &item.kind { + ItemKind::TextFill { layout, .. } | ItemKind::TextStroke { layout, .. } => Some(layout), + _ => None, + }) + .collect::>(); + assert_eq!(replay_layouts.len(), 2); + assert!(replay_layouts + .iter() + .all(|replay| Arc::ptr_eq(layout, replay))); +} + +#[test] +fn missing_scalar_is_reported_as_unresolved_instead_of_silent_fallback() { + let missing = '\u{10ffff}'; + let typeface = inter_typeface(); + let notdef = typeface.unichar_to_glyph(missing as i32); + assert_eq!( + notdef, 0, + "fixture scalar must be absent from bundled Inter" + ); + + let source = format!("A{missing}B"); + let (document, text_id) = uniform_text_document(&source, 320.0, 24.0); + let (resolved, _) = render_with_typeface(&document, typeface); + let layout = resolved.text_layout_of(text_id); + + assert!(layout.unresolved_glyphs > 0); + assert!(resolved.reports.iter().any(|report| matches!( + report, + Report::ErrorByRule { node, field, rule } + if *node == text_id + && *field == "text" + && *rule == "text layout contains unresolved glyphs" + ))); + assert_eq!(layout.lines[0].byte_range, 0..source.len() as u32); +} + +#[test] +fn shaped_drawlist_retains_exact_fonts_after_its_shaping_context_is_gone() { + let (document, _) = uniform_text_document("office AV", 320.0, 28.0); + // `render` drops the PaintCtx that hosted shaping before returning. + let (_, list) = render(&document); + + let fontless_context = PaintCtx::new(None); + 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_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)); +} + +#[test] +fn public_non_rasterizing_stage_returns_replayable_shaped_text() { + let (document, text_id) = uniform_text_document("office AV", 180.0, 24.0); + let shaping_context = PaintCtx::new(Some(inter_typeface())); + let options = ResolveOptions { + viewport: (640.0, 480.0), + ..Default::default() + }; + + 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_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" + ); +} + +#[test] +fn font_identity_and_drawlist_are_stable_across_fresh_resolves_in_one_environment() { + let (document, text_id) = uniform_text_document("office AV", 320.0, 28.0); + let context = PaintCtx::new(Some(inter_typeface())); + let (first_resolved, first_list) = render_with_context(&document, &context); + let (second_resolved, second_list) = render_with_context(&document, &context); + + let identities = |resolved: &Resolved| { + resolved + .text_layout_of(text_id) + .glyph_runs + .iter() + .map(|run| run.font_identity.clone()) + .collect::>() + }; + assert_eq!(identities(&first_resolved), identities(&second_resolved)); + assert_eq!( + first_list, second_list, + "fresh resolution must not mint false display-list changes" + ); +} 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); +} 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..177d77fa22 --- /dev/null +++ b/model-v2/models/a.md @@ -0,0 +1,496 @@ +# 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 | 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 | +| `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, fill_rule: NonZero | EvenOdd = NonZero } + // full SVG path grammar in fixed 0 0 1 1 space + | Line // the box's horizontal midline; height intent must be Fixed(0) +``` + +`Path` is declared-box geometry, not intrinsically measured SVG content. The +exact tight geometry represented by `d` must stay within the closed unit +rectangle. Resolution maps `(u, v)` to `(u × width, v × height)` before fill +or stroke coverage is constructed, with independent axis scales. Layout, +span, aspect-ratio, grow, and stretch therefore change the realized outline +without rewriting `d`; stroke widths and dash lengths remain px-stable. Paints +use the full resolved box, and path children use ordinary box-local +coordinates rather than the unit path space. The path does not clip them. +Resolution materializes one box-mapped command artifact and derives +conservative local bounds from that exact command stream. Paint, repeated +strokes, damage, and bounds share it; they do not each rescale `d` or its unit +bounds. A mapping that exceeds the resolved numeric domain is an explicit +resolution error and contributes no partial path ink. + +Raw intrinsic coordinates and continually re-derived tight bounds are not a +second `Path` mode. They would give one kind two box sources, make geometry +edits change layout contribution, and make flex-assigned sizes ambiguous. +Tight-fitting raw coordinates also destroys intentional padding, fails on a +zero-extent axis, and remaps untouched contours when one extremum changes. A +future non-unit reference rectangle may be additive, but it must be stable +authored intent and never a tight-bounds cache serialized as source. + +`vector` is deliberately _not_ a descriptor: its network is editable geometry +rather than a compact render path. `shape/Path` remains the render-oriented, +box-mapped form. + +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. + +### 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 + shape/Path: realized unit tight geometry → local base bounds + all: local bounds → world AABBs → conservative stroke/effect inflation +``` + +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 — 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 + + + + + + + + + + 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. +- Raw intrinsic path sizing → one declared box plus unit-space `Path` + realization; no tight-bounds path mode. +- `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. +An optional non-unit path reference rectangle is also deferred; if added, it +must preserve the same declared-box mapping and stable-reference law. 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..813fdcaa31 --- /dev/null +++ b/model-v2/problems.md @@ -0,0 +1,543 @@ +# 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? + +**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 + 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.