diff --git a/.beads/.gitignore b/.beads/.gitignore deleted file mode 100644 index aef2b5b9..00000000 --- a/.beads/.gitignore +++ /dev/null @@ -1,65 +0,0 @@ -# Dolt database (managed by Dolt, not git) -dolt/ -dolt-access.lock - -# Runtime files -bd.sock -bd.sock.startlock -sync-state.json -last-touched -.exclusive-lock - -# Daemon runtime (lock, log, pid) -daemon.* - -# Interactions log (runtime, not versioned) -interactions.jsonl - -# Push state (runtime, per-machine) -push-state.json - -# Lock files (various runtime locks) -*.lock - -# Local version tracking (prevents upgrade notification spam after git ops) -.local_version - -# Worktree redirect file (contains relative path to main repo's .beads/) -# Must not be committed as paths would be wrong in other clones -redirect - -# Sync state (local-only, per-machine) -# These files are machine-specific and should not be shared across clones -.sync.lock -export-state/ - -# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) -ephemeral.sqlite3 -ephemeral.sqlite3-journal -ephemeral.sqlite3-wal -ephemeral.sqlite3-shm - -# Dolt server management (auto-started by bd) -dolt-server.pid -dolt-server.log -dolt-server.lock -dolt-server.port - -# Corrupt backup directories (created by bd doctor --fix recovery) -*.corrupt.backup/ - -# Backup data (auto-exported JSONL, local-only) -backup/ - -# Legacy files (from pre-Dolt versions) -*.db -*.db?* -*.db-journal -*.db-wal -*.db-shm -db.sqlite -bd.db -# NOTE: Do NOT add negation patterns here. -# They would override fork protection in .git/info/exclude. -# Config files (metadata.json, config.yaml) are tracked by git by default -# since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md deleted file mode 100644 index dbfe3631..00000000 --- a/.beads/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Beads - AI-Native Issue Tracking - -Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. - -## What is Beads? - -Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. - -**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) - -## Quick Start - -### Essential Commands - -```bash -# Create new issues -bd create "Add user authentication" - -# View all issues -bd list - -# View issue details -bd show - -# Update issue status -bd update --claim -bd update --status done - -# Sync with Dolt remote -bd dolt push -``` - -### Working with Issues - -Issues in Beads are: -- **Git-native**: Stored in Dolt database with version control and branching -- **AI-friendly**: CLI-first design works perfectly with AI coding agents -- **Branch-aware**: Issues can follow your branch workflow -- **Always in sync**: Auto-syncs with your commits - -## Why Beads? - -✨ **AI-Native Design** -- Built specifically for AI-assisted development workflows -- CLI-first interface works seamlessly with AI coding agents -- No context switching to web UIs - -🚀 **Developer Focused** -- Issues live in your repo, right next to your code -- Works offline, syncs when you push -- Fast, lightweight, and stays out of your way - -🔧 **Git Integration** -- Automatic sync with git commits -- Branch-aware issue tracking -- Dolt-native three-way merge resolution - -## Get Started with Beads - -Try Beads in your own projects: - -```bash -# Install Beads -curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash - -# Initialize in your repo -bd init - -# Create your first issue -bd create "Try out Beads" -``` - -## Learn More - -- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) -- **Quick Start Guide**: Run `bd quickstart` -- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) - ---- - -*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml deleted file mode 100644 index e831a6be..00000000 --- a/.beads/config.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# Beads Configuration File -# This file configures default behavior for all bd commands in this repository -# All settings can also be set via environment variables (BD_* prefix) -# or overridden with command-line flags - -# Issue prefix for this repository (used by bd init) -# If not set, bd init will auto-detect from directory name -# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" - -# Use no-db mode: JSONL-only, no Dolt database -# When true, bd will use .beads/issues.jsonl as the source of truth -# no-db: false - -# Enable JSON output by default -# json: false - -# Feedback title formatting for mutating commands (create/update/close/dep/edit) -# 0 = hide titles, N > 0 = truncate to N characters -# output: -# title-length: 255 - -# Default actor for audit trails (overridden by BD_ACTOR or --actor) -# actor: "" - -# Export events (audit trail) to .beads/events.jsonl on each flush/sync -# When enabled, new events are appended incrementally using a high-water mark. -# Use 'bd export --events' to trigger manually regardless of this setting. -# events-export: false - -# Multi-repo configuration (experimental - bd-307) -# Allows hydrating from multiple repositories and routing writes to the correct database -# repos: -# primary: "." # Primary repo (where this database lives) -# additional: # Additional repos to hydrate from (read-only) -# - ~/beads-planning # Personal planning repo -# - ~/work-planning # Work planning repo - -# JSONL backup (periodic export for off-machine recovery) -# Auto-enabled when a git remote exists. Override explicitly: -# backup: -# enabled: false # Disable auto-backup entirely -# interval: 15m # Minimum time between auto-exports -# git-push: false # Disable git push (export locally only) -# git-repo: "" # Separate git repo for backups (default: project repo) - -# Integration settings (access with 'bd config get/set') -# These are stored in the database, not in this file: -# - jira.url -# - jira.project -# - linear.url -# - linear.api-key -# - github.org -# - github.repo diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout deleted file mode 100755 index 2a650b10..00000000 --- a/.beads/hooks/post-checkout +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.61.0 --- -# This section is managed by beads. Do not remove these markers. -if command -v bd >/dev/null 2>&1; then - export BD_GIT_HOOK=1 - _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} - if command -v timeout >/dev/null 2>&1; then - timeout "$_bd_timeout" bd hooks run post-checkout "$@" - _bd_exit=$? - if [ $_bd_exit -eq 124 ]; then - echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi - else - bd hooks run post-checkout "$@" - _bd_exit=$? - fi - if [ $_bd_exit -eq 3 ]; then - echo >&2 "beads: database not initialized — skipping hook 'post-checkout'" - _bd_exit=0 - fi - if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi -fi -# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge deleted file mode 100755 index bd1c1750..00000000 --- a/.beads/hooks/post-merge +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.61.0 --- -# This section is managed by beads. Do not remove these markers. -if command -v bd >/dev/null 2>&1; then - export BD_GIT_HOOK=1 - _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} - if command -v timeout >/dev/null 2>&1; then - timeout "$_bd_timeout" bd hooks run post-merge "$@" - _bd_exit=$? - if [ $_bd_exit -eq 124 ]; then - echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi - else - bd hooks run post-merge "$@" - _bd_exit=$? - fi - if [ $_bd_exit -eq 3 ]; then - echo >&2 "beads: database not initialized — skipping hook 'post-merge'" - _bd_exit=0 - fi - if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi -fi -# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit deleted file mode 100755 index 62444031..00000000 --- a/.beads/hooks/pre-commit +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.61.0 --- -# This section is managed by beads. Do not remove these markers. -if command -v bd >/dev/null 2>&1; then - export BD_GIT_HOOK=1 - _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} - if command -v timeout >/dev/null 2>&1; then - timeout "$_bd_timeout" bd hooks run pre-commit "$@" - _bd_exit=$? - if [ $_bd_exit -eq 124 ]; then - echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi - else - bd hooks run pre-commit "$@" - _bd_exit=$? - fi - if [ $_bd_exit -eq 3 ]; then - echo >&2 "beads: database not initialized — skipping hook 'pre-commit'" - _bd_exit=0 - fi - if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi -fi -# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push deleted file mode 100755 index 9a5ee48c..00000000 --- a/.beads/hooks/pre-push +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.61.0 --- -# This section is managed by beads. Do not remove these markers. -if command -v bd >/dev/null 2>&1; then - export BD_GIT_HOOK=1 - _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} - if command -v timeout >/dev/null 2>&1; then - timeout "$_bd_timeout" bd hooks run pre-push "$@" - _bd_exit=$? - if [ $_bd_exit -eq 124 ]; then - echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi - else - bd hooks run pre-push "$@" - _bd_exit=$? - fi - if [ $_bd_exit -eq 3 ]; then - echo >&2 "beads: database not initialized — skipping hook 'pre-push'" - _bd_exit=0 - fi - if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi -fi -# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg deleted file mode 100755 index 43127137..00000000 --- a/.beads/hooks/prepare-commit-msg +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.61.0 --- -# This section is managed by beads. Do not remove these markers. -if command -v bd >/dev/null 2>&1; then - export BD_GIT_HOOK=1 - _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} - if command -v timeout >/dev/null 2>&1; then - timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@" - _bd_exit=$? - if [ $_bd_exit -eq 124 ]; then - echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi - else - bd hooks run prepare-commit-msg "$@" - _bd_exit=$? - fi - if [ $_bd_exit -eq 3 ]; then - echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'" - _bd_exit=0 - fi - if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi -fi -# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/metadata.json b/.beads/metadata.json deleted file mode 100644 index 008a2c01..00000000 --- a/.beads/metadata.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "database": "dolt", - "backend": "dolt", - "dolt_mode": "server", - "dolt_database": "frame", - "project_id": "baca0d04-a606-4028-bbd1-570c29ea4f38" -} \ No newline at end of file diff --git a/.cargo/config.toml b/.cargo/config.toml index 35049cbc..fdadb25b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,38 @@ [alias] xtask = "run --package xtask --" + +# The rustflags tables are Studio's, in the same order and with the same +# flags, so kache keys Frame's builds of shared crates (Dioxus, Blitz, +# architect, …) the same as Studio's and the store is shared across repos. +# Keep them in step with Studio's .cargo/config.toml. CI must not set +# RUSTFLAGS or CARGO_TARGET_*_RUSTFLAGS: either replaces these tables. + +# Frame pointers, so samply/perf can unwind. `target..rustflags` +# REPLACES `build.rustflags`, so each target table repeats the flag. +[build] +rustflags = ["-C", "force-frame-pointers=yes"] + +# mold (from the dev shell) and x86-64-v3 (AVX2/FMA) on Linux. +[target.x86_64-unknown-linux-gnu] +rustflags = [ + "-C", "force-frame-pointers=yes", + "-C", "link-arg=-fuse-ld=mold", + "-C", "target-cpu=x86-64-v3", +] + +[target.x86_64-apple-darwin] +rustflags = [ + "-C", "force-frame-pointers=yes", + "-C", "target-cpu=x86-64-v3", +] + +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", "force-frame-pointers=yes", + "--cfg", 'getrandom_backend="wasm_js"', +] + +# Git dependencies fetch through the git CLI, so credentials and SSH work +# the way they do for `git`. +[net] +git-fetch-with-cli = true diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 807d5983..00000000 --- a/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ - -# Use bd merge for beads JSONL files -.beads/issues.jsonl merge=beads diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 00000000..2db64440 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,70 @@ +name: checks + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# A PR's new push supersedes its old run; a push to main always finishes, +# so every main commit says whether it is green. +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# No RUSTFLAGS / CARGO_TARGET_*_RUSTFLAGS here: either would replace the +# rustflags tables in .cargo/config.toml, and with them the kache keys +# Frame shares with Studio. `-D warnings` goes on clippy instead. +env: + CARGO_TERM_COLOR: always + +# Every job runs on the THEBATTLESHIP pool (the repo is private). The +# runners set RUSTC_WRAPPER=kache over the host's shared store and give each +# instance a CI_CACHE_DIR; the toolchain and native libraries (Vulkan loader +# for the wgpu renders, mold) come from the flake's `ci` shell. +jobs: + lint: + name: Lint (fmt + clippy) + runs-on: [self-hosted, thebattleship] + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Target dir + run: echo "CARGO_TARGET_DIR=${CI_CACHE_DIR:?}/frame/lint/target" >> "$GITHUB_ENV" + - name: fmt + run: nix develop --accept-flake-config .#ci --command cargo fmt --all -- --check + - name: clippy + if: ${{ !cancelled() }} + run: nix develop --accept-flake-config .#ci --command cargo clippy --workspace --all-targets -- -D warnings + + check: + name: Check + runs-on: [self-hosted, thebattleship] + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Target dir + run: echo "CARGO_TARGET_DIR=${CI_CACHE_DIR:?}/frame/test/target" >> "$GITHUB_ENV" + - name: just check + run: nix develop --accept-flake-config .#ci --command just check + - name: just test + run: nix develop --accept-flake-config .#ci --command just test + + # The one status to require on main: it passes only when every job above + # succeeded; a skipped or cancelled job fails it. + ci: + name: ci + if: ${{ always() }} + needs: [lint, check] + runs-on: [self-hosted, thebattleship] + steps: + - name: all checks passed + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "lint/check: $RESULTS" + for r in $RESULTS; do [ "$r" = success ] || exit 1; done diff --git a/.gitignore b/.gitignore index b6dbf9c8..6b106775 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,11 @@ *~ .idea/ .vscode/ -node_modules/ -.pnpm-store/ +.tmp/ +.claude/worktrees/ +result -# Dolt database files (added by bd init) -.dolt/ -*.db -.beads/.beads-credential-key +# The plugin faces: generated by `just faces` (examples/plugin-faces), not +# kept in git (130 MB). +/examples/plugins/ +/examples/plugin-faces/generated/ diff --git a/.ralph-tui/config.toml b/.ralph-tui/config.toml deleted file mode 100644 index d3291b11..00000000 --- a/.ralph-tui/config.toml +++ /dev/null @@ -1,16 +0,0 @@ -configVersion = "2.1" -maxIterations = 10 -agent = "claude" -tracker = "beads-bv" -autoCommit = true -subagentTracingDetail = "full" - -[agentOptions] -model = "claude-sonnet-4-6" - -[trackerOptions] -beadsDir = ".beads" -labels = "" - -[notifications] -sound = "system" diff --git a/.ralph-tui/progress.md b/.ralph-tui/progress.md deleted file mode 100644 index 1f19a8e4..00000000 --- a/.ralph-tui/progress.md +++ /dev/null @@ -1,186 +0,0 @@ -# Ralph Progress Log - -This file tracks progress across iterations. Agents update this file -after each iteration and it's included in prompts for context. - -## Codebase Patterns (Study These First) - -### SVG Color + Opacity Pipeline -- `svg_color_paint_values(r: u8, g: u8, b: u8, a: f64) -> (String, Option)` — encodes RGB as hex, alpha via `fmt_f64(a)` (trims trailing zeros) -- `to_u8(v: f64) -> u8` — only used for RGB channels (hex rendering); never for alpha in SVG output -- `fmt_f64(v: f64) -> String` — 6 decimal places, trimmed (e.g., 0.7 not 0.700000) -- Rgba struct stores all channels as f64; alpha flows through without quantization in SVG path - -### Parity Test Workflow -- `tools/render_parity_actuals.sh --all` — regenerates all actual.svg files -- `tools/run_svg_parity_scenarios.sh --all` — runs comparison and updates reports -- `actual.svg` files in scenario dirs are generated artifacts; can be stale after code changes -- Failures in: effects.effect-ordering, effects.layer-background-blur, primitives.fractional-geometry, regression.tough-1176-blue-face are pre-existing (not introduced by bd-der.1) - ---- - -## 2026-02-27 - bd-der.1 -- **What was implemented**: Eliminated 8-bit opacity quantization in SVG export -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — changed `svg_color_paint_values` signature from `a: u8` to `a: f64`, updated all call sites to pass `color.a` directly instead of `(color.a * 255.0).round() as u8` - - `crates/frame-ui/src/svg/effects.rs` — same fix for stroke color alpha in push_rect_with_effects - - `tests/frame/parity/scenarios/regression.tough-1176-blue-face/actual.svg` — regenerated with clean opacity values - - `tests/frame/parity/scenarios/text.alignment-truncation/actual.svg` — regenerated, now passes strict parity - - Report files updated -- **Learnings**: - - The fix was already present in the working tree (previous iteration had implemented it); this iteration verified correctness and regenerated stale artifacts - - The old code did `(color.a * 255.0).round().clamp(0.0, 255.0) as u8` as the 4th arg to svg_color_paint_values, which stored alpha as u8 and then formatted as `f64::from(a) / 255.0` with 6 decimal places — creating values like 0.698039 instead of 0.7 - - Figma JSON opacity values come in as f32-precision (e.g., 0.699999988079071 for 0.7); `fmt_f64` correctly rounds these to "0.7" via `format!("{:.6}", v)` + trim trailing zeros - - actual.svg parity files are GENERATED artifacts — always regenerate after code changes before running comparison - - text.alignment-truncation was also fixed as a side effect (now strict=true) - ---- - -## 2026-02-27 - bd-der.3 -- **What was implemented**: Placed opacity on `` wrapper instead of baking it into fill/effect colors -- **Files changed**: - - `crates/frame-ui/src/anyrender_renderer/collect.rs` — added `has_filter_effects` early check; `needs_layer` now also true when `has_filter_effects && effective_opacity < 1.0`, so opacity is NOT baked into fills/effects for those nodes - - `crates/frame-ui/src/svg/render.rs` — modified `LayerEnd` to merge outer opacity group into inner filter group child; added `try_merge_layer_into_filter_child()` and `push_node_dyn()` helpers -- **Learnings**: - - Figma's SVG convention: when a node has both opacity AND filter effects, opacity goes on the SAME `` element, NOT baked into fill/stroke/effect colors - - The `svg` crate's `Node` trait provides `get_attributes_mut()` and `get_children_mut()` for DOM inspection — `Box` is usable via trait object dispatch - - `Box: Into>` via std identity impl, so `group.add(child_box)` compiles - - Pre-existing `needs_layer` in collect.rs was only for non-Normal blend modes; adding filter-effects case required peeking at raw JSON before full effect parsing - - The merge check: require outer group has opacity, exactly 1 child, child is `` with `filter` but no `opacity`; copy all outer attrs to child using `entry().or_insert()` - - SVG attribute order differs (actual: `filter opacity`, expected: `opacity filter`) but is semantically equivalent — canonical diff still succeeds - - 22/26 parity tests still pass; same 4 pre-existing failures remain unchanged - ---- - -## 2026-02-27 - bd-der.4 -- **What was implemented**: Applied rotation transform to star and vector shape paths -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — inserted rotation block after px/py candidate correction (before bounds accumulation). When `rotation.abs() > 1e-6 && has_native_geometry && !text_outline`, applies 2×2 rotation to all fill_paths/stroke_paths via `kurbo::Affine` and recomputes `px/py` as `chain_sx/chain_sy` -- **Key formulas**: - - `cos_r = rotation.cos()`, `sin_r = rotation.sin()` (rotation from extract_local_rect = atan2(c, a)) - - `abs_cx = bx + tx + bw/2`, `abs_cy = by + ty + bh/2` (absoluteBoundingBox center in screen space) - - `chain_sx = abs_cx - cos_r*(w/2) + sin_r*(h/2)` — positions rotated local center at abs center - - `chain_sy = abs_cy - sin_r*(w/2) - cos_r*(h/2)` - - `kurbo::Affine::new([cos_r, sin_r, -sin_r, cos_r, 0.0, 0.0])` — column-major 2×2 rotation only -- **Learnings**: - - Figma's relativeTransform tx/ty is the AABB top-left, NOT the local origin — the correct translate uses absoluteBoundingBox center - - Decompose affine = 2×2 rotation on path coords + translate; normalize bakes the translate into compact path format - - kurbo Affine::new([a,b,c,d,e,f]) is column-major: `|a c e| / |b d f|`; Figma row-major [[cos_r, -sin_r],[sin_r, cos_r]] → `new([cos_r, sin_r, -sin_r, cos_r, 0, 0])` - - `match primitive` (not `match &primitive`) means fill_paths/stroke_paths are owned Vec; shadow with `let (fill_paths, stroke_paths) = ...;` after candidate correction - - 22/26 parity tests still pass; same 4 pre-existing failures; star M56.3732 37.7679 and Union paths now match expected.svg exactly - ---- - -## 2026-02-27 - bd-der.5 -- **What was implemented**: Emitted fill-opacity on pattern overlay elements; inlined blend mode on shape instead of wrapper `` -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — 8 image fill code paths fixed: - 1. Removed `opacity` from `` elements inside pattern defs (moved to consumer shape) - 2. Added `fill-opacity` to rect/circle when `*alpha < 1.0` - 3. Inlined `style="mix-blend-mode:..."` directly on shape (circle/rect) instead of wrapping in `` - 4. Fixed paths: aggregate-group rect, single-fill rect, FRAME multi-fill for-loop, main RECTANGLE path, circle path, 3x path fills -- **Learnings:** - - **4 distinct rendering paths** for image pattern fills in render.rs, each needed a separate fix: (1) `fills.len() > 1 && !effects.is_empty()` aggregate, (2) `fills.len() == 1` single-fill branch, (3) FRAME fills `for (fill, fill_blend) in fills` loop, (4) `is_rectangle_node` main rect path - - **Image caching bug**: The original code put `opacity` on the `` element, but `image_defs` caches by hash — subsequent shapes sharing the same image couldn't override its opacity. Moving to `fill-opacity` on the shape fixes this caching issue - - **VU meter FRAME has 2 fills**: GRADIENT_LINEAR (normal blend) + IMAGE (multiply blend). `fills.len() > 1 && !effects.is_empty()` → false (no effects), so falls through to the FRAME for-loop path (not the single-fill path or RECTANGLE path) - - **PASS_THROUGH → BlendMix::Normal**: `parse_blend_mode` maps `PASS_THROUGH` to `Normal` (no case for it), so PASS_THROUGH nodes don't trigger `needs_layer`. The blend mode wrapper for VU meter was coming from the fill-level blend in the FRAME for-loop, not a `LayerStart` - - 22/26 parity tests still pass; same 4 pre-existing failures unchanged - ---- - -## 2026-02-27 - bd-der.6 -- **What was implemented**: Fixed angular gradient foreignObject sizing, conic-gradient angle, SVG transform matrix, seamless stop wrap, and metadata circle emission -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — 5 fixes in GradientAngular CIRCLE arm + `svg_angular_metadata_attr` function overhaul - - `crates/frame-ui/src/svg/normalize.rs` — `normalize_circle_line` now preserves `data-figma-gradient-fill` attribute -- **Key changes in render.rs**: - 1. from-angle: was 90°-θ_grad (gave -45deg), now correctly `90deg` (conic-gradient starts at 12 o'clock, which maps to Figma's gradient angle 0 → SVG 90deg) - 2. FO size: was 2022.22 (based on `1/(s*s)` formula), now `(r + strokeWeight) / |a|` where a = t[0][0]*rw/2000 — correct scaling - 3. SVG transform matrix: was missing full rotation, now `matrix(a b c d cx cy)` with a=t[0][0]*rw/2000, b=-t[1][0]*rw/2000, c=-t[0][1]*rw/2000, d=t[1][1]*rw/2000 - 4. Seamless stop wrap: added first stop at 0deg and last stop at 360deg to prevent gap - 5. `svg_angular_metadata_attr` reworked: stopsVar→[], full f64 precision via `{:?}` format, actual gradientTransform matrix passed for CIRCLE (None for rects) -- **Metadata transform formula** (column-major naming mRC where R=col, C=row): - - m00 = t[0][0]*rw, m01 = t[1][0]*rw, m02 = cx - t[0][0]*rw - - m10 = t[0][1]*rh, m11 = t[1][1]*rh, m12 = cy -- **normalize.rs root cause**: `normalize_circle_line` only re-emits known attrs — `data-figma-gradient-fill` was silently stripped. Fixed by adding `extract_xml_attr` call + conditional emit (same pattern as `normalize_rect_line`) -- **Learnings**: - - `normalize_svg_for_figma_parity` is a post-processor that parses SVG line-by-line and only keeps known attributes per element type — any new attribute must be explicitly added to the normalizer function for its element type - - SVG `{:?}` format for f64 preserves decimal point for whole numbers (1.0 not 1) and uses Ryu shortest-unique for fractionals; matches f32→f64 precision from Figma JSON better than `fmt_f64` (which rounds to 6dp) - - The metadata circle IS generated correctly (confirmed via group-children trace) — the issue was the normalizer stripping it, not the renderer failing to emit it - - 22/26 parity tests still pass; same 4 pre-existing failures; regression.tough-1176-blue-face remains a pre-existing failure (child count 201 vs 205 and path coord precision are unrelated to angular gradient work) - ---- - -## 2026-02-27 - bd-der.7 -- **What was implemented**: Fixed gradient type swaps (paint36-39) and missing paint42/43 defs -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — hoisted `raw_frame_strokes_early`/`has_complex_frame_strokes_early`/`frame_stroke_width_early` computation before the `fills.len()==1` shortcut block; in `GradientLinear` and `GradientRadial` single-fill arms, added check `frame_rect_uses_transform && has_complex_frame_strokes_early && frame_stroke_width_early > 0.0` to emit fill-only rect + call `push_complex_frame_stroke_overlays`; removed duplicate computation at original site (replaced with aliases) -- **Learnings:** - - **Early-exit bug pattern**: The `fills.len()==1` shortcut hits `continue` before reaching the `raw_frame_strokes` computation at the original site. Any FRAME node with 1 gradient fill + gradient stroke skipped complex stroke overlay logic entirely — causing all subsequent paint IDs to shift by the count of missing stroke gradients - - **FRAME stroke convention**: Figma SVG emits separate rects for each stroke (solid + gradient) as stroke-only rects (`fill="none"`), not combined with the fill rect. `push_complex_frame_stroke_overlays` handles both solid and gradient strokes in one loop - - **Gradient ID numbering ripple**: The "swapped types" (paint36 radial vs linear) were actually a numbering offset caused by 2 missing linear gradient defs (MN frame's gradient stroke + Frame 2's gradient stroke). Once those were emitted, downstream IDs 36-43 shifted into correct positions with correct types - - `push_complex_frame_stroke_overlays` only needed for FRAME nodes (`frame_rect_uses_transform = true`); non-FRAME nodes (circles, etc.) use the old combined fill+stroke rect approach - ---- - -## 2026-02-27 - bd-der.8 -- **What was implemented**: FRAME stroke rect deferred outside clip group (Figma places stroke after clip boundary, not inside it) -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — added `clip_depth: usize` counter + `pending_post_clip_stroke: Vec<(usize, Rectangle)>` queue; `ClipStart` increments depth, `ClipEnd` decrements depth and drains matching pending strokes after closing the group; in multi-fill loop stroke emission (line ~1446), when `frame_rect_uses_transform && clip_depth > 0`, push to pending queue instead of calling `push_node` directly -- **Learnings:** - - **ClipStart/ClipEnd wraps Rect**: primitive order for FRAME with `clip_children=true` is `ClipStart → Rect (fills+stroke) → [children] → ClipEnd`. The stroke rect was landing inside the clip group. - - **Figma convention**: FRAME stroke is a sibling of the clip group (after it), not a child inside it. Fills are correctly inside the clip (they get clipped to the boundary). The stroke is outside so it renders over the full boundary. - - **Depth-tagged deferred queue**: `Vec<(usize, Rectangle)>` where the usize is `clip_depth` at deferral time. `ClipEnd` only drains items whose depth == `old_depth` (the depth before decrement), handling nested clip groups correctly. - - **Affects only multi-fill loop path**: single-fill shortcut `fills.len() == 1` with complex frame strokes uses `push_complex_frame_stroke_overlays` which emits before the FRAME's own `ClipStart` (so already outside the clip group). The multi-fill path emits after `ClipStart` → was landing inside. - - **Child count result**: `/svg/g[0]` child count mismatch `actual=204 expected=205` is now resolved. Remaining 1176 failure is a path coordinate precision issue (pre-existing). - - 22/26 parity tests still pass; same 4 pre-existing failures unchanged - ---- - -## 2026-02-27 - bd-der.9 -- **What was implemented**: Inlined mix-blend-mode on elements instead of wrapping in `` -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — 4 fixes: - 1. `push_node_with_optional_blend`: replaced `Group::new().set("style",...).add(node)` with `svg::Node::assign(&mut node, "style", ...)` to set style directly on the node - 2. Text-outline path fill loop: changed group-wrap to `path = path.set("style",...)` then `path_group.add(path)` - 3. Nonfull-ellipse path fill loop: collapsed blend/no-blend branches into unified loop that sets `style` directly on each path - 4. Main path fill loop (non-special case): same inline fix as #2 -- **Learnings:** - - **Most fixes already done**: bd-der.5 had already inlined blend mode for image pattern fills (circle/rect); `try_merge_layer_into_filter_child` already handled filter+opacity+blend merging. Only the path fill loops and `push_node_with_optional_blend` remained. - - **`svg::Node::assign(&mut node, key, val)`** works for any `N: svg::Node` including groups — used to mutate the node before passing to `push_node`, eliminating the wrapper `` entirely - - **Angular group callers** are also correct: `push_node_with_optional_blend` now sets `style` on the existing group object (no extra nesting) - - **Semantic equivalence**: For multi-path fills, setting blend mode on each individual path is correct since Figma inlines per-path - - 22/26 parity tests still pass; same 4 pre-existing failures unchanged ---- - -## 2026-02-27 - bd-der.2 -- **What was implemented**: Fixed matrix transform circle translations for reflected/flipped circles -- **Files changed**: - - `crates/frame-ui/src/svg/render.rs` — in the `det < -1e-6` circle branch, replaced `e = row0[2] + canvas_tx` / `f = row1[2] + canvas_ty` with `e = cx - (a + c) * r` / `f = cy - (b + d) * r`, back-calculating world-space translation from the absoluteBoundingBox center -- **Learnings**: - - **Root cause**: For reflected circles (det < -1e-6), render.rs read `relativeTransform.tx/ty` (local to parent) and added canvas offset — ignoring the parent rotation chain. For a circle inside a rotated parent, the local tx/ty in parent space ≠ world tx/ty - - **Fix without collect.rs changes**: `absoluteBoundingBox` center (already available as `cx, cy` from `source_bboxes`) IS the world-space center. Back-calculate: for SVG `matrix(a,b,c,d,e,f)` mapping local (r,r) → world (cx,cy): `e = cx-(a+c)*r`, `f = cy-(b+d)*r` - - **`det < -1e-6` = reflection case**: Pure rotations have det=+1 (handled by `is_rotation` path using `rotate(angle cx cy)`); reflections have det=-1 (requires full `matrix(...)`) - - **absoluteBoundingBox center is always correct for round circles**: Rotation/reflection doesn't change a circle's AABB, so the AABB center is always the world center regardless of the transform chain - - **Variable naming in circle block is SVG column-major**: `a`=row0[0]=SVG_a, `c`=row0[1]=SVG_c, `b`=row1[0]=SVG_b, `d`=row1[1]=SVG_d — emitted as `matrix(a b c d e f)` = SVG format directly - - All 16 reflected circles in 1176 now match expected translations within 0.001 (abs_tol 0.006); 22/26 parity tests still pass; same 4 pre-existing failures - ---- - -## 2026-02-27 - bd-der.10 -- **What was implemented**: Introduced `AffineTransform2D` type replacing all raw `[[f64; 3]; 2]` arrays in the render pipeline -- **Files changed**: - - `crates/frame-ui/src/anyrender_renderer/transform.rs` (new) — `AffineTransform2D` struct with fields `a, b, tx, c, d, ty`; `impl Mul` for composition; `impl Display` for SVG `matrix()` string; `impl From<[[f64;3];2]>`; methods: `identity()`, `rotation()`, `translation()`, `scale()`, `transform_point()`, `determinant()`, `is_identity()`, `decompose_rotation()`, `inverse()`, `to_svg_matrix_coeffs()`; 17 unit tests - - `crates/frame-ui/src/anyrender_renderer/mod.rs` — added `pub mod transform` and re-export of `AffineTransform2D` - - `crates/frame-ui/src/anyrender_renderer/types.rs` — `FillPaint` gradient/image variants now use `Option` - - `crates/frame-ui/src/anyrender_renderer/extract.rs` — `gradient_transform_from_paint` and `parse_image_transform` return `Option`; `ImageFillData.image_transform` field updated - - `crates/frame-ui/src/anyrender_renderer/paint.rs` — all gradient/image function signatures updated; field access replaces array indexing - - `crates/frame-ui/src/svg/render.rs` — all gradient/image function signatures updated; field access replaces array indexing; also found a second raw-array usage in angular gradient CIRCLE branch - - `crates/frame-ui/src/svg/source_bridge.rs` — `raw_gradient_transform_from_paint` returns `AffineTransform2D` -- **Learnings:** - - **Figma row-major vs SVG column-major**: Figma `t[row][col]` → our fields `a=t[0][0], b=t[0][1], tx=t[0][2], c=t[1][0], d=t[1][1], ty=t[1][2]`. SVG `matrix(a,b,c,d,e,f)` is column-major — output order is `(a, c, b, d, tx, ty)` via `to_svg_matrix_coeffs()` - - **Axis vectors for radial/angular gradients**: column vectors of the 2×2 linear part scaled to pixel space: `ax = (t.a * width, t.c * height)`, `ay = (t.b * width, t.d * height)` — NOT `t[0][*]` / `t[1][*]` per row - - **`From<[[f64;3];2]>` as bridge**: lets parsing code construct `AffineTransform2D` from Figma JSON arrays without touching the rest of the call chain - - **Near-zero determinant test**: `1e-7 * 1e-7 = 1e-14 < 1e-12` threshold — IS singular. The test comment "above threshold" was wrong; fixed test to match actual behavior - - 30/30 unit tests pass; all pre-existing parity results unchanged ---- diff --git a/.ralph-tui/reports/sequential-summary-0858a429-4ae3-46f1-a34c-e24a60c2733f-2026-02-27T07-45-11-119Z.txt b/.ralph-tui/reports/sequential-summary-0858a429-4ae3-46f1-a34c-e24a60c2733f-2026-02-27T07-45-11-119Z.txt deleted file mode 100644 index bae7ac00..00000000 --- a/.ralph-tui/reports/sequential-summary-0858a429-4ae3-46f1-a34c-e24a60c2733f-2026-02-27T07-45-11-119Z.txt +++ /dev/null @@ -1,14 +0,0 @@ -═══════════════════════════════════════════════════════════════ - Sequential Run Summary -═══════════════════════════════════════════════════════════════ - - Session: 0858a429-4ae3-46f1-a34c-e24a60c2733f - Mode: tui - Status: INTERRUPTED - Started: 2/26/2026, 11:40:51 PM - Finished: 2/26/2026, 11:45:11 PM - Duration: 4m 19s - Tasks: 0/13 completed - Iterations: 1/10 - -═══════════════════════════════════════════════════════════════ diff --git a/.ralph-tui/reports/sequential-summary-4af3aed8-d8ed-43f3-afa5-91f2935a2a7a-2026-02-27T17-34-57-667Z.txt b/.ralph-tui/reports/sequential-summary-4af3aed8-d8ed-43f3-afa5-91f2935a2a7a-2026-02-27T17-34-57-667Z.txt deleted file mode 100644 index 730e4503..00000000 --- a/.ralph-tui/reports/sequential-summary-4af3aed8-d8ed-43f3-afa5-91f2935a2a7a-2026-02-27T17-34-57-667Z.txt +++ /dev/null @@ -1,14 +0,0 @@ -═══════════════════════════════════════════════════════════════ - Sequential Run Summary -═══════════════════════════════════════════════════════════════ - - Session: 4af3aed8-d8ed-43f3-afa5-91f2935a2a7a - Mode: tui - Status: INTERRUPTED - Started: 2/26/2026, 11:45:17 PM - Finished: 2/27/2026, 9:34:57 AM - Duration: 9h 49m - Tasks: 8/13 completed - Iterations: 11/11 - -═══════════════════════════════════════════════════════════════ diff --git a/.ralph-tui/session-meta.json b/.ralph-tui/session-meta.json deleted file mode 100644 index 45e1bc78..00000000 --- a/.ralph-tui/session-meta.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "4af3aed8-d8ed-43f3-afa5-91f2935a2a7a", - "status": "interrupted", - "startedAt": "2026-02-27T07:45:16.791Z", - "updatedAt": "2026-02-27T17:34:57.668Z", - "agentPlugin": "claude", - "trackerPlugin": "beads-bv", - "epicId": "bd-der", - "currentIteration": 11, - "maxIterations": 11, - "totalTasks": 0, - "tasksCompleted": 8, - "cwd": "/Users/codywright/Documents/Development/FastTrackStudio/frame", - "endedAt": "2026-02-27T17:34:57.668Z" -} \ No newline at end of file diff --git a/.ralph-tui/session.json b/.ralph-tui/session.json deleted file mode 100644 index d0cef96e..00000000 --- a/.ralph-tui/session.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "version": 1, - "sessionId": "4af3aed8-d8ed-43f3-afa5-91f2935a2a7a", - "status": "running", - "startedAt": "2026-02-27T07:45:17.771Z", - "updatedAt": "2026-02-27T17:34:57.663Z", - "currentIteration": 11, - "maxIterations": 10, - "tasksCompleted": 8, - "isPaused": false, - "agentPlugin": "claude", - "trackerState": { - "plugin": "beads-bv", - "epicId": "bd-der", - "totalTasks": 13, - "tasks": [ - { - "id": "bd-der.1", - "title": "Eliminate 8-bit opacity quantization — preserve original float values", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.2", - "title": "Fix matrix transform composition for nested parent chains", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.3", - "title": "Place opacity on group wrapper, not on individual children", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.4", - "title": "Apply rotation transform to star and vector shape paths", - "status": "open", - "completedInSession": false - }, - { - "id": "bd-der.5", - "title": "Emit fill-opacity on pattern overlay elements", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.6", - "title": "Fix angular gradient foreignObject sizing, angle, and transform", - "status": "open", - "completedInSession": false - }, - { - "id": "bd-der.7", - "title": "Fix gradient type swaps (linear↔radial) and add missing gradient defs", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.8", - "title": "Emit separate elements for fill and stroke layers (Figma style)", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.9", - "title": "Inline mix-blend-mode on elements instead of wrapping in ", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.10", - "title": "Introduce AffineTransform2D type with proper matrix algebra", - "status": "completed", - "completedInSession": true - }, - { - "id": "bd-der.11", - "title": "Emit for non-equal radii instead of always using ", - "status": "open", - "completedInSession": false - }, - { - "id": "bd-der.12", - "title": "Format opacity with minimal precision (trim trailing zeros)", - "status": "open", - "completedInSession": false - }, - { - "id": "bd-der.13", - "title": "Integration pass: run full parity suite, fix regressions, verify 1176 passes", - "status": "open", - "completedInSession": false - } - ] - }, - "iterations": [ - { - "iteration": 1, - "status": "completed", - "taskId": "bd-der.1", - "taskTitle": "Eliminate 8-bit opacity quantization — preserve original float values", - "taskCompleted": true, - "durationMs": 1140193, - "startedAt": "2026-02-27T07:45:22.750Z", - "endedAt": "2026-02-27T08:04:22.943Z" - }, - { - "iteration": 2, - "status": "completed", - "taskId": "bd-der.3", - "taskTitle": "Place opacity on group wrapper, not on individual children", - "taskCompleted": true, - "durationMs": 1122295, - "startedAt": "2026-02-27T08:04:25.197Z", - "endedAt": "2026-02-27T08:23:07.492Z" - }, - { - "iteration": 3, - "status": "completed", - "taskId": "bd-der.4", - "taskTitle": "Apply rotation transform to star and vector shape paths", - "taskCompleted": false, - "durationMs": 3927997, - "startedAt": "2026-02-27T08:23:09.599Z", - "endedAt": "2026-02-27T09:28:37.596Z" - }, - { - "iteration": 4, - "status": "completed", - "taskId": "bd-der.5", - "taskTitle": "Emit fill-opacity on pattern overlay elements", - "taskCompleted": true, - "durationMs": 1264963, - "startedAt": "2026-02-27T09:28:39.150Z", - "endedAt": "2026-02-27T09:49:44.113Z" - }, - { - "iteration": 5, - "status": "completed", - "taskId": "bd-der.6", - "taskTitle": "Fix angular gradient foreignObject sizing, angle, and transform", - "taskCompleted": false, - "durationMs": 5828067, - "startedAt": "2026-02-27T09:49:46.302Z", - "endedAt": "2026-02-27T11:26:54.369Z" - }, - { - "iteration": 6, - "status": "completed", - "taskId": "bd-der.7", - "taskTitle": "Fix gradient type swaps (linear↔radial) and add missing gradient defs", - "taskCompleted": true, - "durationMs": 1214057, - "startedAt": "2026-02-27T11:26:55.947Z", - "endedAt": "2026-02-27T11:47:10.004Z" - }, - { - "iteration": 7, - "status": "completed", - "taskId": "bd-der.8", - "taskTitle": "Emit separate elements for fill and stroke layers (Figma style)", - "taskCompleted": true, - "durationMs": 767529, - "startedAt": "2026-02-27T11:47:12.227Z", - "endedAt": "2026-02-27T11:59:59.756Z" - }, - { - "iteration": 8, - "status": "completed", - "taskId": "bd-der.9", - "taskTitle": "Inline mix-blend-mode on elements instead of wrapping in ", - "taskCompleted": true, - "durationMs": 589638, - "startedAt": "2026-02-27T12:00:01.989Z", - "endedAt": "2026-02-27T12:09:51.627Z" - }, - { - "iteration": 9, - "status": "completed", - "taskId": "bd-der.10", - "taskTitle": "Introduce AffineTransform2D type with proper matrix algebra", - "taskCompleted": true, - "durationMs": 557995, - "startedAt": "2026-02-27T12:09:53.572Z", - "endedAt": "2026-02-27T12:19:11.567Z" - }, - { - "iteration": 10, - "status": "completed", - "taskId": "bd-der.2", - "taskTitle": "Fix matrix transform composition for nested parent chains", - "taskCompleted": true, - "durationMs": 661867, - "startedAt": "2026-02-27T12:19:13.584Z", - "endedAt": "2026-02-27T12:30:15.451Z" - }, - { - "iteration": 11, - "status": "interrupted", - "taskId": "bd-der.13", - "taskTitle": "Integration pass: run full parity suite, fix regressions, verify 1176 passes", - "taskCompleted": false, - "durationMs": 1656902, - "startedAt": "2026-02-27T17:07:20.148Z", - "endedAt": "2026-02-27T17:34:57.050Z" - } - ], - "skippedTaskIds": [], - "cwd": "/Users/codywright/Documents/Development/FastTrackStudio/frame", - "activeTaskIds": [], - "subagentPanelVisible": true -} \ No newline at end of file diff --git a/.tmp/strokes_diff.png b/.tmp/strokes_diff.png deleted file mode 100644 index ec99cd33..00000000 Binary files a/.tmp/strokes_diff.png and /dev/null differ diff --git a/.tmp/strokes_metric.txt b/.tmp/strokes_metric.txt deleted file mode 100644 index d7f527a3..00000000 --- a/.tmp/strokes_metric.txt +++ /dev/null @@ -1 +0,0 @@ -50251 (0.0242332) \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index bc218b18..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,170 +0,0 @@ -# Agent Instructions - -This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. - -## Quick Reference - -```bash -bd ready # Find available work -bd show # View issue details -bd update --status in_progress # Claim work -bd close # Complete work -bd sync # Sync with git -``` - -## btca — Source Code Search - -Use **btca** to query the actual source code of key dependencies before implementing features or debugging. Prefer this over web searches or docs that may be outdated. - -```bash -btca ask -r -q "your question" -btca ask -r facet -q "How do I use facet-reflect to build values at runtime?" -btca resources # list all available resources -``` - -### Relevant Resources for This Repo - -| Resource | Repo | Description | -|----------|------|-------------| -| `facet` | facet-rs/facet | Rust reflection — shapes, derive macros, serialization, pretty-printing | -| `figue` | bearcove/figue | Config parsing from CLI args, env vars, and config files using facet reflection | -| `styx` | bearcove/styx | Cleaner serialization format — alternative to JSON/YAML with schema support | - -## Landing the Plane (Session Completion) - -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. - -**MANDATORY WORKFLOW:** - -1. **File issues for remaining work** - Create issues for anything that needs follow-up -2. **Run quality gates** (if code changed) - Tests, linters, builds -3. **Update issue status** - Close finished work, update in-progress items -4. **PUSH TO REMOTE** - This is MANDATORY: - ```bash - git pull --rebase - bd sync - git push - git status # MUST show "up to date with origin" - ``` -5. **Clean up** - Clear stashes, prune remote branches -6. **Verify** - All changes committed AND pushed -7. **Hand off** - Provide context for next session - -**CRITICAL RULES:** -- Work is NOT complete until `git push` succeeds -- NEVER stop before pushing - that leaves work stranded locally -- NEVER say "ready to push when you are" - YOU must push -- If push fails, resolve and retry until it succeeds - - -## Issue Tracking with bd (beads) - -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. - -### Why bd? - -- Dependency-aware: Track blockers and relationships between issues -- Git-friendly: Dolt-powered version control with native sync -- Agent-optimized: JSON output, ready work detection, discovered-from links -- Prevents duplicate tracking systems and confusion - -### Quick Start - -**Check for ready work:** - -```bash -bd ready --json -``` - -**Create new issues:** - -```bash -bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json -bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json -``` - -**Claim and update:** - -```bash -bd update --claim --json -bd update bd-42 --priority 1 --json -``` - -**Complete work:** - -```bash -bd close bd-42 --reason "Completed" --json -``` - -### Issue Types - -- `bug` - Something broken -- `feature` - New functionality -- `task` - Work item (tests, docs, refactoring) -- `epic` - Large feature with subtasks -- `chore` - Maintenance (dependencies, tooling) - -### Priorities - -- `0` - Critical (security, data loss, broken builds) -- `1` - High (major features, important bugs) -- `2` - Medium (default, nice-to-have) -- `3` - Low (polish, optimization) -- `4` - Backlog (future ideas) - -### Workflow for AI Agents - -1. **Check ready work**: `bd ready` shows unblocked issues -2. **Claim your task atomically**: `bd update --claim` -3. **Work on it**: Implement, test, document -4. **Discover new work?** Create linked issue: - - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:` -5. **Complete**: `bd close --reason "Done"` - -### Auto-Sync - -bd automatically syncs via Dolt: - -- Each write auto-commits to Dolt history -- Use `bd dolt push`/`bd dolt pull` for remote sync -- No manual export/import needed! - -### Important Rules - -- ✅ Use bd for ALL task tracking -- ✅ Always use `--json` flag for programmatic use -- ✅ Link discovered work with `discovered-from` dependencies -- ✅ Check `bd ready` before asking "what should I work on?" -- ❌ Do NOT create markdown TODO lists -- ❌ Do NOT use external issue trackers -- ❌ Do NOT duplicate tracking systems - -For more details, see README.md and docs/QUICKSTART.md. - -## Landing the Plane (Session Completion) - -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. - -**MANDATORY WORKFLOW:** - -1. **File issues for remaining work** - Create issues for anything that needs follow-up -2. **Run quality gates** (if code changed) - Tests, linters, builds -3. **Update issue status** - Close finished work, update in-progress items -4. **PUSH TO REMOTE** - This is MANDATORY: - ```bash - git pull --rebase - bd dolt push - git push - git status # MUST show "up to date with origin" - ``` -5. **Clean up** - Clear stashes, prune remote branches -6. **Verify** - All changes committed AND pushed -7. **Hand off** - Provide context for next session - -**CRITICAL RULES:** -- Work is NOT complete until `git push` succeeds -- NEVER stop before pushing - that leaves work stranded locally -- NEVER say "ready to push when you are" - YOU must push -- If push fails, resolve and retry until it succeeds - - diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..905001ba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# frame: agent instructions + +- One crate, `frame` (`crates/frame`), with its layers behind features (`io`, `render`, `runtime`, + `live`, `embed`, `figma`, `design`, `library`, `theme`, `nice-plug`, …). Its tools are in + `crates/frame/tools`, the plugin faces in `examples/plugin-faces`, the parity harness in `xtask`. +- Build inside `nix develop` and through the `just` recipes (`just check`, `just test`, + `just parity`). Don't set `RUSTFLAGS` or `CARGO_TARGET_DIR`: the rustflags tables in + `.cargo/config.toml` are Studio's on purpose, so kache shares builds across the fleet's repos. +- The pins (architect, Dioxus, Blitz, vox, the `[patch]` tables) follow architect's release tags; + consumers repeat the same `[patch]` tables. +- Faces are generated (`just faces`), never committed. +- Until Studio depends on Frame by tag (FastTrackStudios/studio#373), Studio commits that touch + Frame are replayed here with `just catch-up `. +- The plan: FastTrackStudios/frame#2. diff --git a/Cargo.lock b/Cargo.lock index 287fbb1d..845826aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,21 +19,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] -name = "adler2" -version = "2.0.1" +name = "addr2line" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] [[package]] -name = "ahash" -version = "0.7.8" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" @@ -50,22 +48,13 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - [[package]] name = "aligned-vec" version = "0.6.4" @@ -83,23 +72,21 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-activity" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.11.0", + "bitflags 2.13.1", "cc", - "cesu8", "jni", - "jni-sys", "libc", "log", "ndk", "ndk-context", "ndk-sys", "num_enum", - "thiserror 1.0.69", + "thiserror 2.0.20", ] [[package]] @@ -110,58 +97,90 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "anyrender" -version = "0.7.0" -source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#0691c2d4168aae1db6507465faafda30978442c7" +version = "0.11.1" +source = "git+https://github.com/FastTrackStudios/blitz?tag=fts-pointer-2026-10#98cc00f65e92760abdc29a859be31e1b429ff314" +dependencies = [ + "kurbo 0.13.1", + "peniko", + "raw-window-handle", + "smallvec 1.16.0", +] + +[[package]] +name = "anyrender" +version = "0.13.0" +source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#b0ef17ec3937a378a2efcfd9901d9074ddbc8403" dependencies = [ - "kurbo 0.13.0", + "kurbo 0.13.1", "peniko", - "raw-window-handle 0.6.2", + "raw-window-handle", + "smallvec 1.16.0", ] [[package]] name = "anyrender_svg" -version = "0.8.0" -source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#0691c2d4168aae1db6507465faafda30978442c7" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2c1ecd7b35cea3ca88e8310eef73c5c06f7d8b0f878a2ac415ab6cf45de3ec6" dependencies = [ - "anyrender", + "anyrender 0.11.1", "image", - "kurbo 0.13.0", + "kurbo 0.13.1", "peniko", - "thiserror 2.0.18", "usvg 0.46.0", ] +[[package]] +name = "anyrender_svg" +version = "0.14.0" +source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#b0ef17ec3937a378a2efcfd9901d9074ddbc8403" +dependencies = [ + "anyrender 0.13.0", + "image", + "kurbo 0.13.1", + "peniko", + "usvg 0.48.1", +] + [[package]] name = "anyrender_vello" -version = "0.7.1" -source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#0691c2d4168aae1db6507465faafda30978442c7" +version = "0.14.0" +source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#b0ef17ec3937a378a2efcfd9901d9074ddbc8403" dependencies = [ - "anyrender", + "anyrender 0.13.0", "debug_timer", - "kurbo 0.13.0", + "futures-channel", + "kurbo 0.13.1", "peniko", "pollster", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", "vello", + "wasm-bindgen-futures", "wgpu", "wgpu_context", ] +[[package]] +name = "append-only-bytes" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" + [[package]] name = "arbitrary" version = "1.4.2" @@ -171,6 +190,78 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arborium-theme" +version = "2.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "042f9d025c80b2e625b4f0f10eda56a652444ef916025e855a292937bb8a949a" + +[[package]] +name = "architect" +version = "0.1.0" +source = "git+https://github.com/FastTrackStudios/architect?tag=v0.9.31#89338f66fd386daa981367e4c2749f2d6f73e1bb" +dependencies = [ + "architect-action-derive", + "architect-derive", + "architect-permissions", + "architect-rpc-derive", + "async-channel", + "eyre", + "facet", + "futures", + "gloo-timers", + "thiserror 2.0.20", + "tokio", + "tracing", + "uuid", + "vox", + "vox-core", + "vox-types", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "architect-action-derive" +version = "0.1.0" +source = "git+https://github.com/FastTrackStudios/architect?tag=v0.9.31#89338f66fd386daa981367e4c2749f2d6f73e1bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "architect-derive" +version = "0.1.0" +source = "git+https://github.com/FastTrackStudios/architect?tag=v0.9.31#89338f66fd386daa981367e4c2749f2d6f73e1bb" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "architect-permissions" +version = "0.1.0" +source = "git+https://github.com/FastTrackStudios/architect?tag=v0.9.31#89338f66fd386daa981367e4c2749f2d6f73e1bb" +dependencies = [ + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "architect-rpc-derive" +version = "0.1.0" +source = "git+https://github.com/FastTrackStudios/architect?tag=v0.9.31#89338f66fd386daa981367e4c2749f2d6f73e1bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -179,7 +270,17 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", +] + +[[package]] +name = "ariadne" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5" +dependencies = [ + "unicode-width 0.2.2", + "yansi", ] [[package]] @@ -190,24 +291,21 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" +name = "arref" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" [[package]] -name = "as-slice" -version = "0.2.1" +name = "as-raw-xcb-connection" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] name = "ash" @@ -215,41 +313,39 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading 0.8.9", + "libloading", ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "atk" -version = "0.18.2" +name = "async-trait" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ - "atk-sys", - "glib", - "libc", + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] -name = "atk-sys" -version = "0.18.2" +name = "atomic-polyfill" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "critical-section", ] [[package]] @@ -259,30 +355,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "autocfg" -version = "1.5.0" +name = "atomic_float" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" [[package]] -name = "av-scenechange" -version = "0.14.1" +name = "atomic_refcell" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "av1-grain" @@ -300,9 +388,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ "arrayvec", ] @@ -313,19 +401,49 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object 0.37.3", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -334,6 +452,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bit_field" version = "0.10.3" @@ -348,39 +472,37 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] -name = "bitstream-io" -version = "4.9.0" +name = "bitmaps" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" dependencies = [ - "core2", + "typenum", ] [[package]] -name = "bitvec" -version = "1.0.1" +name = "bitstream-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" [[package]] -name = "block" -version = "0.1.6" +name = "blake3" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] [[package]] name = "block-buffer" @@ -406,84 +528,69 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] name = "borsh" -version = "1.6.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - -[[package]] -name = "bumpalo" -version = "3.20.1" +name = "btparse" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" +checksum = "387e80962b798815a2b5c4bcfdb6bf626fa922ffe9f74e373103b858738e9f31" [[package]] -name = "bytecheck" -version = "0.6.12" +name = "built" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" [[package]] -name = "bytecheck_derive" -version = "0.6.12" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] @@ -500,37 +607,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.11.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" @@ -538,7 +617,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -558,11 +637,17 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" + [[package]] name = "cc" -version = "1.2.56" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -570,30 +655,13 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - [[package]] name = "cfg-expr" version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ - "smallvec", + "smallvec 1.16.0", "target-lexicon", ] @@ -605,67 +673,63 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", - "windows-link 0.2.1", + "wasm-bindgen", + "windows-link", ] [[package]] -name = "cocoa" -version = "0.26.1" +name = "cobs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "bitflags 2.11.0", - "block", - "cocoa-foundation", - "core-foundation 0.10.1", - "core-graphics 0.24.0", - "foreign-types 0.5.0", - "libc", - "objc", + "thiserror 2.0.20", ] [[package]] -name = "cocoa-foundation" -version = "0.2.1" +name = "codespan-reporting" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ - "bitflags 2.11.0", - "block", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", - "objc", + "serde", + "termcolor", + "unicode-width 0.1.14", ] [[package]] -name = "codespan-reporting" -version = "0.12.0" +name = "color" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" dependencies = [ - "serde", - "termcolor", - "unicode-width", + "bytemuck", ] [[package]] -name = "color" -version = "0.3.2" +name = "color-backtrace" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18ef4657441fb193b65f34dc39b3781f0dfec23d3bd94d0eeb4e88cde421edb" +checksum = "01fd62bba8d0ebaacae8e3faf5c05d3c80b215ea6a5dfe6069619bcd8d72451d" +dependencies = [ + "backtrace", + "btparse", + "termcolor", +] [[package]] name = "color_quant" @@ -675,9 +739,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -699,90 +763,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" [[package]] -name = "const-serialize" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" -dependencies = [ - "const-serialize-macro 0.7.2", -] - -[[package]] -name = "const-serialize" -version = "0.8.0-alpha.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" -dependencies = [ - "const-serialize 0.7.2", - "const-serialize-macro 0.8.0-alpha.0", - "serde", -] - -[[package]] -name = "const-serialize-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", -] - -[[package]] -name = "const-serialize-macro" -version = "0.8.0-alpha.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", -] - -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "convert_case" -version = "0.8.0" +name = "constant_time_eq" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" -dependencies = [ - "unicode-segmentation", -] +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] -name = "cookie" -version = "0.18.1" +name = "copypatch" +version = "0.2.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "ffef70feaa3f41c7b1d2f80d628dd08e501e858b9957fba201dab4d88c812b9d" dependencies = [ - "time", - "version_check", + "libc", + "object 0.39.1", ] [[package]] @@ -819,20 +812,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", + "core-graphics-types", "foreign-types 0.5.0", "libc", ] @@ -849,66 +829,87 @@ dependencies = [ ] [[package]] -name = "core-graphics-types" -version = "0.2.0" +name = "core_maths" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "libc", + "libm", ] [[package]] -name = "core2" -version = "0.4.0" +name = "countme" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] -name = "core_maths" -version = "0.1.1" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "libm", + "libc", ] [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] [[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +name = "crdt" +version = "0.1.0" +source = "git+https://github.com/FastTrackStudios/architect?tag=v0.9.31#89338f66fd386daa981367e4c2749f2d6f73e1bb" dependencies = [ - "crossbeam-utils", + "architect", + "architect-permissions", + "async-trait", + "chrono", + "crdt-derive", + "loro", + "loro-internal", + "thiserror 2.0.20", + "tokio", + "tracing", + "uuid", + "wasm-bindgen-futures", +] + +[[package]] +name = "crdt-derive" +version = "0.1.0" +source = "git+https://github.com/FastTrackStudios/architect?tag=v0.9.31#89338f66fd386daa981367e4c2749f2d6f73e1bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -916,18 +917,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -946,46 +947,43 @@ dependencies = [ ] [[package]] -name = "cssparser" -version = "0.29.6" +name = "cursor-icon" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] -name = "cssparser-macros" -version = "0.6.1" +name = "darling" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "quote", - "syn 2.0.116", + "darling_core 0.20.11", + "darling_macro 0.20.11", ] -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -999,7 +997,18 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", ] [[package]] @@ -1008,16 +1017,16 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-url" @@ -1033,11 +1042,10 @@ checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c" [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1049,21 +1057,14 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] -name = "derive_more" -version = "0.99.20" +name = "diff" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.116", -] +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "digest" @@ -1076,723 +1077,439 @@ dependencies = [ ] [[package]] -name = "dioxus" -version = "0.7.3" +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 = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "dioxus-asset-resolver", - "dioxus-cli-config", - "dioxus-config-macro", - "dioxus-config-macros", - "dioxus-core", - "dioxus-core-macro", - "dioxus-desktop", - "dioxus-devtools", - "dioxus-document", - "dioxus-history", - "dioxus-hooks", - "dioxus-html", - "dioxus-logger", - "dioxus-signals", - "dioxus-stores", - "dioxus-web", - "manganis", - "subsecond", - "warnings", + "bitflags 2.13.1", + "objc2 0.6.4", ] [[package]] -name = "dioxus-asset-resolver" -version = "0.7.3" +name = "displaydoc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ - "dioxus-cli-config", - "http", - "infer", - "jni", - "ndk", - "ndk-context", - "ndk-sys", - "percent-encoding", - "thiserror 2.0.18", - "tokio", + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] -name = "dioxus-cli-config" -version = "0.7.3" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "wasm-bindgen", + "libloading", ] [[package]] -name = "dioxus-config-macro" -version = "0.7.3" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "proc-macro2", - "quote", + "litrs", ] [[package]] -name = "dioxus-config-macros" -version = "0.7.3" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "dioxus-core" -version = "0.7.3" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" -dependencies = [ - "anyhow", - "const_format", - "dioxus-core-types", - "futures-channel", - "futures-util", - "generational-box", - "longest-increasing-subsequence", - "rustc-hash 2.1.1", - "rustversion", - "serde", - "slab", - "slotmap", - "subsecond", - "tracing", -] +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "dioxus-core-macro" -version = "0.7.3" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" -dependencies = [ - "convert_case 0.8.0", - "dioxus-rsx", - "proc-macro2", - "quote", - "syn 2.0.116", -] +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "dioxus-core-types" -version = "0.7.3" +name = "either" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] -name = "dioxus-desktop" -version = "0.7.3" +name = "embedded-io" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" -dependencies = [ - "async-trait", - "base64", - "bytes", - "cocoa", - "core-foundation 0.10.1", - "dioxus-asset-resolver", - "dioxus-cli-config", - "dioxus-core", - "dioxus-devtools", - "dioxus-document", - "dioxus-history", - "dioxus-hooks", - "dioxus-html", - "dioxus-interpreter-js", - "dioxus-signals", - "dunce", - "futures-channel", - "futures-util", - "generational-box", - "global-hotkey", - "infer", - "jni", - "lazy-js-bundle", - "libc", - "muda", - "ndk", - "ndk-context", - "ndk-sys", - "objc", - "objc_id", - "percent-encoding", - "rand 0.9.2", - "rfd", - "rustc-hash 2.1.1", - "serde", - "serde_json", - "signal-hook", - "slab", - "subtle", - "tao", - "thiserror 2.0.18", - "tokio", - "tracing", - "tray-icon", - "tungstenite", - "webbrowser", - "wry", -] +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" [[package]] -name = "dioxus-devtools" -version = "0.7.3" +name = "embedded-io" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" -dependencies = [ - "dioxus-cli-config", - "dioxus-core", - "dioxus-devtools-types", - "dioxus-signals", - "serde", - "serde_json", - "subsecond", - "thiserror 2.0.18", - "tracing", - "tungstenite", -] +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] -name = "dioxus-devtools-types" -version = "0.7.3" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "dioxus-core", - "serde", - "subsecond-types", + "cfg-if", ] [[package]] -name = "dioxus-document" -version = "0.7.3" +name = "ensure-cov" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" -dependencies = [ - "dioxus-core", - "dioxus-core-macro", - "dioxus-core-types", - "dioxus-html", - "futures-channel", - "futures-util", - "generational-box", - "lazy-js-bundle", - "serde", - "serde_json", - "tracing", -] - -[[package]] -name = "dioxus-history" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" -dependencies = [ - "dioxus-core", - "tracing", -] +checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e" [[package]] -name = "dioxus-hooks" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" -dependencies = [ - "dioxus-core", - "dioxus-signals", - "futures-channel", - "futures-util", - "generational-box", - "rustversion", - "slab", - "tracing", -] - -[[package]] -name = "dioxus-html" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" -dependencies = [ - "async-trait", - "bytes", - "dioxus-core", - "dioxus-core-macro", - "dioxus-core-types", - "dioxus-hooks", - "dioxus-html-internal-macro", - "enumset", - "euclid", - "futures-channel", - "futures-util", - "generational-box", - "keyboard-types", - "lazy-js-bundle", - "rustversion", - "serde", - "serde_json", - "serde_repr", - "tracing", -] - -[[package]] -name = "dioxus-html-internal-macro" -version = "0.7.3" +name = "enum-as-inner" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" dependencies = [ - "convert_case 0.8.0", + "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.116", -] - -[[package]] -name = "dioxus-interpreter-js" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" -dependencies = [ - "dioxus-core", - "dioxus-core-types", - "dioxus-html", - "js-sys", - "lazy-js-bundle", - "rustc-hash 2.1.1", - "serde", - "sledgehammer_bindgen", - "sledgehammer_utils", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "dioxus-logger" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" -dependencies = [ - "dioxus-cli-config", - "tracing", - "tracing-subscriber", - "tracing-wasm", + "syn 1.0.109", ] [[package]] -name = "dioxus-rsx" -version = "0.7.3" +name = "enum-as-inner" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ + "heck 0.5.0", "proc-macro2", - "proc-macro2-diagnostics", "quote", - "rustversion", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] -name = "dioxus-signals" -version = "0.7.3" +name = "enum_dispatch" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" dependencies = [ - "dioxus-core", - "futures-channel", - "futures-util", - "generational-box", - "parking_lot", - "rustc-hash 2.1.1", - "tracing", - "warnings", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "dioxus-stores" -version = "0.7.3" +name = "equator" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" dependencies = [ - "dioxus-core", - "dioxus-signals", - "dioxus-stores-macro", - "generational-box", + "equator-macro", ] [[package]] -name = "dioxus-stores-macro" -version = "0.7.3" +name = "equator-macro" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ - "convert_case 0.8.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] -name = "dioxus-web" -version = "0.7.3" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" -dependencies = [ - "dioxus-cli-config", - "dioxus-core", - "dioxus-core-types", - "dioxus-devtools", - "dioxus-document", - "dioxus-history", - "dioxus-html", - "dioxus-interpreter-js", - "dioxus-signals", - "futures-channel", - "futures-util", - "generational-box", - "gloo-timers", - "js-sys", - "lazy-js-bundle", - "rustc-hash 2.1.1", - "send_wrapper", - "serde", - "serde-wasm-bindgen", - "serde_json", - "tracing", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "dirs" -version = "6.0.0" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "dirs-sys", + "libc", + "windows-sys 0.60.2", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "euclid" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", + "num-traits", ] [[package]] -name = "dispatch" -version = "0.2.0" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "libc", - "objc2 0.6.3", + "parking", + "pin-project-lite", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", + "event-listener", + "pin-project-lite", ] [[package]] -name = "dlib" -version = "0.5.2" +name = "exr" +version = "1.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" dependencies = [ - "libloading 0.8.9", + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec 1.16.0", + "zune-inflate", ] [[package]] -name = "dlopen2" -version = "0.8.2" +name = "eyre" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" dependencies = [ - "dlopen2_derive", - "libc", + "autocfg", + "indenter", "once_cell", - "winapi", ] [[package]] -name = "dlopen2_derive" -version = "0.4.3" +name = "facet" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +checksum = "db275d3c6e1957f362cd2a965caa699fe3134a1c78b1b696a8da8cfa60e101c5" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", + "autocfg", + "facet-core", + "facet-macros", + "facet-reflect", ] [[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +name = "facet-core" +version = "0.50.0-rc.7" +source = "git+https://github.com/FastTrackStudios/vendor?tag=v0.4.1#c2a29c0437270b36051fb3441f61ed63e1475c0e" dependencies = [ - "litrs", + "autocfg", + "camino", + "chrono", + "const-fnv1a-hash", + "iddqd", + "impls", + "taxon", + "uuid", ] [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "facet-dessert" +version = "0.50.0-rc.7" 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 = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +checksum = "6ccdcd3b144d86fa7fc1780d344bb3581e9d050bf731cd4fbbfa155349993488" dependencies = [ - "dtoa", + "facet-core", + "facet-reflect", ] [[package]] -name = "dunce" -version = "1.0.5" +name = "facet-format" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "3c75a4fe14aef3c11614dc018e0c33ed3ac93bc7a8e6c006e0ffa0f7eb715685" dependencies = [ - "cfg-if", + "facet-core", + "facet-dessert", + "facet-path", + "facet-reflect", + "facet-solver", ] [[package]] -name = "enumset" -version = "1.1.10" +name = "facet-json" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +checksum = "91b8be2433eba7a2df5cb744714cb62d9ecee4db09722436d6acf68a921723cf" dependencies = [ - "enumset_derive", + "facet", + "facet-core", + "facet-format", + "facet-reflect", ] [[package]] -name = "enumset_derive" -version = "0.14.0" +name = "facet-macro-parse" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +checksum = "9bc6dce04d6069e36e8dcdf1699f2650b566eb6dbdfc479342be8ccf2758a4c3" dependencies = [ - "darling", + "facet-macro-types", "proc-macro2", "quote", - "syn 2.0.116", ] [[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" +name = "facet-macro-types" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +checksum = "863542bfe7eae71ef32d8a4ba6490d9cfa282545ba56afc82d4ed94fba91a9ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "unsynn", ] [[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" +name = "facet-macros" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "269a10f70d4ce2a6c26c54238627f1878e78668d695198dc3206279271d68595" dependencies = [ - "libc", - "windows-sys 0.61.2", + "facet-macros-impl", ] [[package]] -name = "euclid" -version = "0.22.13" +name = "facet-macros-impl" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "48789395fedf755c16222693bf6b77870e36b71625f38793c8a06bb3f86845ef" dependencies = [ - "num-traits", - "serde", + "facet-macro-parse", + "facet-macro-types", + "proc-macro2", + "quote", + "strsim", + "unsynn", ] [[package]] -name = "exr" -version = "1.74.0" +name = "facet-path" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "f6f9587a5b882492c2c250c1f0020fc2f238d26e33bc8a90f953537c4a6eb95d" dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", + "facet-core", ] [[package]] -name = "facet" -version = "0.44.1" +name = "facet-pretty" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c578b0cf6efa3459c6ff5f9f67ddfa668ba778fdbf301a1ba6bb493bf32aba" +checksum = "87e9fdb0a7bd353b20035c1fadf237d2778e06d19fd2537d5a71c8a8919f8aa0" dependencies = [ - "autocfg", "facet-core", - "facet-macros", + "facet-reflect", + "owo-colors", ] [[package]] -name = "facet-core" -version = "0.44.1" +name = "facet-reflect" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2c09e5a796b8989e7555cbe097ddd3c3ffa4c02392b9b4ad0d7cd48b1af5c2" +checksum = "7a94d748ff98faa8b281580dd759af7b10cc5914603af776fcb58bca9f6d72d7" dependencies = [ - "autocfg", - "const-fnv1a-hash", - "iddqd", - "impls", - "uuid", + "facet-core", + "facet-path", + "hashbrown 0.17.1", + "smallvec 2.0.0-alpha.10", ] [[package]] -name = "facet-macro-parse" -version = "0.44.1" +name = "facet-solver" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0ecddfa2b01f0ea083571f9cbb945222bc6603ce2c1cad9105eb8a432e09298" +checksum = "dbf7661c49144bf0b2f87a9a2088dce873838034734fa749da41d461ce9bad9b" dependencies = [ - "facet-macro-types", - "proc-macro2", - "quote", + "facet-core", + "facet-reflect", + "strsim", ] [[package]] -name = "facet-macro-types" -version = "0.44.1" +name = "facet-styx" +version = "5.0.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22ff06d4c4bb3cc6cfaf51d20c66444d7845ab047a7cd500b2677f01a9f6fd9" +checksum = "858d93e5eba04f4e2605b526ccc7a57fa07f7e55c6d607d45bc7f315481b15cb" dependencies = [ - "proc-macro2", - "quote", - "unsynn", + "ariadne", + "facet", + "facet-core", + "facet-format", + "facet-reflect", + "margin", + "margin-term", + "serde_json", + "styx-format", + "styx-parse", + "styx-tree", ] [[package]] -name = "facet-macros" -version = "0.44.1" +name = "facet-testhelpers" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cd4fe21b304e67937988fd88ef0907453a11921597fddff8ea8dc711575bfc7" +checksum = "49db1dd4b9ec9172a1157dccfa6251e5538bbd2882d378d57a80214c0701f780" dependencies = [ - "facet-macros-impl", + "color-backtrace", + "facet-testhelpers-macros", + "termcolor", + "tracing", + "tracing-subscriber", ] [[package]] -name = "facet-macros-impl" -version = "0.44.1" +name = "facet-testhelpers-macros" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b050ae34e587b27fa45e33720722be8ccc20a3d64f4560882cffae1ce65c564f" +checksum = "3e20d6aff1a05262765eaa196931cb0995a5117a5e29c2c849804fac5492ce21" dependencies = [ - "facet-macro-parse", - "facet-macro-types", - "proc-macro2", "quote", - "strsim", "unsynn", ] [[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fax" -version = "0.2.6" +name = "facet-value" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +checksum = "408ab3eed05a33bf14d75ec0419e883eb67f6aabad7a79be9cb6d1513ff42b65" dependencies = [ - "fax_derive", + "facet-core", + "facet-format", + "facet-reflect", + "indexmap 2.14.1", ] [[package]] -name = "fax_derive" -version = "0.2.0" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -1804,14 +1521,10 @@ dependencies = [ ] [[package]] -name = "field-offset" -version = "0.3.6" +name = "fearless_simd" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figma-api" @@ -1828,9 +1541,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixed" @@ -1846,12 +1559,13 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1889,9 +1603,18 @@ dependencies = [ [[package]] name = "font-types" -version = "0.11.0" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4d2d0cf79d38430cc9dc9aadec84774bff2e1ba30ae2bf6c16cfce9385a23" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" dependencies = [ "bytemuck", ] @@ -1919,6 +1642,19 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -1940,13 +1676,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] @@ -1974,98 +1710,143 @@ dependencies = [ name = "frame" version = "0.1.0" dependencies = [ - "frame-proto", -] - -[[package]] -name = "frame-import" -version = "0.1.0" -dependencies = [ + "anyrender 0.11.1", + "anyrender 0.13.0", + "anyrender_svg 0.12.0", + "anyrender_svg 0.14.0", + "anyrender_vello", + "architect", + "base64 0.22.1", + "blake3", + "chrono", + "crdt", + "facet", + "facet-json", + "facet-styx", "figma-api", - "frame-proto", + "fixed", + "frame", + "image", + "kurbo 0.13.1", + "miniz_oxide 0.8.9", + "nice-plug-core", + "peniko", + "pollster", + "proptest", + "roxmltree 0.20.0", + "rust_decimal", "serde", "serde_json", - "thiserror 2.0.18", + "skrifa 0.26.6", + "svg", + "taffy", + "thiserror 2.0.20", + "tokio", + "uuid", + "vello", + "vello_cpu", + "vox", + "vox-core", + "vox-websocket", + "wgpu", + "winit", + "zip", ] [[package]] -name = "frame-proto" +name = "frame-cli" version = "0.1.0" dependencies = [ - "base64", - "facet", - "figma-api", + "architect", + "frame", + "image", + "kurbo 0.13.1", + "peniko", + "pollster", + "roxmltree 0.20.0", "serde", "serde_json", - "thiserror 2.0.18", - "uuid", - "zip", + "skrifa 0.26.6", + "tokio", + "vello", + "winit", ] [[package]] -name = "frame-ui" +name = "frame-desktop" version = "0.1.0" dependencies = [ - "anyrender", - "anyrender_svg", - "anyrender_vello", - "base64", - "dioxus", - "figma-api", - "fixed", - "frame-proto", + "architect", + "frame", "image", - "kurbo 0.13.0", + "kurbo 0.13.1", "peniko", "pollster", - "roxmltree 0.20.0", - "rust_decimal", - "serde", "serde_json", - "skrifa 0.26.6", - "svg", - "taffy", "tokio", "vello", "winit", ] [[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +name = "frame-library-server" +version = "0.1.0" +dependencies = [ + "architect", + "frame", + "image", + "kurbo 0.13.1", + "peniko", + "serde_json", + "tokio", +] [[package]] -name = "futf" -version = "0.1.5" +name = "frame-mcp" +version = "0.1.0" +dependencies = [ + "architect", + "frame", + "serde_json", + "tokio", +] + +[[package]] +name = "futures" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ - "mac", - "new_debug_unreachable", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2085,39 +1866,40 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2129,107 +1911,18 @@ dependencies = [ ] [[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" +name = "generator" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ - "gdk-sys", - "glib-sys", + "cc", + "cfg-if", "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generational-box" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" -dependencies = [ - "parking_lot", - "tracing", + "log", + "rustversion", + "windows-link", + "windows-result 0.4.1", ] [[package]] @@ -2243,24 +1936,27 @@ dependencies = [ ] [[package]] -name = "gethostname" -version = "1.1.0" +name = "generic-btree" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b" dependencies = [ - "rustix 1.1.3", - "windows-link 0.2.1", + "arref", + "heapless 0.9.3", + "itertools 0.11.0", + "loro-thunderdome", + "proc-macro2", + "rustc-hash 2.1.3", ] [[package]] -name = "getrandom" -version = "0.1.16" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", + "rustix 1.1.4", + "windows-link", ] [[package]] @@ -2270,8 +1966,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", + "wasm-bindgen", ] [[package]] @@ -2282,21 +1980,19 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "wasip2", - "wasip3", + "r-efi 6.0.0", ] [[package]] @@ -2310,46 +2006,10 @@ dependencies = [ ] [[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" +name = "gimli" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "gl_generator" @@ -2363,67 +2023,19 @@ dependencies = [ ] [[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.11.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.116", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "global-hotkey" -version = "0.7.0" +name = "glifo" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "once_cell", - "thiserror 2.0.18", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.42.1", + "smallvec 1.16.0", + "vello_common", ] [[package]] @@ -2440,9 +2052,9 @@ dependencies = [ [[package]] name = "glow" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -2459,45 +2071,17 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.11.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "gpu-allocator" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ + "ash", + "hashbrown 0.16.1", "log", "presser", - "thiserror 1.0.69", + "thiserror 2.0.20", "windows 0.58.0", ] @@ -2507,7 +2091,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -2518,72 +2102,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] name = "grid" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36119f3a540b086b4e436bb2b588cf98a68863470e0e880f4d0842f112a3183a" - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.116", -] +checksum = "36119f3a540b086b4e436bb2b588cf98a68863470e0e880f4d0842f112a3183a" [[package]] name = "guillotiere" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ "euclid", "svg_fmt", @@ -2591,9 +2123,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2601,7 +2133,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.1", "slab", "tokio", "tokio-util", @@ -2620,14 +2152,47 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts 0.41.0", + "smallvec 1.16.0", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" @@ -2645,9 +2210,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2662,9 +2273,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -2678,23 +2289,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2702,9 +2301,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2712,9 +2311,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2731,9 +2330,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2745,23 +2344,21 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", - "smallvec", + "smallvec 1.16.0", "tokio", "want", ] [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -2789,7 +2386,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2800,7 +2397,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -2820,7 +2417,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -2834,12 +2431,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2847,9 +2445,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2860,29 +2458,29 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec", + "smallvec 1.16.0", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -2894,15 +2492,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2913,23 +2511,16 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "iddqd" -version = "0.3.17" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b215e67ed1d1a4b1702acd787c487d16e4c977c5dcbcc4587bdb5ea26b6ce06" +checksum = "8450e1521a7518a32addf0b805ffcfbc8f9c558d2ad5769f068f2aeef5a81126" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", - "hashbrown 0.16.1", - "rustc-hash 2.1.1", + "hashbrown 0.17.1", ] [[package]] @@ -2945,42 +2536,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec", + "smallvec 1.16.0", "utf8_iter", ] [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "serde", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "image" -version = "0.25.9" +version = "0.25.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "exr", - "gif 0.14.1", + "gif", "image-webp", - "moxcms", "num-traits", - "png 0.18.1", + "png 0.17.16", "qoi", "ravif", "rayon", "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2990,7 +2595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -3005,11 +2610,17 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "impls" @@ -3017,6 +2628,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -3030,25 +2647,16 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - [[package]] name = "interpolate_name" version = "0.2.4" @@ -3057,114 +2665,139 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] -name = "iri-string" -version = "0.7.10" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "memchr", - "serde", + "hermit-abi", + "libc", + "windows-sys 0.60.2", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "itertools" -version = "0.14.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "javascriptcore-rs" -version = "1.1.2" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", ] [[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", ] [[package]] -name = "jni" -version = "0.21.1" +name = "jni-sys" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", + "jni-sys 0.4.1", ] [[package]] name = "jni-sys" -version = "0.3.0" +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 = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] -name = "js-sys" -version = "0.3.85" +name = "jpeg-decoder" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" [[package]] -name = "keyboard-types" -version = "0.7.0" +name = "js-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "bitflags 2.11.0", - "serde", - "unicode-segmentation", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] @@ -3174,7 +2807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading 0.8.9", + "libloading", "pkg-config", ] @@ -3184,18 +2817,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever", - "indexmap 2.13.0", - "selectors", -] - [[package]] name = "kurbo" version = "0.11.3" @@ -3204,26 +2825,21 @@ checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ "arrayvec", "euclid", - "smallvec", + "smallvec 1.16.0", ] [[package]] name = "kurbo" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", - "smallvec", + "polycool", + "smallvec 1.16.0", ] -[[package]] -name = "lazy-js-bundle" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" - [[package]] name = "lazy_static" version = "1.5.0" @@ -3231,10 +2847,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "leb128" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" [[package]] name = "lebe" @@ -3242,56 +2858,22 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading 0.7.4", - "once_cell", -] - [[package]] name = "libc" -version = "0.2.182" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", ] -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3299,7 +2881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3310,32 +2892,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags 2.11.0", - "libc", - "redox_syscall 0.7.1", -] - -[[package]] -name = "libxdo" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" -dependencies = [ - "libxdo-sys", -] - -[[package]] -name = "libxdo-sys" -version = "0.11.0" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ + "bitflags 2.13.1", "libc", - "x11", + "plain", + "redox_syscall 0.9.4", ] [[package]] @@ -3352,15 +2916,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -3379,15 +2943,24 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] -name = "longest-increasing-subsequence" -version = "0.1.0" +name = "loom" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] [[package]] name = "loop9" @@ -3399,94 +2972,171 @@ dependencies = [ ] [[package]] -name = "mac" -version = "0.1.1" +name = "loro" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcab151bb96d4bf8589fb42275fe3f4111d733edb98da2562752a093280cad4" +dependencies = [ + "enum-as-inner 0.6.1", + "generic-btree", + "loro-common", + "loro-delta", + "loro-internal", + "loro-kv-store", + "rustc-hash 2.1.3", + "tracing", +] + +[[package]] +name = "loro-common" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2146b353a56b0a743f8643725cb9f42dfe209665939b59db901ecea732a3ab29" +dependencies = [ + "arbitrary", + "enum-as-inner 0.6.1", + "leb128", + "loro-rle", + "nonmax", + "rustc-hash 2.1.3", + "serde", + "serde_columnar", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "loro-delta" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +checksum = "96f381e7fb5d4bf5605b4f4a9241f3adf61403ded978d757552004c8302cccbb" +dependencies = [ + "arrayvec", + "enum-as-inner 0.5.1", + "generic-btree", + "heapless 0.8.0", +] [[package]] -name = "macro-string" -version = "0.1.4" +name = "loro-internal" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +checksum = "26d86659b029a6faedddf8a0d7c4d017e04a96a7acf784cd979266303f39139b" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", + "append-only-bytes", + "arref", + "bytes", + "either", + "ensure-cov", + "enum-as-inner 0.6.1", + "enum_dispatch", + "generic-btree", + "getrandom 0.2.17", + "im", + "itertools 0.12.1", + "leb128", + "loom", + "loro-common", + "loro-delta", + "loro-kv-store", + "loro-rle", + "loro_fractional_index", + "md5", + "nonmax", + "num", + "num-traits", + "once_cell", + "parking_lot", + "pest", + "pest_derive", + "postcard", + "pretty_assertions", + "rand 0.8.8", + "rustc-hash 2.1.3", + "serde", + "serde_columnar", + "serde_json", + "smallvec 1.16.0", + "thiserror 1.0.69", + "thread_local", + "tracing", + "wasm-bindgen", + "xxhash-rust", ] [[package]] -name = "malloc_buf" -version = "0.0.6" +name = "loro-kv-store" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +checksum = "841e206d3749fd14f23e64985be230dad2b2199da1d381c44c3dd0d2b9583fcc" dependencies = [ - "libc", + "bytes", + "ensure-cov", + "loro-common", + "lz4_flex", + "once_cell", + "quick_cache", + "rustc-hash 2.1.3", + "tracing", + "xxhash-rust", ] [[package]] -name = "manganis" -version = "0.7.3" +name = "loro-rle" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20" dependencies = [ - "const-serialize 0.7.2", - "const-serialize 0.8.0-alpha.0", - "manganis-core", - "manganis-macro", + "append-only-bytes", + "num", + "smallvec 1.16.0", ] [[package]] -name = "manganis-core" -version = "0.7.3" +name = "loro-thunderdome" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" + +[[package]] +name = "loro_fractional_index" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +checksum = "aca7180674d0273ddf37049a5efcde4547fd5330d24abb7519bb9d9eb6780d5b" dependencies = [ - "const-serialize 0.7.2", - "const-serialize 0.8.0-alpha.0", - "dioxus-cli-config", - "dioxus-core-types", + "once_cell", + "rand 0.8.8", "serde", - "winnow 0.7.14", ] [[package]] -name = "manganis-macro" -version = "0.7.3" +name = "lz4_flex" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" dependencies = [ - "dunce", - "macro-string", - "manganis-core", - "proc-macro2", - "quote", - "syn 2.0.116", + "twox-hash", ] [[package]] -name = "markup5ever" -version = "0.14.1" +name = "margin" +version = "0.10.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +checksum = "079df84bf12a2a718fa39c4f2b389a470140ba7bed2661a6141956e713d302c8" dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", + "facet", + "unicode-width 0.2.2", ] [[package]] -name = "match_token" -version = "0.1.0" +name = "margin-term" +version = "0.10.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "b5e190abf1f018543b36f8e2b40462449f79df52d3c077bffe07c22916fa412d" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", + "arborium-theme", + "margin", + "unicode-width 0.2.2", ] [[package]] @@ -3498,12 +3148,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3515,52 +3159,31 @@ dependencies = [ ] [[package]] -name = "memchr" -version = "2.8.0" +name = "md5" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] -name = "memfd" -version = "0.6.5" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix 1.1.3", -] +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +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 = "metal" -version = "0.32.0" +name = "midi-consts" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" -dependencies = [ - "bitflags 2.11.0", - "block", - "core-graphics-types 0.2.0", - "foreign-types 0.5.0", - "log", - "objc", - "paste", -] +checksum = "6f2dd5c7f8aaf48a76e389068ab25ed80bdbc226b887f9013844c415698c9952" [[package]] name = "mime" @@ -3579,45 +3202,24 @@ dependencies = [ ] [[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "moxcms" -version = "0.7.11" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ - "num-traits", - "pxfm", + "adler2", + "simd-adler32", ] [[package]] -name = "muda" -version = "0.17.1" +name = "mio" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "libxdo", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.17.16", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -3628,27 +3230,27 @@ checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" [[package]] name = "naga" -version = "27.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", - "bitflags 2.11.0", + "bit-set 0.9.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "codespan-reporting", "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap 2.13.0", + "indexmap 2.14.1", "libm", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.18", + "thiserror 2.0.20", "unicode-ident", ] @@ -3675,12 +3277,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", - "jni-sys", + "bitflags 2.13.1", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", - "raw-window-handle 0.6.2", + "raw-window-handle", "thiserror 1.0.69", ] @@ -3696,7 +3298,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -3706,10 +3308,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +name = "nice-plug-core" +version = "0.3.0" +source = "git+https://github.com/FastTrackStudios/nice-plug.git?branch=fts-baseview-03#4e1204f4a64b23348efebe316e9cb6e55f2d3544" +dependencies = [ + "atomic_float", + "atomic_refcell", + "bitflags 2.13.1", + "crossbeam-utils", + "dpi", + "midi-consts", + "nice-plug-derive", + "parking_lot", + "raw-window-handle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "nice-plug-derive" +version = "0.1.2" +source = "git+https://github.com/FastTrackStudios/nice-plug.git?branch=fts-baseview-03#4e1204f4a64b23348efebe316e9cb6e55f2d3544" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] name = "nom" @@ -3720,27 +3345,66 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "noop_proc_macro" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -3750,15 +3414,25 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ + "num-integer", "num-traits", ] @@ -3785,9 +3459,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -3795,23 +3469,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.116", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", + "syn 2.0.119", ] [[package]] @@ -3832,12 +3497,11 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", - "objc2-exception-helper", ] [[package]] @@ -3846,27 +3510,14 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "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.11.0", - "block2 0.6.2", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -3875,7 +3526,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3899,7 +3550,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3911,19 +3562,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "dispatch2", - "objc2 0.6.3", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.11.0", - "objc2-core-foundation", + "objc2 0.6.4", ] [[package]] @@ -3935,7 +3576,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -3956,22 +3597,13 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - [[package]] name = "objc2-foundation" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -3984,9 +3616,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "objc2 0.6.3", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -3998,7 +3629,7 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-app-kit 0.2.2", + "objc2-app-kit", "objc2-foundation 0.2.2", ] @@ -4008,23 +3639,48 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.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.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -4043,7 +3699,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -4052,24 +3708,12 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "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.11.0", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" @@ -4087,7 +3731,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -4095,45 +3739,41 @@ dependencies = [ ] [[package]] -name = "objc2-web-kit" -version = "0.3.2" +name = "object" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", + "memchr", ] [[package]] -name = "objc_id" -version = "0.1.1" +name = "object" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ - "objc", + "flate2", + "memchr", + "ruzstd", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -4146,7 +3786,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] @@ -4157,9 +3797,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -4167,17 +3807,11 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "orbclient" -version = "0.3.50" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ "libc", "libredox", @@ -4185,9 +3819,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ "num-traits", ] @@ -4202,29 +3836,20 @@ dependencies = [ ] [[package]] -name = "pango" -version = "0.18.3" +name = "owo-colors" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", + "supports-color 2.1.0", + "supports-color 3.0.2", ] [[package]] -name = "pango-sys" -version = "0.18.0" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" @@ -4245,8 +3870,8 @@ dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", + "smallvec 1.16.0", + "windows-link", ] [[package]] @@ -4255,22 +3880,17 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "peniko" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2b6aadb221872732e87d465213e9be5af2849b0e8cc5300a8ba98fffa2e00a" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ + "bytemuck", "color", - "kurbo 0.13.0", + "kurbo 0.13.1", "linebender_resource_handle", - "smallvec", + "smallvec 1.16.0", ] [[package]] @@ -4280,123 +3900,102 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" +name = "pest" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "memchr", + "ucd-trie", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "pest_derive" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "pest", + "pest_generator", ] [[package]] -name = "phf_generator" -version = "0.8.0" +name = "pest_generator" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "phf_generator" -version = "0.10.0" +name = "pest_meta" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", + "pest", ] [[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +name = "phon" +version = "0.2.0-rc.7" +source = "git+https://github.com/FastTrackStudios/vendor?tag=v0.4.1#c2a29c0437270b36051fb3441f61ed63e1475c0e" dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "facet", + "facet-value", + "phon-engine", + "phon-ir", + "phon-jit", + "phon-schema", ] [[package]] -name = "phf_macros" -version = "0.10.0" +name = "phon-engine" +version = "0.2.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "d96c6b8b293b99b5d24b08f3fd9f7720831fcf5a0fbf0663d8ad3d4f7ef51204" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", + "facet-value", + "phon-ir", + "phon-schema", + "weavy", ] [[package]] -name = "phf_shared" -version = "0.8.0" +name = "phon-ir" +version = "0.2.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +checksum = "1245075aaf80d7a3809082386bdabcdb1dc273fde88599209ae07e17d429de0e" dependencies = [ - "siphasher 0.3.11", + "phon-schema", + "weavy", ] [[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +name = "phon-jit" +version = "0.2.0-rc.7" +source = "git+https://github.com/FastTrackStudios/vendor?tag=v0.2.3#d7ef478dd397f68f752a7ed9260da9fea3c3adbc" dependencies = [ - "siphasher 0.3.11", + "copypatch", + "phon-ir", + "phon-schema", + "weavy", ] [[package]] -name = "phf_shared" -version = "0.11.3" +name = "phon-schema" +version = "0.2.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "9304de6f0f0ff03119acf22358a45d3b8aba7b278420f6fbf317597c14427428" dependencies = [ - "siphasher 1.0.2", + "blake3", + "facet-value", + "taxon", ] [[package]] @@ -4407,41 +4006,52 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pkg-config" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] -name = "pkg-config" -version = "0.3.32" +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plugin-faces" +version = "0.1.0" +dependencies = [ + "frame", + "kurbo 0.13.1", + "serde_json", + "skrifa 0.26.6", + "uuid", +] [[package]] name = "png" @@ -4453,7 +4063,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4462,11 +4072,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4479,7 +4089,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4489,26 +4099,48 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -4528,12 +4160,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "presser" version = "0.3.1" @@ -4541,142 +4167,93 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" [[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.116", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" +name = "pretty_assertions" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "diff", + "yansi", ] [[package]] name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "proc-macro2", - "quote", - "version_check", + "toml_edit 0.25.15+spec-1.1.0", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", - "version_check", -] - [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] -name = "ptr_meta" -version = "0.1.4" +name = "proptest" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "ptr_meta_derive", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", ] [[package]] -name = "ptr_meta_derive" -version = "0.1.4" +name = "pulp" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", ] [[package]] -name = "pxfm" -version = "0.1.27" +name = "pulp-wasm-simd-flag" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" [[package]] name = "qoi" @@ -4687,6 +4264,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -4695,18 +4278,30 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4718,30 +4313,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.7.3" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4750,24 +4331,14 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -4788,15 +4359,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -4816,46 +4378,44 @@ dependencies = [ ] [[package]] -name = "rand_hc" -version = "0.2.0" +name = "rand_xorshift" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.5.1", + "rand_core 0.9.5", ] [[package]] -name = "rand_pcg" -version = "0.2.1" +name = "rand_xoshiro" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" dependencies = [ - "rand_core 0.5.1", + "rand_core 0.6.4", ] [[package]] name = "range-alloc" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "rav1e" -version = "0.8.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" dependencies = [ - "aligned-vec", "arbitrary", "arg_enum_proc_macro", "arrayvec", - "av-scenechange", "av1-grain", "bitstream-io", "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.12.1", "libc", "libfuzzer-sys", "log", @@ -4864,36 +4424,41 @@ dependencies = [ "noop_proc_macro", "num-derive", "num-traits", + "once_cell", "paste", "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", + "rand 0.8.8", + "rand_chacha 0.3.1", "simd_helpers", - "thiserror 2.0.18", + "system-deps", + "thiserror 1.0.69", "v_frame", "wasm-bindgen", ] [[package]] name = "ravif" -version = "0.12.0" +version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", ] [[package]] -name = "raw-window-handle" -version = "0.5.2" +name = "raw-cpuid" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] [[package]] name = "raw-window-handle" @@ -4901,11 +4466,22 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "git+https://github.com/FastTrackStudios/vendor?tag=v0.6.1#ae09c12a6637ca5cd0b1bfe5dbde745b61024950" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -4933,14 +4509,31 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.37.0" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.11.0", + "font-types 0.12.6", + "once_cell", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.4.1" @@ -4956,54 +4549,43 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", + "bitflags 2.13.1", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5012,18 +4594,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - -[[package]] -name = "rend" -version = "0.4.2" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderdoc-sys" @@ -5037,7 +4610,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -5077,7 +4650,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "gif 0.13.3", + "gif", "image-webp", "log", "pico-args", @@ -5085,31 +4658,7 @@ dependencies = [ "svgtypes 0.15.3", "tiny-skia", "usvg 0.45.1", - "zune-jpeg 0.4.21", -] - -[[package]] -name = "rfd" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" -dependencies = [ - "block2 0.6.2", - "dispatch2", - "js-sys", - "libc", - "log", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "percent-encoding", - "pollster", - "raw-window-handle 0.6.2", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.61.2", + "zune-jpeg", ] [[package]] @@ -5136,32 +4685,15 @@ dependencies = [ ] [[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" +name = "rowan" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "countme", + "hashbrown 0.14.5", + "rustc-hash 1.1.0", + "text-size", ] [[package]] @@ -5181,20 +4713,27 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand 0.8.5", - "rkyv", + "rand 0.8.8", + "rand 0.9.5", "serde", "serde_json", + "wasm-bindgen", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -5203,9 +4742,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -5222,7 +4761,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5231,22 +4770,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "linux-raw-sys 0.12.1", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", "rustls-pki-types", @@ -5257,18 +4796,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -5277,9 +4816,21 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] [[package]] name = "rustybuzz" @@ -5287,11 +4838,11 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytemuck", "core_maths", "log", - "smallvec", + "smallvec 1.16.0", "ttf-parser", "unicode-bidi-mirroring", "unicode-ccc", @@ -5299,6 +4850,15 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5316,9 +4876,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -5337,9 +4897,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -5372,19 +4932,13 @@ dependencies = [ "tiny-skia", ] -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5393,93 +4947,80 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", -] - [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] -name = "send_wrapper" -version = "0.6.0" +name = "serde" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "futures-core", + "serde_core", + "serde_derive", ] [[package]] -name = "serde" -version = "1.0.228" +name = "serde_columnar" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "2a16e404f17b16d0273460350e29b02d76ba0d70f34afdc9a4fa034c97d6c6eb" dependencies = [ - "serde_core", - "serde_derive", + "itertools 0.11.0", + "postcard", + "serde", + "serde_columnar_derive", + "thiserror 1.0.69", ] [[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" +name = "serde_columnar_derive" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +checksum = "45958fce4903f67e871fbf15ac78e289269b21ebd357d6fecacdba233629112e" dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5490,13 +5031,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] @@ -5522,17 +5063,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.1", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -5541,34 +5082,24 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.116", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5578,17 +5109,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -5600,19 +5120,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook" -version = "0.3.18" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -5626,9 +5136,19 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] [[package]] name = "simd_helpers" @@ -5656,15 +5176,19 @@ dependencies = [ [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] -name = "siphasher" -version = "1.0.2" +name = "sized-chunks" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] [[package]] name = "skrifa" @@ -5678,64 +5202,53 @@ dependencies = [ [[package]] name = "skrifa" -version = "0.40.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", - "read-fonts 0.37.0", + "read-fonts 0.39.2", ] [[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "sledgehammer_bindgen" -version = "0.6.0" +name = "skrifa" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ - "sledgehammer_bindgen_macro", - "wasm-bindgen", + "bytemuck", + "read-fonts 0.41.0", ] [[package]] -name = "sledgehammer_bindgen_macro" -version = "0.6.5" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" -dependencies = [ - "quote", - "syn 2.0.116", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "sledgehammer_utils" -version = "0.3.1" +name = "slotmap" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ - "rustc-hash 1.1.0", + "version_check", ] [[package]] -name = "slotmap" -version = "1.1.1" +name = "smallvec" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" dependencies = [ "serde", - "version_check", ] [[package]] name = "smallvec" -version = "1.15.1" +version = "2.0.0-alpha.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" [[package]] name = "smithay-client-toolkit" @@ -5743,7 +5256,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -5773,47 +5286,40 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] -name = "soup3" -version = "0.5.0" +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "futures-channel", - "gio", - "glib", "libc", - "soup3-sys", + "windows-sys 0.60.2", ] [[package]] -name = "soup3-sys" -version = "0.5.0" +name = "spin" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "lock_api", ] [[package]] name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -5838,62 +5344,70 @@ dependencies = [ ] [[package]] -name = "string_cache" -version = "0.8.9" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "string_cache_codegen" -version = "0.5.4" +name = "structstruck" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +checksum = "f318209842d04696a2c139cd3d5d4d75c4a9e2af435b077aa5cbd391a79c34b3" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", "proc-macro2", "quote", + "venial", ] [[package]] -name = "strsim" -version = "0.11.1" +name = "styx-cst" +version = "5.0.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "e4f59f441607e9bdf8cc2d6907a713f8ad82a0f2952b4f0c52e8b49ed624d334" +dependencies = [ + "rowan", + "styx-tokenizer", +] + +[[package]] +name = "styx-format" +version = "5.0.0-rc.5" +source = "git+https://github.com/FastTrackStudios/vendor?tag=v0.1.0#85c1a4b85a316b39a61c156127d61c05bf25fd9f" +dependencies = [ + "styx-cst", + "styx-parse", + "styx-tree", +] [[package]] -name = "subsecond" -version = "0.7.3" +name = "styx-parse" +version = "5.0.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +checksum = "5870d3d6e90a1fec36a7a793a536a588e5efbb4093a071c91e9199a7e7df33d5" dependencies = [ - "js-sys", - "libc", - "libloading 0.8.9", - "memfd", - "memmap2", - "serde", - "subsecond-types", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "styx-tokenizer", + "tracing", ] [[package]] -name = "subsecond-types" -version = "0.7.3" +name = "styx-tokenizer" +version = "5.0.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +checksum = "135db0329be86f76e50320f43507fd2258dc2077d21878e0c3c47c4583918e10" dependencies = [ - "serde", + "tracing", +] + +[[package]] +name = "styx-tree" +version = "5.0.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a11f0b1db6814e3ec5d96afe4b29ed4567bf93cfbf3b1952b713eba577ad35" +dependencies = [ + "ariadne", + "facet-testhelpers", + "styx-parse", ] [[package]] @@ -5902,6 +5416,25 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "svg" version = "0.18.0" @@ -5921,7 +5454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ "kurbo 0.11.3", - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -5930,8 +5463,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo 0.13.0", - "siphasher 1.0.2", + "kurbo 0.13.1", + "siphasher", ] [[package]] @@ -5947,9 +5480,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -5973,7 +5517,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] @@ -5982,7 +5526,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6022,64 +5566,6 @@ dependencies = [ "slotmap", ] -[[package]] -name = "tao" -version = "0.34.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" -dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "core-foundation 0.10.1", - "core-graphics 0.24.0", - "crossbeam-channel", - "dispatch", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni", - "lazy_static", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "once_cell", - "parking_lot", - "raw-window-handle 0.5.2", - "raw-window-handle 0.6.2", - "scopeguard", - "tao-macros", - "unicode-segmentation", - "url", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -6087,27 +5573,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] -name = "tempfile" -version = "3.25.0" +name = "taxon" +version = "0.2.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "3acccfbd01e7cf4c7ea384b46df6ba3afb934ad88abd247f92998249854de968" dependencies = [ - "fastrand", - "getrandom 0.4.1", - "once_cell", - "rustix 1.1.3", - "windows-sys 0.61.2", + "blake3", ] [[package]] -name = "tendril" -version = "0.4.3" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "futf", - "mac", - "utf-8", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.60.2", ] [[package]] @@ -6119,6 +5603,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + [[package]] name = "thiserror" version = "1.0.69" @@ -6130,11 +5620,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -6145,51 +5635,47 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "tiff" -version = "0.10.3" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" dependencies = [ - "fax", "flate2", - "half", - "quick-error", + "jpeg-decoder", "weezl", - "zune-jpeg 0.4.21", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -6199,15 +5685,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -6225,7 +5711,7 @@ dependencies = [ "cfg-if", "log", "png 0.17.16", - "tiny-skia-path", + "tiny-skia-path 0.11.4", ] [[package]] @@ -6239,11 +5725,22 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -6251,9 +5748,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -6266,9 +5763,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -6276,20 +5773,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] @@ -6312,15 +5809,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -6348,31 +5858,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - [[package]] name = "toml_edit" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.1", "serde", "serde_spanned", "toml_datetime 0.6.3", @@ -6381,23 +5880,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "indexmap 2.14.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] @@ -6417,20 +5916,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6464,7 +5963,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] @@ -6474,53 +5973,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", ] [[package]] -name = "tracing-subscriber" -version = "0.3.22" +name = "tracing-log" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "matchers", + "log", "once_cell", - "regex-automata", - "sharded-slab", - "thread_local", - "tracing", "tracing-core", ] [[package]] -name = "tracing-wasm" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" -dependencies = [ - "tracing", - "tracing-subscriber", - "wasm-bindgen", -] - -[[package]] -name = "tray-icon" -version = "0.21.3" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", + "matchers", + "nu-ansi-term", "once_cell", - "png 0.17.16", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "regex-automata", + "sharded-slab", + "smallvec 1.16.0", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -6540,28 +6022,43 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "native-tls", - "rand 0.9.2", - "rustls", + "rand 0.9.5", "sha1", - "thiserror 2.0.18", - "utf-8", + "thiserror 2.0.20", ] +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unarray" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-bidi" @@ -6601,9 +6098,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-vo" @@ -6613,15 +6110,15 @@ checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unsynn" @@ -6631,7 +6128,7 @@ checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" dependencies = [ "mutants", "proc-macro2", - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", ] [[package]] @@ -6658,10 +6155,10 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", - "fontdb", + "fontdb 0.23.0", "imagesize 0.13.0", "kurbo 0.11.3", "log", @@ -6669,10 +6166,10 @@ dependencies = [ "roxmltree 0.20.0", "rustybuzz", "simplecss", - "siphasher 1.0.2", + "siphasher", "strict-num", "svgtypes 0.15.3", - "tiny-skia-path", + "tiny-skia-path 0.11.4", "unicode-bidi", "unicode-script", "unicode-vo", @@ -6685,21 +6182,21 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", - "fontdb", + "fontdb 0.23.0", "imagesize 0.14.0", - "kurbo 0.13.0", + "kurbo 0.13.1", "log", "pico-args", "roxmltree 0.21.1", "rustybuzz", "simplecss", - "siphasher 1.0.2", + "siphasher", "strict-num", "svgtypes 0.16.1", - "tiny-skia-path", + "tiny-skia-path 0.11.4", "unicode-bidi", "unicode-script", "unicode-vo", @@ -6707,10 +6204,31 @@ dependencies = [ ] [[package]] -name = "utf-8" -version = "0.7.6" +name = "usvg" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "fontdb 0.24.0", + "harfrust", + "imagesize 0.15.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.1", + "simplecss", + "siphasher", + "skrifa 0.44.0", + "strict-num", + "svgtypes 0.16.1", + "tiny-skia-path 0.12.0", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] [[package]] name = "utf8_iter" @@ -6720,11 +6238,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.21.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.3", "js-sys", "serde_core", "sha1_smol", @@ -6742,6 +6260,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -6750,49 +6274,87 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vello" -version = "0.7.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72fef40773530322d5c2ffe3c1107e9874bd8239ac137d1c2b6c1edad695146e" +checksum = "af76ceb17b2869be23598baef40a9c8db4de86b87c60510c3bc245559275a617" dependencies = [ "bytemuck", "futures-intrusive", "log", "peniko", - "png 0.17.16", - "skrifa 0.40.0", + "png 0.18.1", + "skrifa 0.44.0", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", "vello_encoding", "vello_shaders", "wgpu", ] +[[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 0.17.1", + "log", + "peniko", + "smallvec 1.16.0", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + [[package]] name = "vello_encoding" -version = "0.7.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24c91203ec4b483440614a9a5c7c2d991932af72c5349659a63ec49476f0b79c" +checksum = "1e31cd622201690d8dfe9fd8fea8d1ae59db1bfeea414856a617d1d03438418c" dependencies = [ "bytemuck", "guillotiere", "peniko", - "skrifa 0.40.0", - "smallvec", + "skrifa 0.44.0", + "smallvec 1.16.0", ] [[package]] name = "vello_shaders" -version = "0.7.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a765d44d4bd354146e44f9a860f4e92effd91a97302549be9e47f0a18d8128c" +checksum = "abf943bd2920bfd22928a9c1bad39866f7ffcc1109b6ed924ab52773e3868a83" dependencies = [ "bytemuck", "log", "naga", - "thiserror 2.0.18", + "thiserror 2.0.20", "vello_encoding", ] +[[package]] +name = "venial" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61584a325b16f97b5b25fcc852eb9550843a251057a5e3e5992d2376f3df4bb2" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -6806,204 +6368,289 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "walkdir" -version = "2.5.0" +name = "vox" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "f4fc8847c6eec37c47e14c324bd1342d2366ecc0484d37f41847f0f5cc34a9e2" dependencies = [ - "same-file", - "winapi-util", + "facet", + "facet-pretty", + "facet-reflect", + "tokio", + "tracing", + "vox-core", + "vox-phon", + "vox-rt", + "vox-service-macros", + "vox-types", ] [[package]] -name = "want" -version = "0.3.1" +name = "vox-core" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "55113b9b0332c85bdc913fb2ebf4e466e54b209f7a9b515412bd77058bf3af8a" dependencies = [ - "try-lock", + "facet", + "facet-core", + "futures-util", + "tokio", + "tracing", + "vox-phon", + "vox-rt", + "vox-types", + "wasm-bindgen-futures", + "zerocopy", ] [[package]] -name = "warnings" -version = "0.2.1" +name = "vox-macros-core" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +checksum = "84c362071ad2465e5b6012cb5370f6cc1fd551d7852995ba5ba89d830187a728" dependencies = [ - "pin-project", - "tracing", - "warnings-macro", + "heck 0.5.0", + "proc-macro2", + "quote", + "vox-macros-parse", ] [[package]] -name = "warnings-macro" -version = "0.2.0" +name = "vox-macros-parse" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +checksum = "68c330e9e0b5051e5e81d9a05c105664c47fc4f7d6a23f1128f0f894dc7a9ba1" dependencies = [ "proc-macro2", - "quote", - "syn 2.0.116", + "unsynn", ] [[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" +name = "vox-phon" +version = "0.10.0-rc.7" +source = "git+https://github.com/FastTrackStudios/vendor?tag=v0.3.0#2db28e4e760b195e23d5c3b2eda7d05e16b65e26" +dependencies = [ + "facet", + "phon", + "phon-engine", + "phon-ir", + "phon-jit", + "phon-schema", + "vox-rt", +] + +[[package]] +name = "vox-rt" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" +checksum = "c1d60ad9e8e14b59df388fe2f94461dbf30c28d88a4c601c27944bf02135fa62" +dependencies = [ + "futures-channel", + "futures-util", + "parking_lot", + "tokio", + "vox-rt-macros", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasmtimer", + "web-sys", +] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "vox-rt-macros" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "59ac5da248ee3338f551773b7560b384974ab738e28822e7e4e3dc6ad145371c" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "vox-schema" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0e604e1bd7e7a9566a74a74434582a0112a1a53faa6e256d9720a02283c2e03d" dependencies = [ - "wit-bindgen", + "blake3", + "facet", + "facet-core", ] [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "vox-service-macros" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "9c3f3346f2d8e55ba959d07aead491df684c371ffbfd6dbdca8deb6c7169226a" dependencies = [ - "wit-bindgen", + "proc-macro2", + "vox-macros-core", ] [[package]] -name = "wasm-bindgen" -version = "0.2.108" +name = "vox-types" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "734ee4a4114398afcee8a4dfd5a5cb2534acb93f060ea33b2936e9176cc37378" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "blake3", + "facet", + "facet-core", + "facet-reflect", + "facet-value", + "heck 0.5.0", + "indexmap 2.14.1", + "structstruck", + "tokio", + "vox-phon", + "vox-rt", + "vox-schema", ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" +name = "vox-websocket" +version = "0.10.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "9fe651cdc222634dbc3f2501230fe3858941fcddaac643c60b0514a1685ec4cd" dependencies = [ - "cfg-if", + "futures-channel", "futures-util", "js-sys", - "once_cell", + "tokio", + "tokio-tungstenite", + "vox-core", + "vox-types", "wasm-bindgen", "web-sys", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" +name = "wait-timeout" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "libc", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.116", - "wasm-bindgen-shared", + "same-file", + "winapi-util", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "unicode-ident", + "try-lock", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "leb128fmt", - "wasmparser", + "wit-bindgen", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "wasm-bindgen" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "wasm-streams" -version = "0.4.2" +name = "wasm-bindgen-futures" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "futures-util", "js-sys", "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", ] [[package]] -name = "wasmparser" -version = "0.244.0" +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 2.0.119", + "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 = "wasmtimer" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", + "js-sys", + "wasm-bindgen", ] [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.4", "scoped-tls", - "smallvec", + "smallvec 1.16.0", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.11.0", - "rustix 1.1.3", + "bitflags 2.13.1", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -7014,29 +6661,29 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] [[package]] name = "wayland-cursor" -version = "0.31.12" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7044,11 +6691,11 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7057,11 +6704,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7070,9 +6717,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -7081,9 +6728,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -7092,119 +6739,32 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.85" +name = "weavy" +version = "0.2.3-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "02a7fcadcbd0f12cd098f64637c46d9b4195f36490cb5ad2ffa2f4e867d15abb" dependencies = [ - "js-sys", - "wasm-bindgen", + "copypatch", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "web-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "webbrowser" +name = "web-time" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" -dependencies = [ - "core-foundation 0.10.1", - "jni", - "log", - "ndk-context", - "objc2 0.6.3", - "objc2-foundation 0.3.2", - "url", - "web-sys", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -7215,12 +6775,13 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "27.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", - "bitflags 2.11.0", + "bitflags 2.13.1", + "bytemuck", "cfg-if", "cfg_aliases", "document-features", @@ -7231,8 +6792,8 @@ dependencies = [ "parking_lot", "portable-atomic", "profiling", - "raw-window-handle 0.6.2", - "smallvec", + "raw-window-handle", + "smallvec 1.16.0", "static_assertions", "wasm-bindgen", "wasm-bindgen-futures", @@ -7244,94 +6805,95 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "27.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", - "bitflags 2.11.0", + "bit-set 0.9.1", + "bit-vec 0.9.1", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "document-features", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.1", "log", "naga", "once_cell", "parking_lot", "portable-atomic", "profiling", - "raw-window-handle 0.6.2", + "raw-window-handle", "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.18", + "smallvec 1.16.0", + "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", + "wgpu-naga-bridge", "wgpu-types", ] [[package]] name = "wgpu-core-deps-apple" -version = "27.0.0" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "27.0.0" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "27.0.0" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "27.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +version = "29.0.4" +source = "git+https://github.com/FastTrackStudios/vendor?tag=v0.6.1#ae09c12a6637ca5cd0b1bfe5dbde745b61024950" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", - "bitflags 2.11.0", - "block", + "bit-set 0.9.1", + "bitflags 2.13.1", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", - "core-graphics-types 0.2.0", "glow", "glutin_wgl_sys", - "gpu-alloc", "gpu-allocator", "gpu-descriptor", "hashbrown 0.16.1", "js-sys", "khronos-egl", "libc", - "libloading 0.8.9", + "libloading", "log", - "metal", "naga", "ndk-sys", - "objc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", @@ -7339,71 +6901,63 @@ dependencies = [ "portable-atomic-util", "profiling", "range-alloc", - "raw-window-handle 0.6.2", + "raw-window-handle", + "raw-window-metal", "renderdoc-sys", - "smallvec", - "thiserror 2.0.18", + "smallvec 1.16.0", + "thiserror 2.0.20", "wasm-bindgen", + "wayland-sys", "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", "wgpu-types", - "windows 0.58.0", - "windows-core 0.58.0", ] [[package]] name = "wgpu-types" -version = "27.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", - "thiserror 2.0.18", + "raw-window-handle", "web-sys", ] [[package]] name = "wgpu_context" -version = "0.3.1" -source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#0691c2d4168aae1db6507465faafda30978442c7" +version = "0.9.0" +source = "git+https://github.com/DioxusLabs/anyrender.git?branch=main#b0ef17ec3937a378a2efcfd9901d9074ddbc8403" dependencies = [ "futures-intrusive", "wgpu", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[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", + "windows-sys 0.48.0", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.58.0" @@ -7416,24 +6970,23 @@ dependencies = [ [[package]] name = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core 0.62.2", "windows-future", - "windows-link 0.1.3", "windows-numerics", ] [[package]] name = "windows-collections" -version = "0.2.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -7449,19 +7002,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -7470,19 +7010,19 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", - "windows-link 0.2.1", + "windows-link", "windows-result 0.4.1", "windows-strings 0.5.1", ] [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core 0.62.2", + "windows-link", "windows-threading", ] @@ -7494,7 +7034,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] @@ -7505,7 +7045,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] @@ -7516,7 +7056,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] @@ -7527,15 +7067,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -7544,12 +7078,12 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core 0.62.2", + "windows-link", ] [[package]] @@ -7558,7 +7092,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows-result 0.4.1", "windows-strings 0.5.1", ] @@ -7572,22 +7106,13 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -7600,31 +7125,22 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "windows-sys" -version = "0.45.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.42.2", + "windows-targets 0.48.5", ] [[package]] @@ -7660,22 +7176,22 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "windows-targets" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -7700,7 +7216,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -7713,27 +7229,18 @@ dependencies = [ [[package]] name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" @@ -7749,9 +7256,9 @@ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" @@ -7767,9 +7274,9 @@ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" @@ -7797,9 +7304,9 @@ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" @@ -7815,9 +7322,9 @@ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" @@ -7833,9 +7340,9 @@ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" @@ -7851,9 +7358,9 @@ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" @@ -7869,21 +7376,21 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" -version = "0.30.12" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "ahash 0.8.12", + "ahash", "android-activity", "atomic-waker", - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics 0.23.2", + "core-graphics", "cursor-icon", "dpi", "js-sys", @@ -7891,13 +7398,13 @@ dependencies = [ "memmap2", "ndk", "objc2 0.5.2", - "objc2-app-kit 0.2.2", + "objc2-app-kit", "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", + "objc2-ui-kit", "orbclient", "percent-encoding", "pin-project", - "raw-window-handle 0.6.2", + "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", @@ -7930,168 +7437,24 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", - "prettyplease", - "syn 2.0.116", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.116", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wry" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" -dependencies = [ - "base64", - "block2 0.6.2", - "cookie", - "crossbeam-channel", - "dirs", - "dpi", - "dunce", - "gtk", - "html5ever", - "http", - "javascriptcore-rs", - "jni", - "kuchikiki", - "libc", - "ndk", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-ui-kit 0.3.2", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle 0.6.2", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x11" -version = "2.21.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11-dl" @@ -8113,9 +7476,9 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading 0.8.9", + "libloading", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -8127,9 +7490,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xkbcommon-dl" @@ -8137,7 +7500,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -8152,9 +7515,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmlwriter" @@ -8171,16 +7534,22 @@ dependencies = [ ] [[package]] -name = "y4m" -version = "0.8.0" +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "yansi" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8189,68 +7558,68 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -8259,9 +7628,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -8270,13 +7639,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 3.0.4", ] [[package]] @@ -8290,17 +7659,23 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.1", "memchr", - "thiserror 2.0.18", + "thiserror 2.0.20", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" @@ -8320,12 +7695,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - [[package]] name = "zune-inflate" version = "0.2.54" @@ -8341,14 +7710,5 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core 0.5.1", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 10a92ec6..592e5681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,46 +1,126 @@ +# Frame: the design and control-surface engine. One crate, `frame` +# (crates/frame), with its layers behind features; its tools in +# crates/frame/tools; the hardware plugin faces in examples/plugin-faces. +# +# Frame lived inside Studio (FastTrackStudios/studio, crates/frame) from +# 2026-10-12 until it moved back here; its history runs through both. + [workspace] -members = ["crates/*", "xtask"] resolver = "2" +members = [ + "crates/frame", + "crates/frame/tools/cli", + "crates/frame/tools/desktop", + "crates/frame/tools/library-server", + "crates/frame/tools/mcp", + "examples/plugin-faces", + "xtask", +] +default-members = [ + "crates/frame", + "crates/frame/tools/cli", + "crates/frame/tools/desktop", + "crates/frame/tools/library-server", + "crates/frame/tools/mcp", + "examples/plugin-faces", +] [workspace.package] -edition = "2021" version = "0.1.0" +edition = "2024" +rust-version = "1.87" +authors = ["FastTrack Studios"] license = "MIT OR Apache-2.0" +repository = "https://github.com/FastTrackStudios/frame" -[workspace.lints.rust] - -[workspace.lints.clippy] - +# The versions Studio pinned at the cut (studio@fc6b1954), so Frame builds +# with exactly what it built with there. [workspace.dependencies] -# Figma API types +anyrender_0_11 = { package = "anyrender", git = "https://github.com/FastTrackStudios/blitz", tag = "fts-pointer-2026-10" } +anyrender_svg = { git = "https://github.com/DioxusLabs/anyrender.git", branch = "main" } +anyrender_svg_0_12 = { version = "0.12", package = "anyrender_svg" } +architect = { git = "https://github.com/FastTrackStudios/architect", tag = "v0.9.31" } +base64 = "0.22.1" +blake3 = { version = "1.8" } +chrono = { version = "0.4", default-features = false, features = ["clock", "std", "wasmbind"] } +crdt = { git = "https://github.com/FastTrackStudios/architect", tag = "v0.9.31" } +facet = { version = "0.50.0-rc.5", features = ["reflect", "chrono", "uuid"] } +facet-json = { version = "0.50.0-rc.7" } +facet-styx = { version = "5.0.0-rc.7" } figma-api = { git = "https://github.com/gridaco/figma-api-rust.git" } - -# Common +fixed = "1.28" +image = { version = "0.25", default-features = false, features = ["png"] } +kurbo = "0.13" +miniz_oxide = "0.8" +nice-plug-core = { git = "https://github.com/FastTrackStudios/nice-plug.git", branch = "fts-baseview-03" } +peniko = "0.6" +pollster = "0.4.0" +proptest = "1" +resvg = "0.45" +roxmltree = "0.20" +rust_decimal = "1.39" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +svg = "0.18" +taffy = { version = "0.7", default-features = false, features = ["std", "flexbox", "taffy_tree"] } thiserror = "2.0" -uuid = { version = "1", features = ["v4", "v5", "v7", "serde"] } tokio = { version = "1.48", features = ["full"] } - -# Facet (used by frame-proto) - use crates.io 0.43.x to match roam's constraint -facet = { version = "0.44" } -facet-core = { version = "0.44" } -facet-format = { version = "0.44" } -facet-json = { version = "0.44" } -facet-postcard = { version = "0.44" } -facet-pretty = { version = "0.44" } -facet-value = { version = "0.44" } -facet-yaml = { version = "0.44" } - -# UI/rendering (optional, behind features in frame-ui) -dioxus = "0.7.2" -anyrender = { git = "https://github.com/DioxusLabs/anyrender.git", branch = "main" } -anyrender_svg = { git = "https://github.com/DioxusLabs/anyrender.git", branch = "main" } -anyrender_vello = { git = "https://github.com/DioxusLabs/anyrender.git", branch = "main" } -kurbo = "0.13" -peniko = "0.6" -taffy = { version = "0.7", default-features = false, features = ["std", "flexbox", "taffy_tree"] } -image = "0.25" +uuid = { version = "1", features = ["v4", "v5", "v7", "serde"] } +vello_cpu = { version = "0.0.9", default-features = false } +vox = { version = "0.10.0-rc.5", default-features = false, features = ["runtime"] } +vox-core = { version = "0.10.0-rc.5" } +vox-websocket = { version = "0.10.0-rc.5" } winit = "0.30" -vello = "0.7.0" -pollster = "0.4" +zip = { version = "2", default-features = false } + +# Studio's patch tables, the entries that reach Frame's graph. +[patch.crates-io] +anyrender = { git = "https://github.com/FastTrackStudios/blitz", tag = "fts-pointer-2026-10" } +wgpu-hal = { git = "https://github.com/FastTrackStudios/vendor", tag = "v0.6.1" } +raw-window-metal = { git = "https://github.com/FastTrackStudios/vendor", tag = "v0.6.1" } +styx-format = { git = "https://github.com/FastTrackStudios/vendor", tag = "v0.1.0" } +vox-phon = { git = "https://github.com/FastTrackStudios/vendor", tag = "v0.3.0" } +phon = { git = "https://github.com/FastTrackStudios/vendor", tag = "v0.4.1" } +facet-core = { git = "https://github.com/FastTrackStudios/vendor", tag = "v0.4.1" } +phon-jit = { git = "https://github.com/FastTrackStudios/vendor", tag = "v0.2.3" } + +[workspace.lints.rust] +# Flipped from "allow" 2026-07-28 (task cleanup): the blanket allows are +# WHY ~30k lines of dead code accumulated silently. The task tree is +# warning-clean under these; legacy crates in other domains carry a +# crate-level `#![allow(dead_code, unused)]` marked "Lint debt" until +# their own burn-down. +dead_code = "warn" +unused = "warn" +unused_unsafe = "warn" +# architect's attribute macros (`#[architect::error]`, `wire`, `entity`) +# emit `#[cfg_attr(feature = "fake", ...)]` into the calling crate; declare +# the name so those crates don't warn. Same line as architect's own +# workspace. +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("fake"))'] } + +[workspace.lints.rustdoc] +broken_intra_doc_links = "allow" +private_intra_doc_links = "allow" +redundant_explicit_links = "allow" + +# clippy's default lints, enforced (`clippy -- -D warnings` in CI). Studio +# denied pedantic and nursery on paper but ran clippy as advisory, so Frame +# never met them; tighten here a lint at a time, with the fixes. +[workspace.lints.clippy] +too_many_arguments = "allow" +module_inception = "allow" +type_complexity = "allow" + +[profile.dev] +debug = "line-tables-only" + +# The renderer and its fonts/geometry stacks are unusably slow unoptimised; +# Frame's own crates stay at opt-level 0 so they rebuild fast. +[profile.dev.package."*"] +opt-level = 2 + +[profile.release-fast] +inherits = "release" +lto = false +codegen-units = 16 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..4e05bafa --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FastTrack Studios + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2f9bf613 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# frame + +A design and control-surface engine: a native document format (`.frame` +bundles and a lossless text markup), Figma import, a Vello renderer held to +pixel parity with Figma, and a runtime for fully mapped, AI-designable +control surfaces and plugin faces (see +[docs/control-surface-roadmap.md](docs/control-surface-roadmap.md)). + +Frame is built on [architect](https://github.com/FastTrackStudios/architect). + +``` +crates/frame the `frame` crate; its layers are features: + src/document model + ops (proto), Loro replica, .frame bundle, markup, io + src/render Scene → paint → Vello / SVG; effects stage (WGSL) + src/surface catalog, controls, bindings, engine contract, runtime, kit + src/motion, feed, theme animation, typed feeds, host views drawn from documents + src/design lint + the tool session (the AI authoring surface) + src/figma, library Figma import; surface libraries + src/live, embed hosts: a texture on the host's wgpu device; strips + render/tests/parity the Figma parity scenarios + tools/cli frame-cli: render_surface, export_markup, parity/diagnostics + tools/desktop `frame`: the live surface player (hot reload, mock engine) + tools/mcp frame-mcp: an MCP server to design surfaces with a model + tools/library-server serves a folder of surfaces as a live library +examples/plugin-faces hardware-style plugin faces written with the surface kit +xtask cargo xtask: the parity harness and its scripts +docs design notes, parity specs and reports +``` + +## Develop + +```sh +nix develop # the toolchain (1.99.1), GUI libraries, Vulkan loader, mold +just check # what CI checks +just test +just parity # the Figma parity suite (needs a GPU) +just faces # generate the plugin faces into examples/plugin-faces/generated + +cargo run --release -p frame-desktop -- crates/frame/examples/surfaces/01-stage.frame # hot-reloads on save +``` + +The faces are generated, not kept in git (`just faces`, about 130 MB). The +parity scenarios, `regression.tough-1176-blue-face` included, are in +`crates/frame/render/tests/parity/scenarios`. + +## History + +Frame lived in [Studio](https://github.com/FastTrackStudios/studio) from +2026-10-12 to 2026-11 (`crates/frame`), where its 31 crates were folded into +one. Its history was cut out of Studio with `git filter-repo` and grafted +onto this repo's `2dae10b`, so `git log --follow` runs from March 2026 +through Studio to now. Commits carried over from Studio end with a +`Studio-commit: FastTrackStudios/studio@` trailer. Until Studio depends +on Frame by tag, `just catch-up ` replays Studio commits +that touched Frame (`scripts/frame-catch-up.sh`). + +## Licence + +Frame is licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) +or [MIT license](LICENSE-MIT), at your option. Unless you state otherwise, +any contribution you submit for inclusion in Frame is dual licensed as above, +without any additional terms or conditions. diff --git a/crates/frame-import/Cargo.toml b/crates/frame-import/Cargo.toml deleted file mode 100644 index 2f317091..00000000 --- a/crates/frame-import/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "frame-import" -description = "Figma REST API JSON importer for frame-proto" -version.workspace = true -edition.workspace = true - -[dependencies] -frame-proto = { path = "../frame-proto" } -figma-api = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - -[lints] -workspace = true diff --git a/crates/frame-proto/Cargo.toml b/crates/frame-proto/Cargo.toml deleted file mode 100644 index 1818db1b..00000000 --- a/crates/frame-proto/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "frame-proto" -description = "Frame protocol — Figma-compatible vector design document model" -version.workspace = true -edition.workspace = true - -[dependencies] -facet = { workspace = true, features = ["uuid"] } -figma-api = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -uuid = { workspace = true } -base64 = "0.22" -zip = { version = "2.2", default-features = false, features = ["deflate"] } - -[lints] -workspace = true diff --git a/crates/frame-ui/Cargo.toml b/crates/frame-ui/Cargo.toml deleted file mode 100644 index 0f8e9e46..00000000 --- a/crates/frame-ui/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "frame-ui" -description = "Frame UI — Dioxus components for Figma-compatible design rendering" -version.workspace = true -edition.workspace = true - -[dependencies] -frame-proto = { path = "../frame-proto" } -figma-api = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["time"] } -roxmltree = "0.20" - -# UI framework (conditional on feature) -dioxus = { workspace = true, optional = true, features = ["desktop"] } -anyrender = { workspace = true, optional = true } -anyrender_svg = { workspace = true, optional = true } -anyrender_vello = { workspace = true, optional = true } -vello = { workspace = true, optional = true } -kurbo = { workspace = true, optional = true } -peniko = { workspace = true, optional = true } -skrifa = { version = "0.26", optional = true } -base64 = { version = "0.22", optional = true } -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"], optional = true } -taffy = { workspace = true } -winit = { workspace = true, optional = true } -pollster = { workspace = true, optional = true } -svg = { version = "0.18", optional = true } -rust_decimal = { version = "1.36", optional = true } -fixed = { version = "1.28", optional = true } - -[features] -default = ["web"] -web = ["dep:dioxus"] -svg_export = ["dep:svg", "dep:rust_decimal", "dep:fixed", "dep:kurbo"] -anyrender = ["dep:anyrender", "dep:anyrender_svg", "dep:anyrender_vello", "dep:vello", "dep:kurbo", "dep:peniko", "dep:skrifa", "dep:base64", "dep:image", "dep:winit", "dep:pollster", "svg_export"] - -[lints] -workspace = true diff --git a/crates/frame-ui/src/anyrender_renderer/collect.rs b/crates/frame-ui/src/anyrender_renderer/collect.rs deleted file mode 100644 index f209bd12..00000000 --- a/crates/frame-ui/src/anyrender_renderer/collect.rs +++ /dev/null @@ -1,740 +0,0 @@ -use super::extract::*; -use super::layout::build_layout_boxes; -use super::types::*; -use frame_proto::{FrameDocument, NodeId, RenderNodeClass}; - -pub fn collect_render_diagnostics(doc: &FrameDocument, root: NodeId) -> RenderDiagnostics { - let mut out = RenderDiagnostics::default(); - for node_id in doc.walk_subtree(root) { - let Some(p) = doc.project_node(node_id) else { - continue; - }; - out.total_nodes += 1; - if p.feature_flags.has_non_normal_blend { - out.non_normal_blend_nodes += 1; - } - if p.feature_flags.has_gradient_fill { - out.gradient_fill_nodes += 1; - } - if p.feature_flags.has_image_fill { - out.image_fill_nodes += 1; - } - if p.feature_flags.has_drop_shadow { - out.drop_shadow_nodes += 1; - } - if p.feature_flags.has_inner_shadow { - out.inner_shadow_nodes += 1; - } - if p.feature_flags.has_layer_blur { - out.layer_blur_nodes += 1; - if is_layer_blur_approximated(&p) { - out.layer_blur_approx_nodes += 1; - } - } - if p.feature_flags.has_background_blur { - out.background_blur_nodes += 1; - out.background_blur_approx_nodes += 1; - } - if p.feature_flags.has_alpha_mask { - out.alpha_mask_nodes += 1; - } - if p.feature_flags.has_luminance_mask { - out.luminance_mask_nodes += 1; - } - } - out -} - -fn is_layer_blur_approximated(p: &frame_proto::RenderNodeProjection) -> bool { - matches!( - p.class, - frame_proto::RenderNodeClass::Text - | frame_proto::RenderNodeClass::Utility - | frame_proto::RenderNodeClass::Unknown - ) -} - -pub fn build_paint_primitives(doc: &FrameDocument, root: NodeId) -> Vec { - let layout_boxes = build_layout_boxes(doc, root); - let mut out = Vec::new(); - collect_primitives_recursive( - doc, - root, - 0.0, - 0.0, - false, - 1.0, - &[], - &layout_boxes, - &mut out, - ); - - out -} - -fn collect_primitives_recursive( - doc: &FrameDocument, - node_id: NodeId, - parent_x: f64, - parent_y: f64, - force_local_positioning: bool, - inherited_opacity: f64, - inherited_layer_blurs: &[NodeEffect], - layout_boxes: &std::collections::HashMap, - out: &mut Vec, -) { - let Some(node) = doc.get_node(node_id) else { - return; - }; - if !node.visible { - return; - } - - let local_opacity = node - .raw - .get("opacity") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - let effective_opacity = (inherited_opacity * local_opacity).clamp(0.0, 1.0); - - // Always extract rotation from relativeTransform, even when position/size - // comes from layout boxes. Layout boxes flatten position but don't encode rotation. - let (local_x, local_y, _lw, _lh, rotation) = extract_local_rect(&node.raw); - let chain_x = parent_x + local_x; - let chain_y = parent_y + local_y; - let (x, y, width, height) = if !force_local_positioning { - layout_boxes - .get(&node_id) - .map(|b| (b.x, b.y, b.width, b.height)) - .unwrap_or_else(|| { - let (local_x, local_y, width, height, _rot) = extract_local_rect(&node.raw); - (parent_x + local_x, parent_y + local_y, width, height) - }) - } else { - let (local_x, local_y, width, height, _rot) = extract_local_rect(&node.raw); - (parent_x + local_x, parent_y + local_y, width, height) - }; - - let corner_radii = { - if let Some(arr) = node - .raw - .get("rectangleCornerRadii") - .and_then(|v| v.as_array()) - { - if arr.len() >= 4 { - frame_proto::CornerRadii { - top_left: arr[0].as_f64().unwrap_or(0.0).max(0.0), - top_right: arr[1].as_f64().unwrap_or(0.0).max(0.0), - bottom_right: arr[2].as_f64().unwrap_or(0.0).max(0.0), - bottom_left: arr[3].as_f64().unwrap_or(0.0).max(0.0), - } - } else { - frame_proto::CornerRadii::uniform( - node.raw - .get("cornerRadius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0), - ) - } - } else { - frame_proto::CornerRadii::uniform( - node.raw - .get("cornerRadius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0), - ) - } - }; - // ELLIPSE nodes in Figma are inherently circular/elliptical — they don't - // carry a cornerRadius. When rendered as a Rect, set corner_radii to - // half the smaller axis so the RoundedRect is a circle/pill shape. - let corner_radii = if node.figma_type == "ELLIPSE" && corner_radii.is_zero() { - let r = width.min(height) * 0.5; - frame_proto::CornerRadii::uniform(r) - } else { - corner_radii - }; - let blend = parse_blend_mode(&node.raw); - // Check whether this node has any visible filter effects (drop shadow, - // inner shadow, layer blur). When such effects AND opacity < 1 coexist, - // we must surface the opacity on the filter-group wrapper rather than - // baking it into fill/effect colors — otherwise the group opacity won't - // be present on the element as Figma expects. - let has_filter_effects = node - .raw - .get("effects") - .and_then(|v| v.as_array()) - .is_some_and(|arr| { - arr.iter().any(|e| { - e.get("visible") - .and_then(|v| v.as_bool()) - .unwrap_or(true) - && matches!( - e.get("type").and_then(|v| v.as_str()).unwrap_or(""), - "DROP_SHADOW" | "INNER_SHADOW" | "LAYER_BLUR" - ) - }) - }); - // Non-normal blends are composited through an explicit layer; keep that - // layer's opacity there instead of baking it into paints/effects/children. - // Nodes with filter effects and sub-unit opacity also need an explicit layer - // so the opacity appears on the wrapper (not baked into colors). - let needs_layer = (blend != BlendMix::Normal - || (has_filter_effects && effective_opacity < 1.0)) - && width > 0.0 - && height > 0.0; - let content_opacity = if needs_layer { 1.0 } else { effective_opacity }; - let primitive_blend = if needs_layer { - BlendMix::Normal - } else { - blend.clone() - }; - - let fills: Vec<(FillPaint, BlendMix)> = all_fill_paints(&node.raw, "fills") - .into_iter() - .map(|(f, b)| (f.with_opacity(content_opacity), b)) - .collect(); - let fills_is_empty = fills.is_empty(); - let stroke = parse_stroke_style(&node.raw, content_opacity); - let effects = parse_effects(&node.raw, content_opacity); - let mut merged_effects = inherited_layer_blurs.to_vec(); - merged_effects.extend(effects.clone()); - let mut child_layer_blurs = inherited_layer_blurs.to_vec(); - child_layer_blurs.extend( - effects - .iter() - .filter(|e| matches!(e.kind, EffectKind::LayerBlur)) - .cloned(), - ); - // Only push a compositor layer for non-Normal blend modes. - if needs_layer { - out.push(PaintPrimitive::LayerStart { - node_id, - x, - y, - width, - height, - blend: blend.clone(), - opacity: effective_opacity, - }); - } - - let fill_paths = path_strings(&node.raw, "fillGeometry"); - let stroke_paths = path_strings(&node.raw, "strokeGeometry"); - let svg_base64 = exported_svg_base64(&node.raw); - let has_native_geometry = !fill_paths.is_empty() || !stroke_paths.is_empty(); - let has_vector_geometry = has_native_geometry || svg_base64.is_some(); - let is_instance = node.figma_type == "INSTANCE"; - let is_boolean_operation = node.figma_type == "BOOLEAN_OPERATION"; - let clips_content = node - .raw - .get("clipsContent") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let child_overflows_bounds = if clips_content && width > 0.0 && height > 0.0 { - let right = x + width; - let bottom = y + height; - const EPS: f64 = 1e-3; - node.children.iter().any(|child_id| { - let (cx, cy, cw, ch) = layout_boxes - .get(child_id) - .map(|b| (b.x, b.y, b.width, b.height)) - .unwrap_or_else(|| { - let Some(child_node) = doc.get_node(*child_id) else { - return (x, y, 0.0, 0.0); - }; - let (local_x, local_y, child_w, child_h, _rot) = - extract_local_rect(&child_node.raw); - (x + local_x, y + local_y, child_w, child_h) - }); - cx < x - EPS - || cy < y - EPS - || (cx + cw) > right + EPS - || (cy + ch) > bottom + EPS - }) - } else { - false - }; - let clip_subtree = - clips_content && !node.children.is_empty() && (fills_is_empty || child_overflows_bounds); - - let mut rendered_as_atomic_svg = false; - if clip_subtree && width > 0.0 && height > 0.0 { - out.push(PaintPrimitive::ClipStart { - node_id, - x, - y, - width, - height, - corner_radii, - }); - } - - let node_class = frame_proto::classify_node_type(&node.figma_type); - // For parity exports, Figma includes authoritative text outline geometry. - // Prefer outline paths whenever available so SVG and raster paths align with - // Figma's own serializer/layout instead of host-side text shaping. - let prefer_text_outlines = has_native_geometry; - - match node_class { - RenderNodeClass::Text if has_vector_geometry && prefer_text_outlines => { - // Text with pre-rendered glyph outlines in fillGeometry — render as vector paths. - // Bridge exports don't include font metadata so glyph-by-glyph layout fails; - // the fillGeometry paths are the authoritative visual representation. - // Use raw transform-chain coordinates here (not layout boxes) to avoid - // taffy/layout float drift in authored text outline positions. - let mut outline_fills = fills; - if outline_fills.is_empty() { - outline_fills = styled_text_fill_runs(&node.raw, content_opacity); - } - if stroke_paths.is_empty() - && !fill_paths.is_empty() - && outline_fills.len() == fill_paths.len() - { - for (d, fill) in fill_paths.into_iter().zip(outline_fills.into_iter()) { - out.push(PaintPrimitive::Path { - node_id, - x: chain_x, - y: chain_y, - width, - height, - normalize_to_origin: true, - text_outline: true, - fill_paths: vec![d], - stroke_paths: Vec::new(), - svg_base64: None, - fills: vec![fill], - stroke: stroke.clone(), - effects: merged_effects.clone(), - blend: primitive_blend.clone(), - rotation, - }); - } - } else { - out.push(PaintPrimitive::Path { - node_id, - x: chain_x, - y: chain_y, - width, - height, - normalize_to_origin: true, - text_outline: true, - fill_paths, - stroke_paths, - svg_base64, - fills: outline_fills, - stroke, - effects: merged_effects.clone(), - blend: primitive_blend.clone(), - rotation, - }); - } - } - RenderNodeClass::Text => { - let text = node - .raw - .get("characters") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - if !text.is_empty() { - let color = fills - .first() - .and_then(|(f, _)| first_color_from_fill(f)) - .unwrap_or(Rgba { - r: 1.0, - g: 1.0, - b: 1.0, - a: content_opacity, - }); - // v2 plugin format: fontSize at top level - // v1 REST API format: style.fontSize - let font_size = node - .raw - .get("fontSize") - .and_then(|v| v.as_f64()) - .or_else(|| { - node.raw - .get("style") - .and_then(|v| v.get("fontSize")) - .and_then(|v| v.as_f64()) - }) - .unwrap_or(14.0) - .max(1.0); - let line_height = node - .raw - .get("lineHeightPx") - .and_then(|v| v.as_f64()) - .or_else(|| { - node.raw - .get("style") - .and_then(|v| v.get("lineHeightPx")) - .and_then(|v| v.as_f64()) - }) - .filter(|v| *v > 0.0); - let letter_spacing = node - .raw - .get("letterSpacingPx") - .and_then(|v| v.as_f64()) - .or_else(|| { - node.raw - .get("style") - .and_then(|v| v.get("letterSpacing")) - .and_then(|v| v.as_f64()) - }) - .unwrap_or(0.0); - // v2: textCase at top level; v1: style.textCase - let text_case = - match node - .raw - .get("textCase") - .and_then(|v| v.as_str()) - .or_else(|| { - node.raw - .get("style") - .and_then(|v| v.get("textCase")) - .and_then(|v| v.as_str()) - }) { - Some("UPPER") => TextCase::Upper, - Some("LOWER") => TextCase::Lower, - Some("TITLE") => TextCase::Title, - Some("SMALL_CAPS") => TextCase::SmallCaps, - Some("SMALL_CAPS_FORCED") => TextCase::SmallCapsForced, - _ => TextCase::Original, - }; - - out.push(PaintPrimitive::Text { - node_id, - x, - y, - text, - color, - font_size, - line_height, - letter_spacing, - text_case, - style_runs: parse_text_style_runs(&node.raw, content_opacity), - blend: primitive_blend.clone(), - }); - } - } - _ if is_instance - && svg_base64.is_some() - && !has_native_geometry - && node.children.is_empty() - && node - .raw - .get("componentId") - .and_then(|v| v.as_str()) - .and_then(|component_id| doc.get_by_figma_id(component_id)) - .is_none() => - { - // Prefer exported instance SVG over component-master fallback. This - // preserves variant/override visuals from Figma and avoids drawing - // stale master internals at incorrect offsets. - rendered_as_atomic_svg = true; - out.push(PaintPrimitive::Path { - node_id, - x, - y, - width, - height, - normalize_to_origin: false, - text_outline: false, - fill_paths, - stroke_paths, - svg_base64, - fills, - stroke, - effects: merged_effects.clone(), - blend: primitive_blend.clone(), - rotation, - }); - } - _ if is_boolean_operation - && svg_base64.is_some() - && !has_native_geometry - && node.children.is_empty() => - { - // Boolean operation fidelity is most accurate from Figma-exported SVG. - // Rendering children directly can duplicate paths and lose operation semantics. - rendered_as_atomic_svg = true; - out.push(PaintPrimitive::Path { - node_id, - x, - y, - width, - height, - normalize_to_origin: false, - text_outline: false, - fill_paths, - stroke_paths, - svg_base64, - fills, - stroke, - effects: merged_effects.clone(), - blend: primitive_blend.clone(), - rotation, - }); - } - _ if is_instance => {} - RenderNodeClass::Vector | RenderNodeClass::Shape if has_vector_geometry => { - out.push(PaintPrimitive::Path { - node_id, - x, - y, - width, - height, - normalize_to_origin: false, - text_outline: false, - fill_paths, - stroke_paths, - svg_base64, - fills, - stroke, - effects: merged_effects.clone(), - blend: primitive_blend.clone(), - rotation, - }); - } - _ => { - if width > 0.0 && height > 0.0 { - out.push(PaintPrimitive::Rect { - node_id, - x, - y, - width, - height, - fills, - stroke, - corner_radii, - effects: merged_effects.clone(), - blend: primitive_blend, - rotation, - }); - } - } - } - - if rendered_as_atomic_svg { - if clip_subtree && width > 0.0 && height > 0.0 { - out.push(PaintPrimitive::ClipEnd { node_id }); - } - if needs_layer { - out.push(PaintPrimitive::LayerEnd { node_id }); - } - return; - } - - // Figma masks affect subsequent siblings within the same parent scope. - // We model this with clip layers driven by `isMask` children. - let mut active_sibling_mask: Option = None; - for child_id in &node.children { - let Some(child) = doc.get_node(*child_id) else { - continue; - }; - let child_is_mask = child - .raw - .get("isMask") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let child_mask_type = child - .raw - .get("maskType") - .and_then(|v| v.as_str()) - .unwrap_or("ALPHA"); - let child_mask_supported = !child_mask_type.eq_ignore_ascii_case("LUMINANCE"); - - if child_is_mask && child_mask_supported { - if let Some(mask_node_id) = active_sibling_mask.take() { - out.push(PaintPrimitive::MaskApplyEnd { - node_id: mask_node_id, - }); - } - - let (mx, my, mw, mh) = layout_boxes - .get(child_id) - .map(|b| (b.x, b.y, b.width, b.height)) - .unwrap_or_else(|| { - let (local_x, local_y, width, height, _rot) = extract_local_rect(&child.raw); - (x + local_x, y + local_y, width, height) - }); - if mw > 0.0 && mh > 0.0 { - out.push(PaintPrimitive::MaskDefStart { - node_id: *child_id, - x: mx, - y: my, - width: mw, - height: mh, - luminance: child_mask_type.eq_ignore_ascii_case("LUMINANCE"), - }); - collect_primitives_recursive( - doc, - *child_id, - x, - y, - false, - content_opacity, - &child_layer_blurs, - layout_boxes, - out, - ); - out.push(PaintPrimitive::MaskDefEnd { node_id: *child_id }); - out.push(PaintPrimitive::MaskApplyStart { - node_id: *child_id, - x: mx, - y: my, - width: mw, - height: mh, - }); - active_sibling_mask = Some(*child_id); - } - - continue; - } - - collect_primitives_recursive( - doc, - *child_id, - x, - y, - false, - content_opacity, - &child_layer_blurs, - layout_boxes, - out, - ); - } - - if let Some(mask_node_id) = active_sibling_mask.take() { - out.push(PaintPrimitive::MaskApplyEnd { - node_id: mask_node_id, - }); - } - - // Bridge JSON often encodes INSTANCEs without child trees. Resolve from - // component master definitions so we still render the actual visuals. - let has_children = !node.children.is_empty(); - if is_instance && !has_children { - if let Some(component_id) = node.raw.get("componentId").and_then(|v| v.as_str()) { - if let Some(component_node) = doc.get_by_figma_id(component_id) { - for child_id in &component_node.children { - collect_primitives_recursive( - doc, - *child_id, - x, - y, - true, - content_opacity, - &child_layer_blurs, - layout_boxes, - out, - ); - } - } - } - } - - if clip_subtree && width > 0.0 && height > 0.0 { - out.push(PaintPrimitive::ClipEnd { node_id }); - } - if needs_layer { - out.push(PaintPrimitive::LayerEnd { node_id }); - } -} - -fn styled_text_fill_runs(raw: &serde_json::Value, opacity: f64) -> Vec<(FillPaint, BlendMix)> { - let mut runs: Vec<(FillPaint, BlendMix)> = Vec::new(); - if let Some(segments) = raw.get("styledTextSegments").and_then(|v| v.as_array()) { - for segment in segments { - if segment - .get("characters") - .and_then(|v| v.as_str()) - .map(|s| s.trim().is_empty()) - .unwrap_or(false) - { - continue; - } - let Some((fill, blend)) = all_fill_paints(segment, "fills") - .into_iter() - .map(|(f, b)| (f.with_opacity(opacity), b)) - .next() - else { - continue; - }; - let is_same_as_last = runs - .last() - .map(|(last_fill, last_blend)| *last_blend == blend && *last_fill == fill) - .unwrap_or(false); - if !is_same_as_last { - runs.push((fill, blend)); - } - } - } - runs -} - -fn parse_text_style_runs(raw: &serde_json::Value, opacity: f64) -> Vec { - let Some(segments) = raw.get("styledTextSegments").and_then(|v| v.as_array()) else { - return Vec::new(); - }; - - let mut out = Vec::new(); - for segment in segments { - let text = segment - .get("characters") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - if text.is_empty() { - continue; - } - - let color = segment - .get("fills") - .and_then(|v| v.as_array()) - .and_then(|fills| { - fills - .iter() - .find(|fill| fill.get("type").and_then(|v| v.as_str()) == Some("SOLID")) - .and_then(solid_rgba_from_paint) - }) - .map(|c| c.with_opacity(opacity)); - - let letter_spacing = segment - .get("letterSpacingPx") - .and_then(|v| v.as_f64()) - .or_else(|| { - segment - .get("letterSpacing") - .and_then(|v| v.get("value")) - .and_then(|v| v.as_f64()) - }); - - let font_style = segment - .get("fontStyle") - .and_then(|v| v.as_str()) - .map(ToString::to_string) - .or_else(|| { - segment - .get("fontName") - .and_then(|v| v.get("style")) - .and_then(|v| v.as_str()) - .map(ToString::to_string) - }); - - out.push(TextStyleRun { - text, - color, - font_size: segment.get("fontSize").and_then(|v| v.as_f64()), - letter_spacing, - font_style, - font_weight: segment.get("fontWeight").and_then(|v| v.as_f64()), - }); - } - - out -} diff --git a/crates/frame-ui/src/anyrender_renderer/extract.rs b/crates/frame-ui/src/anyrender_renderer/extract.rs deleted file mode 100644 index e582ba50..00000000 --- a/crates/frame-ui/src/anyrender_renderer/extract.rs +++ /dev/null @@ -1,511 +0,0 @@ -use super::types::*; - -const SVG_IMAGE_FILL_FALLBACK_BASE64: &str = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; -const SVG_IMAGE_FILL_SAMPLING_FALLBACK_BASE64: &str = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4MDD4DwAE/gH/5pEq9AAAAABJRU5ErkJggg=="; - -fn image_fill_fallback_base64(image_hash: Option<&str>) -> &'static str { - match image_hash { - // Some parity fixtures use a deterministic placeholder tied to this hash. - Some("b6f8e2adf979577a15f43ba46ba26289a0ef23f3") => SVG_IMAGE_FILL_SAMPLING_FALLBACK_BASE64, - _ => SVG_IMAGE_FILL_FALLBACK_BASE64, - } -} - -/// Returns (x, y, width, height, rotation_radians). -/// -/// Rotation is extracted from the relativeTransform matrix columns. -/// For a pure rotation: `[[cos θ, -sin θ, tx], [sin θ, cos θ, ty]]`. -pub(super) fn extract_local_rect(raw: &serde_json::Value) -> (f64, f64, f64, f64, f64) { - // Prefer local node coordinates so rendering is stable regardless of how - // upstream exporters populate absoluteBoundingBox (which may be global, - // page-relative, or component-library-relative). - let mut x = 0.0; - let mut y = 0.0; - let mut rotation = 0.0; - if let Some(t) = raw.get("relativeTransform").and_then(|v| v.as_array()) { - if t.len() >= 2 { - x = t[0] - .as_array() - .and_then(|row| row.get(2)) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - y = t[1] - .as_array() - .and_then(|row| row.get(2)) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - // Extract rotation from matrix columns: atan2(c, a) - let a = t[0] - .as_array() - .and_then(|row| row.first()) - .and_then(|v| v.as_f64()) - .unwrap_or(1.0); - let c = t[1] - .as_array() - .and_then(|row| row.first()) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - rotation = c.atan2(a); - } - } - - let mut w = raw - .get("size") - .and_then(|v| v.get("x")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let mut h = raw - .get("size") - .and_then(|v| v.get("y")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - if w <= 0.0 || h <= 0.0 { - if let Some(bb) = raw.get("absoluteBoundingBox") { - if w <= 0.0 { - w = bb.get("width").and_then(|v| v.as_f64()).unwrap_or(w); - } - if h <= 0.0 { - h = bb.get("height").and_then(|v| v.as_f64()).unwrap_or(h); - } - } - } - - (x, y, w, h, rotation) -} - -pub(super) fn first_solid_rgba(raw: &serde_json::Value, key: &str) -> Option { - let arr = raw.get(key)?.as_array()?; - for paint in arr { - let visible = paint - .get("visible") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if !visible { - continue; - } - if paint.get("type").and_then(|v| v.as_str()) != Some("SOLID") { - continue; - } - - let color = paint.get("color")?; - let opacity = paint - .get("opacity") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - - let r = color.get("r")?.as_f64()?.clamp(0.0, 1.0); - let g = color.get("g")?.as_f64()?.clamp(0.0, 1.0); - let b = color.get("b")?.as_f64()?.clamp(0.0, 1.0); - let a = color - .get("a") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - - return Some(Rgba { - r, - g, - b, - a: (a * opacity).clamp(0.0, 1.0), - }); - } - - None -} - -pub(super) fn all_fill_paints(raw: &serde_json::Value, key: &str) -> Vec<(FillPaint, BlendMix)> { - let Some(arr) = raw.get(key).and_then(|v| v.as_array()) else { - return Vec::new(); - }; - arr.iter() - .filter_map(|paint| { - let visible = paint - .get("visible") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if !visible { - return None; - } - - let blend = parse_blend_mode(paint); - let paint_type = paint - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let fill = match paint_type { - "SOLID" => solid_rgba_from_paint(paint).map(FillPaint::Solid), - "GRADIENT_LINEAR" => { - gradient_stops_from_paint(paint).map(|stops| FillPaint::GradientLinear { - transform: gradient_transform_from_paint(paint), - stops, - }) - } - "GRADIENT_RADIAL" => { - gradient_stops_from_paint(paint).map(|stops| FillPaint::GradientRadial { - transform: gradient_transform_from_paint(paint), - stops, - }) - } - "GRADIENT_ANGULAR" => { - gradient_stops_from_paint(paint).map(|stops| FillPaint::GradientAngular { - transform: gradient_transform_from_paint(paint), - stops, - }) - } - "IMAGE" => image_fill_from_paint(raw, paint).map(|img| FillPaint::Image { - image_hash: img.image_hash, - data_base64: img.data_base64, - alpha: img.alpha, - scale_mode: img.scale_mode, - image_transform: img.image_transform, - }), - _ => None, - }; - fill.map(|f| (f, blend)) - }) - .collect() -} - -pub(super) fn image_fill_from_paint( - raw: &serde_json::Value, - paint: &serde_json::Value, -) -> Option { - let image_hash = paint - .get("imageHash") - .and_then(|v| v.as_str()) - .map(ToString::to_string); - - // Support both v1 flat string and v2 nested object with "base64" field. - // If asset hydration is unavailable, emit a deterministic 1x1 placeholder - // so SVG parity still preserves image-fill structure. - let data_base64 = raw - .get("imageFillAssets") - .and_then(|v| v.as_object()) - .and_then(|image_fill_assets| { - image_hash - .as_ref() - .and_then(|hash| image_fill_assets.get(hash)) - .and_then(|asset_value| { - asset_value - .as_str() - .or_else(|| asset_value.get("base64").and_then(|v| v.as_str())) - }) - }) - .unwrap_or_else(|| image_fill_fallback_base64(image_hash.as_deref())) - .to_string(); - - let alpha = paint - .get("opacity") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - - let scale_mode = match paint.get("scaleMode").and_then(|v| v.as_str()) { - Some("FILL") => ImageScaleMode::Fill, - Some("FIT") => ImageScaleMode::Fit, - Some("CROP") => ImageScaleMode::Crop, - Some("TILE") => ImageScaleMode::Tile, - Some("STRETCH") => ImageScaleMode::Stretch, - _ => ImageScaleMode::Fill, // Figma default - }; - - let image_transform = parse_image_transform(paint); - - Some(ImageFillData { - image_hash, - data_base64, - alpha, - scale_mode, - image_transform, - }) -} - -pub(super) struct ImageFillData { - pub image_hash: Option, - pub data_base64: String, - pub alpha: f64, - pub scale_mode: ImageScaleMode, - pub image_transform: Option, -} - -fn parse_image_transform(paint: &serde_json::Value) -> Option { - let t = paint.get("imageTransform")?.as_array()?; - if t.len() < 2 { - return None; - } - let r0 = t[0].as_array()?; - let r1 = t[1].as_array()?; - if r0.len() < 3 || r1.len() < 3 { - return None; - } - Some(AffineTransform2D::from([ - [ - r0[0].as_f64().unwrap_or(1.0), - r0[1].as_f64().unwrap_or(0.0), - r0[2].as_f64().unwrap_or(0.0), - ], - [ - r1[0].as_f64().unwrap_or(0.0), - r1[1].as_f64().unwrap_or(1.0), - r1[2].as_f64().unwrap_or(0.0), - ], - ])) -} - -pub(super) fn parse_effects(raw: &serde_json::Value, opacity: f64) -> Vec { - let Some(effects) = raw.get("effects").and_then(|v| v.as_array()) else { - return Vec::new(); - }; - - effects - .iter() - .filter_map(|effect| { - let visible = effect - .get("visible") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if !visible { - return None; - } - - let kind = match effect.get("type").and_then(|v| v.as_str()) { - Some("DROP_SHADOW") => EffectKind::DropShadow, - Some("INNER_SHADOW") => EffectKind::InnerShadow, - Some("LAYER_BLUR") => EffectKind::LayerBlur, - Some("BACKGROUND_BLUR") => EffectKind::BackgroundBlur, - _ => return None, - }; - - let color = solid_rgba_from_paint(effect).unwrap_or(Rgba { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.25, - }); - - let offset_x = effect - .get("offset") - .and_then(|v| v.get("x")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let offset_y = effect - .get("offset") - .and_then(|v| v.get("y")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let radius = effect - .get("radius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0); - let spread = effect.get("spread").and_then(|v| v.as_f64()).unwrap_or(0.0); - - Some(NodeEffect { - kind, - color: color.with_opacity(opacity), - offset_x, - offset_y, - radius, - spread, - }) - }) - .collect() -} - -pub(super) fn parse_stroke_style(raw: &serde_json::Value, opacity: f64) -> Option { - let color = first_solid_rgba(raw, "strokes")?.with_opacity(opacity); - let width = raw - .get("strokeWeight") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .max(0.0); - if width <= 0.0 { - return None; - } - - let cap = get_typed::(raw, "strokeCap").unwrap_or(StrokeCap::None); - let join = get_typed::(raw, "strokeJoin").unwrap_or(StrokeJoin::Miter); - let align = get_typed::(raw, "strokeAlign").unwrap_or(StrokeAlign::Center); - let miter_limit = raw - .get("strokeMiterLimit") - .and_then(|v| v.as_f64()) - .unwrap_or(4.0) - .max(1.0); - let dash_pattern = raw - .get("dashPattern") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_f64()) - .filter(|v| *v > 0.0) - .collect::>() - }) - .unwrap_or_default(); - let dash_offset = raw - .get("strokeDashes") - .and_then(|v| v.get("offset")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - - Some(StrokeStyle { - color, - width, - cap, - join, - align, - miter_limit, - dash_pattern, - dash_offset, - }) -} - -pub(super) fn parse_blend_mode(raw: &serde_json::Value) -> BlendMix { - match raw.get("blendMode").and_then(|v| v.as_str()) { - Some("MULTIPLY") => BlendMix::Multiply, - Some("SCREEN") => BlendMix::Screen, - Some("OVERLAY") => BlendMix::Overlay, - Some("DARKEN") => BlendMix::Darken, - Some("LIGHTEN") => BlendMix::Lighten, - Some("COLOR_DODGE") => BlendMix::ColorDodge, - Some("COLOR_BURN") => BlendMix::ColorBurn, - Some("HARD_LIGHT") => BlendMix::HardLight, - Some("SOFT_LIGHT") => BlendMix::SoftLight, - Some("DIFFERENCE") => BlendMix::Difference, - Some("EXCLUSION") => BlendMix::Exclusion, - Some("HUE") => BlendMix::Hue, - Some("SATURATION") => BlendMix::Saturation, - Some("COLOR") => BlendMix::Color, - Some("LUMINOSITY") => BlendMix::Luminosity, - _ => BlendMix::Normal, - } -} - -fn get_typed(raw: &serde_json::Value, key: &str) -> Option -where - T: for<'de> serde::Deserialize<'de>, -{ - let value = raw.get(key)?; - serde_json::from_value(value.clone()).ok() -} - -pub(super) fn solid_rgba_from_paint(paint: &serde_json::Value) -> Option { - let color = paint.get("color")?; - let opacity = paint - .get("opacity") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - let r = color.get("r")?.as_f64()?.clamp(0.0, 1.0); - let g = color.get("g")?.as_f64()?.clamp(0.0, 1.0); - let b = color.get("b")?.as_f64()?.clamp(0.0, 1.0); - let a = color - .get("a") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - Some(Rgba { - r, - g, - b, - a: (a * opacity).clamp(0.0, 1.0), - }) -} - -fn gradient_stops_from_paint(paint: &serde_json::Value) -> Option> { - let stops = paint.get("gradientStops")?.as_array()?; - // Paint-level opacity applies to every stop in the gradient - let paint_opacity = paint - .get("opacity") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - let mut out = Vec::new(); - for stop in stops { - let color_value = stop.get("color")?; - let position = stop - .get("position") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .clamp(0.0, 1.0); - // Pass the paint opacity so it multiplies into the stop's alpha - let color = solid_rgba_from_paint( - &serde_json::json!({ "color": color_value, "opacity": paint_opacity }), - )?; - out.push(GradientStop { - offset: position, - color, - }); - } - if out.is_empty() { - None - } else { - Some(out) - } -} - -fn gradient_transform_from_paint(paint: &serde_json::Value) -> Option { - let t = paint.get("gradientTransform")?.as_array()?; - if t.len() < 2 { - return None; - } - let r0 = t[0].as_array()?; - let r1 = t[1].as_array()?; - if r0.len() < 3 || r1.len() < 3 { - return None; - } - Some(AffineTransform2D::from([ - [ - r0[0].as_f64().unwrap_or(1.0), - r0[1].as_f64().unwrap_or(0.0), - r0[2].as_f64().unwrap_or(0.0), - ], - [ - r1[0].as_f64().unwrap_or(0.0), - r1[1].as_f64().unwrap_or(1.0), - r1[2].as_f64().unwrap_or(0.0), - ], - ])) -} - -pub(super) fn first_color_from_fill(fill: &FillPaint) -> Option { - match fill { - FillPaint::Solid(c) => Some(c.clone()), - FillPaint::GradientLinear { stops, .. } - | FillPaint::GradientRadial { stops, .. } - | FillPaint::GradientAngular { stops, .. } => stops.first().map(|s| s.color.clone()), - FillPaint::Image { .. } => None, - } -} - -pub(super) fn path_strings(raw: &serde_json::Value, key: &str) -> Vec { - raw.get(key) - .and_then(|v| v.as_array()) - .into_iter() - .flat_map(|arr| arr.iter()) - .filter_map(|entry| { - entry - .get("path") - .and_then(|v| v.as_str()) - .or_else(|| entry.get("data").and_then(|v| v.as_str())) - }) - .filter(|s| !s.trim().is_empty()) - .map(ToString::to_string) - .collect() -} - -pub(super) fn exported_svg_base64(raw: &serde_json::Value) -> Option { - raw.get("exports") - .and_then(|v| v.get("svgBase64")) - .and_then(|v| v.as_str()) - .map(ToString::to_string) -} - -#[cfg(test)] -pub(super) fn gradient_stops_from_paint_test( - paint: &serde_json::Value, -) -> Option> { - gradient_stops_from_paint(paint) -} diff --git a/crates/frame-ui/src/anyrender_renderer/layout.rs b/crates/frame-ui/src/anyrender_renderer/layout.rs deleted file mode 100644 index d52015c3..00000000 --- a/crates/frame-ui/src/anyrender_renderer/layout.rs +++ /dev/null @@ -1,264 +0,0 @@ -use super::extract::extract_local_rect; -use super::types::LayoutBox; -use frame_proto::{FrameDocument, NodeId}; -use taffy::prelude::*; -use taffy::TaffyTree; - -pub fn build_layout_boxes( - doc: &FrameDocument, - root: NodeId, -) -> std::collections::HashMap { - compute_layout_boxes(doc, root) -} - -fn compute_layout_boxes( - doc: &FrameDocument, - root: NodeId, -) -> std::collections::HashMap { - let mut out = std::collections::HashMap::new(); - let (x, y, width, height) = extract_node_rect(doc, root); - compute_layout_boxes_recursive(doc, root, x, y, width, height, &mut out); - out -} - -fn compute_layout_boxes_recursive( - doc: &FrameDocument, - node_id: NodeId, - abs_x: f64, - abs_y: f64, - width: f64, - height: f64, - out: &mut std::collections::HashMap, -) { - out.insert( - node_id, - LayoutBox { - x: abs_x, - y: abs_y, - width, - height, - }, - ); - - let Some(node) = doc.get_node(node_id) else { - return; - }; - let children = node.children.clone(); - if children.is_empty() { - return; - } - - let auto_layout = frame_proto::get_auto_layout_projection(&node.raw); - let is_auto_layout = auto_layout - .as_ref() - .and_then(|l| l.mode) - .filter(|m| { - matches!( - m, - frame_proto::AutoLayoutMode::Horizontal | frame_proto::AutoLayoutMode::Vertical - ) - }) - .is_some(); - - if !is_auto_layout { - for child_id in children { - let (cx, cy, cw, ch) = extract_node_rect(doc, child_id); - compute_layout_boxes_recursive(doc, child_id, abs_x + cx, abs_y + cy, cw, ch, out); - } - return; - } - - let mut taffy = TaffyTree::<()>::new(); - let mut child_nodes: Vec<(NodeId, taffy::NodeId)> = Vec::new(); - - for child_id in &children { - let style = child_taffy_style(doc, *child_id); - if let Ok(id) = taffy.new_leaf(style) { - child_nodes.push((*child_id, id)); - } - } - - let mut taffy_children = Vec::with_capacity(child_nodes.len()); - for (_, id) in &child_nodes { - taffy_children.push(*id); - } - - let container_style = container_taffy_style(auto_layout.as_ref(), width, height); - let Ok(container) = taffy.new_with_children(container_style, &taffy_children) else { - for child_id in children { - let (cx, cy, cw, ch) = extract_node_rect(doc, child_id); - compute_layout_boxes_recursive(doc, child_id, abs_x + cx, abs_y + cy, cw, ch, out); - } - return; - }; - - let _ = taffy.compute_layout( - container, - Size { - width: AvailableSpace::Definite(width as f32), - height: AvailableSpace::Definite(height as f32), - }, - ); - - for (child_id, taffy_id) in child_nodes { - if let Ok(layout) = taffy.layout(taffy_id) { - let cx = abs_x + layout.location.x as f64; - let cy = abs_y + layout.location.y as f64; - let cw = layout.size.width as f64; - let ch = layout.size.height as f64; - compute_layout_boxes_recursive(doc, child_id, cx, cy, cw, ch, out); - } else { - let (cx, cy, cw, ch) = extract_node_rect(doc, child_id); - compute_layout_boxes_recursive(doc, child_id, abs_x + cx, abs_y + cy, cw, ch, out); - } - } -} - -fn extract_node_rect(doc: &FrameDocument, node_id: NodeId) -> (f64, f64, f64, f64) { - doc.get_node(node_id) - .map(|n| { - let (x, y, w, h, _rotation) = extract_local_rect(&n.raw); - (x, y, w, h) - }) - .unwrap_or((0.0, 0.0, 0.0, 0.0)) -} - -fn container_taffy_style( - auto_layout: Option<&frame_proto::AutoLayout>, - width: f64, - height: f64, -) -> Style { - let mut style = Style { - display: Display::Flex, - size: Size { - width: Dimension::Length(width as f32), - height: Dimension::Length(height as f32), - }, - ..Default::default() - }; - - if let Some(layout) = auto_layout { - if let Some(mode) = layout.mode { - style.flex_direction = match mode { - frame_proto::AutoLayoutMode::Horizontal => FlexDirection::Row, - frame_proto::AutoLayoutMode::Vertical => FlexDirection::Column, - frame_proto::AutoLayoutMode::None => FlexDirection::Row, - }; - } - if let Some(v) = layout.item_spacing { - style.gap = if style.flex_direction == FlexDirection::Column { - Size { - width: LengthPercentage::Length(0.0), - height: LengthPercentage::Length(v as f32), - } - } else { - Size { - width: LengthPercentage::Length(v as f32), - height: LengthPercentage::Length(0.0), - } - }; - } - style.padding = Rect { - left: LengthPercentage::Length(layout.padding_left.unwrap_or(0.0) as f32), - right: LengthPercentage::Length(layout.padding_right.unwrap_or(0.0) as f32), - top: LengthPercentage::Length(layout.padding_top.unwrap_or(0.0) as f32), - bottom: LengthPercentage::Length(layout.padding_bottom.unwrap_or(0.0) as f32), - }; - if let Some(wrap) = layout.wrap { - style.flex_wrap = match wrap { - frame_proto::AutoLayoutWrap::Wrap => FlexWrap::Wrap, - frame_proto::AutoLayoutWrap::NoWrap => FlexWrap::NoWrap, - }; - } - style.justify_content = layout.primary_axis_align_items.map(|a| match a { - frame_proto::AutoLayoutPrimaryAlign::Min => JustifyContent::Start, - frame_proto::AutoLayoutPrimaryAlign::Center => JustifyContent::Center, - frame_proto::AutoLayoutPrimaryAlign::Max => JustifyContent::End, - frame_proto::AutoLayoutPrimaryAlign::SpaceBetween => JustifyContent::SpaceBetween, - }); - style.align_items = layout.counter_axis_align_items.map(|a| match a { - frame_proto::AutoLayoutCounterAlign::Min => AlignItems::Start, - frame_proto::AutoLayoutCounterAlign::Center => AlignItems::Center, - frame_proto::AutoLayoutCounterAlign::Max => AlignItems::End, - frame_proto::AutoLayoutCounterAlign::Baseline => AlignItems::Baseline, - }); - } - - style -} - -fn child_taffy_style(doc: &FrameDocument, node_id: NodeId) -> Style { - let Some(node) = doc.get_node(node_id) else { - return Style::default(); - }; - let auto_layout = frame_proto::get_auto_layout_projection(&node.raw); - let (x, y, width, height, _rotation) = extract_local_rect(&node.raw); - let mut style = Style { - size: Size { - width: Dimension::Length(width as f32), - height: Dimension::Length(height as f32), - }, - // Figma auto-layout children should not collapse when parent is constrained. - // Keep explicit node size from Figma and disable shrink by default. - flex_shrink: 0.0, - min_size: Size { - width: Dimension::Length( - auto_layout - .as_ref() - .and_then(|l| l.min_width) - .unwrap_or(0.0) as f32, - ), - height: Dimension::Length( - auto_layout - .as_ref() - .and_then(|l| l.min_height) - .unwrap_or(0.0) as f32, - ), - }, - max_size: Size { - width: auto_layout - .as_ref() - .and_then(|l| l.max_width) - .map(|v| Dimension::Length(v as f32)) - .unwrap_or(Dimension::Auto), - height: auto_layout - .as_ref() - .and_then(|l| l.max_height) - .map(|v| Dimension::Length(v as f32)) - .unwrap_or(Dimension::Auto), - }, - ..Default::default() - }; - - if let Some(layout) = auto_layout.as_ref() { - if let Some(grow) = layout.grow { - style.flex_grow = grow as f32; - } - style.align_self = layout.align_self.and_then(|a| match a { - frame_proto::AutoLayoutAlignSelf::Auto | frame_proto::AutoLayoutAlignSelf::Inherit => { - None - } - frame_proto::AutoLayoutAlignSelf::Min => Some(AlignSelf::Start), - frame_proto::AutoLayoutAlignSelf::Center => Some(AlignSelf::Center), - frame_proto::AutoLayoutAlignSelf::Max => Some(AlignSelf::End), - frame_proto::AutoLayoutAlignSelf::Stretch => Some(AlignSelf::Stretch), - }); - if matches!( - layout.positioning, - Some(frame_proto::AutoLayoutPositioning::Absolute) - ) { - style.position = Position::Absolute; - style.inset = Rect { - left: LengthPercentageAuto::Length(x as f32), - right: LengthPercentageAuto::Auto, - top: LengthPercentageAuto::Length(y as f32), - bottom: LengthPercentageAuto::Auto, - }; - } - // NOTE: Do not map Hug -> Dimension::Auto here without measurement callbacks. - // Taffy cannot infer intrinsic size for vector/text groups in this pipeline and - // collapses nodes, which causes overlap. We keep explicit Figma sizes for now. - } - - style -} diff --git a/crates/frame-ui/src/anyrender_renderer/mod.rs b/crates/frame-ui/src/anyrender_renderer/mod.rs deleted file mode 100644 index e46fcee2..00000000 --- a/crates/frame-ui/src/anyrender_renderer/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -mod collect; -mod extract; -mod layout; -pub mod transform; -mod types; - -#[cfg(feature = "anyrender")] -mod cache; -#[cfg(feature = "anyrender")] -mod geometry; -#[cfg(feature = "anyrender")] -mod paint; - -#[cfg(test)] -mod tests; - -pub use collect::{build_paint_primitives, collect_render_diagnostics}; -pub use layout::build_layout_boxes; -pub use transform::AffineTransform2D; -pub use types::{ - BlendMix, EffectKind, FillPaint, GradientStop, ImageScaleMode, LayoutBox, NodeEffect, - PaintPrimitive, RenderDiagnostics, Rgba, StrokeAlign, StrokeCap, StrokeJoin, StrokeStyle, - TextCase, -}; - -#[cfg(feature = "anyrender")] -pub use paint::{ - paint_into_scene, paint_into_scene_with, paint_into_scene_with_font, - paint_primitives_into_scene_with, -}; -#[cfg(feature = "anyrender")] -pub use types::TextFontRef; diff --git a/crates/frame-ui/src/anyrender_renderer/paint.rs b/crates/frame-ui/src/anyrender_renderer/paint.rs deleted file mode 100644 index a73dc697..00000000 --- a/crates/frame-ui/src/anyrender_renderer/paint.rs +++ /dev/null @@ -1,1654 +0,0 @@ -use super::cache::{cached_bez_path, cached_svg_decode, cached_svg_fit_transform, cached_svg_tree}; -use super::collect::build_paint_primitives; -use super::extract::first_color_from_fill; -use super::geometry::{compute_path_fit_transform, extract_scale, to_peniko}; -use super::types::*; -use frame_proto::{FrameDocument, NodeId}; - -const TEXT_ANCHOR_SHIFT_X: f64 = 1.0; -const TEXT_ANCHOR_SHIFT_Y: f64 = 1.0; - -pub fn paint_into_scene(scene: &mut vello::Scene, doc: &FrameDocument, root: NodeId) { - paint_into_scene_with(scene, doc, root, kurbo::Affine::IDENTITY, None); -} - -pub fn paint_into_scene_with_font( - scene: &mut vello::Scene, - doc: &FrameDocument, - root: NodeId, - font: TextFontRef<'_>, -) { - paint_into_scene_with(scene, doc, root, kurbo::Affine::IDENTITY, Some(font)); -} - -pub fn paint_into_scene_with( - scene: &mut vello::Scene, - doc: &FrameDocument, - root: NodeId, - scene_transform: kurbo::Affine, - text_font: Option>, -) { - let primitives = build_paint_primitives(doc, root); - paint_primitives_into_scene_with(scene, &primitives, scene_transform, text_font); -} - -pub fn paint_primitives_into_scene_with( - scene: &mut vello::Scene, - primitives: &[PaintPrimitive], - scene_transform: kurbo::Affine, - text_font: Option>, -) { - use base64::Engine; - use kurbo::{BezPath, Rect, RoundedRect, Stroke}; - use peniko::{Blob, Fill, FontData, ImageBrush, ImageData, ImageSampler}; - use skrifa::instance::Size; - use skrifa::raw::{FileRef, FontRef}; - use skrifa::MetadataProvider; - use std::sync::Arc; - use vello::Glyph; - - let font_data: Option = text_font.map(|font| { - // Cache the Arc<[u8]> allocation to avoid copying font bytes every frame. - // Key on (pointer, length, index) since the same slice is typically passed repeatedly. - use std::sync::{LazyLock, Mutex}; - static FONT_CACHE: LazyLock>> = - LazyLock::new(|| Mutex::new(None)); - - let ptr = font.bytes.as_ptr() as usize; - let len = font.bytes.len(); - if let Ok(cache) = FONT_CACHE.lock() { - if let Some((cp, cl, ci, ref data)) = *cache { - if cp == ptr && cl == len && ci == font.index { - return data.clone(); - } - } - } - - let data = FontData::new(Blob::new(Arc::new(font.bytes.to_vec())), font.index); - if let Ok(mut cache) = FONT_CACHE.lock() { - *cache = Some((ptr, len, font.index, data.clone())); - } - data - }); - - let font_ref: Option> = font_data.as_ref().and_then(|f| { - let file_ref = FileRef::new(f.data.as_ref()).ok()?; - match file_ref { - FileRef::Font(font) => Some(font), - FileRef::Collection(collection) => collection.get(f.index).ok(), - } - }); - - // Mask buffering state: collect primitives between MaskDefStart..MaskDefEnd - // so they can be replayed as a luminance mask when MaskApplyEnd is reached. - let mut capturing_mask = false; - let mut mask_def_primitives: Vec<&PaintPrimitive> = Vec::new(); - let mut mask_bounds: Option = None; - - for primitive in primitives { - // If we're capturing mask definition primitives, buffer them instead of rendering - if capturing_mask { - match primitive { - PaintPrimitive::MaskDefEnd { .. } => { - capturing_mask = false; - } - _ => { - mask_def_primitives.push(primitive); - } - } - continue; - } - - match primitive { - PaintPrimitive::LayerStart { - x, - y, - width, - height, - blend, - opacity, - .. - } => { - let (x, y, width, height, opacity) = (*x, *y, *width, *height, *opacity); - let clip_rect = Rect::new(x, y, x + width, y + height); - scene.push_layer( - Fill::NonZero, - peniko::BlendMode { - mix: to_peniko_mix(&blend), - compose: peniko::Compose::SrcOver, - }, - opacity as f32, - scene_transform, - &clip_rect, - ); - } - PaintPrimitive::LayerEnd { .. } => { - scene.pop_layer(); - } - PaintPrimitive::ClipStart { - x, - y, - width, - height, - corner_radii, - .. - } => { - let (x, y, width, height) = (*x, *y, *width, *height); - let rect = Rect::new(x, y, x + width, y + height); - if !corner_radii.is_zero() { - let rr = RoundedRect::from_rect( - rect, - kurbo::RoundedRectRadii::new( - corner_radii.top_left, - corner_radii.top_right, - corner_radii.bottom_right, - corner_radii.bottom_left, - ), - ); - scene.push_clip_layer(Fill::NonZero, scene_transform, &rr); - } else { - scene.push_clip_layer(Fill::NonZero, scene_transform, &rect); - } - } - PaintPrimitive::ClipEnd { .. } => { - scene.pop_layer(); - } - PaintPrimitive::MaskDefStart { - x, y, width, height, .. - } => { - capturing_mask = true; - mask_def_primitives.clear(); - mask_bounds = Some(Rect::new(*x, *y, *x + *width, *y + *height)); - } - PaintPrimitive::MaskDefEnd { .. } => { - // Handled above in the capturing_mask branch - } - PaintPrimitive::MaskApplyStart { - x, - y, - width, - height, - .. - } => { - let rect = Rect::new(*x, *y, *x + *width, *y + *height); - // Isolation layer — everything inside gets composited then masked - scene.push_layer(Fill::NonZero, peniko::BlendMode::default(), 1.0, scene_transform, &rect); - } - PaintPrimitive::MaskApplyEnd { .. } => { - // Apply the buffered mask primitives as a luminance mask - let clip = mask_bounds.unwrap_or(Rect::new(0.0, 0.0, 1e6, 1e6)); - scene.push_luminance_mask_layer(Fill::NonZero, 1.0, scene_transform, &clip); - // Replay mask definition primitives - if !mask_def_primitives.is_empty() { - render_mask_primitives( - scene, - &mask_def_primitives, - scene_transform, - text_font, - &font_data, - font_ref.as_ref(), - ); - } - scene.pop_layer(); // luminance mask - scene.pop_layer(); // isolation - mask_def_primitives.clear(); - mask_bounds = None; - } - PaintPrimitive::Rect { - x, - y, - width, - height, - fills, - stroke, - corner_radii, - effects, - blend: _, - rotation, - .. - } => { - let (x, y, width, height, rotation) = (*x, *y, *width, *height, *rotation); - let corner_radii = *corner_radii; - let (node_transform, lx, ly) = if rotation.abs() > 1e-6 { - let t = scene_transform - * kurbo::Affine::translate((x, y)) - * kurbo::Affine::rotate(rotation); - (t, 0.0, 0.0) - } else { - (scene_transform, x, y) - }; - let rect = Rect::new(lx, ly, lx + width, ly + height); - let uniform_corner = corner_radii.top_left; - let blur_color_hint = fills - .first() - .and_then(|(f, _)| first_color_from_fill(f)) - .or_else(|| stroke.as_ref().map(|s| s.color.clone())); - let has_layer_blur = effects.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur)); - for effect in effects { - if matches!(effect.kind, EffectKind::LayerBlur) { - paint_blurred_rect_content( - scene, - node_transform, - lx, - ly, - width, - height, - uniform_corner, - fills.first().map(|(f, _)| f), - stroke.as_ref(), - effect, - ); - } - } - for effect in effects { - if matches!(effect.kind, EffectKind::DropShadow) { - paint_effect( - scene, - node_transform, - &rect, - uniform_corner, - effect, - blur_color_hint.as_ref(), - ); - } - } - // When LayerBlur is active, the blurred rect IS the content — - // don't draw sharp fills/strokes on top. - if !has_layer_blur { - if !corner_radii.is_zero() { - let radii = kurbo::RoundedRectRadii::new( - corner_radii.top_left, - corner_radii.top_right, - corner_radii.bottom_right, - corner_radii.bottom_left, - ); - let stroke_rect = stroke_aligned_rect(rect, stroke.as_ref()); - let stroke_radius = - stroke_aligned_corner_radius(uniform_corner, stroke.as_ref()); - let rr = RoundedRect::from_rect(rect, radii); - for (fill, fill_blend) in fills { - let need_blend_layer = *fill_blend != BlendMix::Normal; - if need_blend_layer { - scene.push_layer( - Fill::NonZero, - peniko::BlendMode { - mix: to_peniko_mix(fill_blend), - compose: peniko::Compose::SrcOver, - }, - 1.0, - node_transform, - &rect, - ); - } - fill_shape(scene, node_transform, &rr, lx, ly, width, height, fill); - if need_blend_layer { - scene.pop_layer(); - } - } - if let Some(stroke) = stroke.as_ref() { - if stroke.width > 0.0 { - let stroke_style = to_kurbo_stroke(stroke, 1.0); - let stroke_rr = RoundedRect::from_rect(stroke_rect, stroke_radius); - scene.stroke( - &stroke_style, - node_transform, - to_peniko(stroke.color.clone()), - None, - &stroke_rr, - ); - } - } - } else { - for (fill, fill_blend) in fills { - let need_blend_layer = *fill_blend != BlendMix::Normal; - if need_blend_layer { - scene.push_layer( - Fill::NonZero, - peniko::BlendMode { - mix: to_peniko_mix(fill_blend), - compose: peniko::Compose::SrcOver, - }, - 1.0, - node_transform, - &rect, - ); - } - fill_shape(scene, node_transform, &rect, lx, ly, width, height, fill); - if need_blend_layer { - scene.pop_layer(); - } - } - if let Some(stroke) = stroke.as_ref() { - if stroke.width > 0.0 { - let stroke_style = to_kurbo_stroke(stroke, 1.0); - let stroke_rect = stroke_aligned_rect(rect, Some(stroke)); - scene.stroke( - &stroke_style, - node_transform, - to_peniko(stroke.color.clone()), - None, - &stroke_rect, - ); - } - } - } - } // !has_layer_blur - for effect in effects { - if matches!( - effect.kind, - EffectKind::InnerShadow | EffectKind::BackgroundBlur - ) { - paint_effect( - scene, - node_transform, - &rect, - uniform_corner, - effect, - blur_color_hint.as_ref(), - ); - } - } - } - PaintPrimitive::Path { - x: path_x, - y: path_y, - width, - height, - normalize_to_origin, - text_outline, - fill_paths, - stroke_paths, - svg_base64, - fills, - stroke, - effects, - blend: _, - rotation, - .. - } => { - let mut path_x = *path_x; - let mut path_y = *path_y; - let (width, height, rotation) = (*width, *height, *rotation); - if *text_outline { - path_x += TEXT_ANCHOR_SHIFT_X; - path_y += TEXT_ANCHOR_SHIFT_Y; - } - let (base_transform, lx, ly) = if rotation.abs() > 1e-6 { - let t = scene_transform - * kurbo::Affine::translate((path_x, path_y)) - * kurbo::Affine::rotate(rotation); - (t, 0.0, 0.0) - } else { - (scene_transform, path_x, path_y) - }; - let mut had_any_path = false; - let has_native_geometry = !fill_paths.is_empty() || !stroke_paths.is_empty(); - let bounds_rect = Rect::new(lx, ly, lx + width, ly + height); - let blur_color_hint = fills - .first() - .and_then(|(f, _)| first_color_from_fill(f)) - .or_else(|| stroke.as_ref().map(|s| s.color.clone())); - let path_origin_transform = base_transform * kurbo::Affine::translate((lx, ly)); - let path_fit = - compute_path_fit_transform( - fill_paths, - stroke_paths, - lx, - ly, - width, - height, - *normalize_to_origin, - ); - let path_transform = path_origin_transform * path_fit; - let (sx, sy) = extract_scale(path_fit); - let stroke_scale = ((sx.abs() + sy.abs()) * 0.5).max(0.01); - let decoded_svg_text: Option> = if has_native_geometry { - None - } else { - svg_base64.as_ref().and_then(|b64| cached_svg_decode(b64)) - }; - let has_layer_blur = effects.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur)); - - if let (Some(svg_text), Some(layer_blur)) = ( - decoded_svg_text.as_deref(), - effects - .iter() - .find(|e| matches!(e.kind, EffectKind::LayerBlur)), - ) { - paint_blurred_svg_content( - scene, - base_transform, - svg_text, - lx, - ly, - width, - height, - layer_blur, - ); - } - for effect in effects { - if matches!(effect.kind, EffectKind::LayerBlur) { - paint_blurred_path_content( - scene, - path_transform, - fill_paths, - stroke_paths, - fills.first().map(|(f, _)| f), - stroke.as_ref(), - stroke_scale, - lx, - ly, - width, - height, - effect, - ); - } - } - for effect in effects { - if matches!(effect.kind, EffectKind::DropShadow) { - paint_effect( - scene, - base_transform, - &bounds_rect, - 0.0, - effect, - blur_color_hint.as_ref(), - ); - } - } - if !has_layer_blur { - if let Some(svg_text) = decoded_svg_text.as_deref() { - let svg_fit = cached_svg_fit_transform(svg_text, width, height); - let svg_transform = path_origin_transform * svg_fit; - if let Some(tree) = cached_svg_tree(svg_text) { - { - let mut painter = anyrender_vello::VelloScenePainter::new(scene); - anyrender_svg::render_svg_tree(&mut painter, &tree, svg_transform); - } - had_any_path = true; - } - } - } // !has_layer_blur for SVG - - if !had_any_path && !has_layer_blur { - for (fill_paint, fill_blend) in fills { - let need_blend_layer = *fill_blend != BlendMix::Normal; - if need_blend_layer { - scene.push_layer( - Fill::NonZero, - peniko::BlendMode { - mix: to_peniko_mix(fill_blend), - compose: peniko::Compose::SrcOver, - }, - 1.0, - base_transform, - &bounds_rect, - ); - } - for path_data in fill_paths { - if let Some(path) = cached_bez_path(path_data) { - fill_path_shape( - scene, - path_transform, - &*path, - lx, - ly, - width, - height, - fill_paint, - ); - had_any_path = true; - } - } - if need_blend_layer { - scene.pop_layer(); - } - } - - if let Some(stroke) = stroke.as_ref() { - if stroke.width > 0.0 { - for path_data in stroke_paths { - if let Some(path) = cached_bez_path(path_data) { - // Figma strokeGeometry is already the outlined stroke shape. - // Fill it directly to avoid double-stroking artifacts. - scene.fill( - peniko::Fill::NonZero, - path_transform, - to_peniko(stroke.color.clone()), - None, - &*path, - ); - had_any_path = true; - } - } - } - } - } - - if !had_any_path { - let rect = Rect::new(lx, ly, lx + width, ly + height); - for (fill, fill_blend) in fills { - let need_blend_layer = *fill_blend != BlendMix::Normal; - if need_blend_layer { - scene.push_layer( - Fill::NonZero, - peniko::BlendMode { - mix: to_peniko_mix(fill_blend), - compose: peniko::Compose::SrcOver, - }, - 1.0, - base_transform, - &rect, - ); - } - fill_shape(scene, base_transform, &rect, lx, ly, width, height, fill); - if need_blend_layer { - scene.pop_layer(); - } - } - if let Some(stroke) = stroke.as_ref() { - if stroke.width > 0.0 { - let stroke_style = to_kurbo_stroke(stroke, 1.0); - scene.stroke( - &stroke_style, - base_transform, - to_peniko(stroke.color.clone()), - None, - &rect, - ); - } - } - } - for effect in effects { - if matches!( - effect.kind, - EffectKind::InnerShadow | EffectKind::BackgroundBlur - ) { - paint_effect( - scene, - base_transform, - &bounds_rect, - 0.0, - effect, - blur_color_hint.as_ref(), - ); - } - } - } - PaintPrimitive::Text { - x, - y, - text, - color, - font_size, - line_height, - letter_spacing, - text_case, - style_runs, - blend: _, - .. - } => { - let (x, y, font_size, letter_spacing) = ( - *x + TEXT_ANCHOR_SHIFT_X, - *y + TEXT_ANCHOR_SHIFT_Y, - *font_size, - *letter_spacing, - ); - let text = apply_text_case(text, text_case); - let line_height = line_height.unwrap_or(font_size * 1.2).max(1.0); - let max_line_chars = text.lines().map(|l| l.chars().count()).max().unwrap_or(0); - let approx_width = - (max_line_chars as f64 * (font_size * 0.6 + letter_spacing)).max(font_size); - let line_count = text.lines().count().max(1) as f64; - let _bounds_rect = Rect::new(x, y, x + approx_width, y + line_count * line_height); - if let (Some(font_data), Some(font_ref)) = (&font_data, font_ref.as_ref()) { - let runs = if style_runs.is_empty() { - vec![TextStyleRun { - text: text.clone(), - color: Some(color.clone()), - font_size: Some(font_size), - letter_spacing: Some(letter_spacing), - font_style: None, - font_weight: None, - }] - } else { - style_runs - .iter() - .cloned() - .map(|mut run| { - run.text = apply_text_case(&run.text, text_case); - run - }) - .collect::>() - }; - - let mut line_idx: usize = 0; - let mut pen_x: f64 = 0.0; - let charmap = font_ref.charmap(); - for run in runs { - let run_size = run.font_size.unwrap_or(font_size).max(1.0); - let run_letter_spacing = run.letter_spacing.unwrap_or(letter_spacing) as f32; - let run_color = run.color.clone().unwrap_or_else(|| color.clone()); - let style_str = run - .font_style - .as_deref() - .map(|s| s.to_ascii_lowercase()) - .unwrap_or_default(); - let is_bold = run.font_weight.unwrap_or(400.0) >= 600.0 - || style_str.contains("bold"); - let is_italic = style_str.contains("italic"); - - let size = Size::new(run_size as f32); - let axes = font_ref.axes(); - let var_loc = axes.location(std::iter::empty::<(&str, f32)>()); - let metrics = font_ref.metrics(size, &var_loc); - let glyph_metrics = font_ref.glyph_metrics(size, &var_loc); - let ascent = f64::from(metrics.ascent); - - for ch in run.text.chars() { - if ch == '\n' { - line_idx += 1; - pen_x = 0.0; - continue; - } - let gid = charmap.map(ch).unwrap_or_default(); - let advance = f64::from(glyph_metrics.advance_width(gid).unwrap_or_default()); - let base_transform = scene_transform - * kurbo::Affine::translate(( - x + pen_x, - y + ascent + (line_idx as f64 * line_height), - )); - let text_transform = if is_italic { - base_transform * kurbo::Affine::new([1.0, 0.0, 0.22, 1.0, 0.0, 0.0]) - } else { - base_transform - }; - - scene - .draw_glyphs(font_data) - .font_size(run_size as f32) - .hint(true) - .brush(to_peniko(run_color.clone())) - .brush_alpha(1.0) - .transform(text_transform) - .draw( - Fill::NonZero, - std::iter::once(Glyph { - id: gid.to_u32(), - x: 0.0, - y: 0.0, - }), - ); - - if is_bold { - scene - .draw_glyphs(font_data) - .font_size(run_size as f32) - .hint(true) - .brush(to_peniko(run_color.clone())) - .brush_alpha(1.0) - .transform(text_transform * kurbo::Affine::translate((0.45, 0.0))) - .draw( - Fill::NonZero, - std::iter::once(Glyph { - id: gid.to_u32(), - x: 0.0, - y: 0.0, - }), - ); - } - - pen_x += advance + f64::from(run_letter_spacing); - } - } - } else { - // fallback to a baseline block when no text font is configured - for (line_idx, line_text) in text.lines().enumerate() { - let width = (line_text.chars().count() as f64 - * (font_size * 0.5 + letter_spacing)) - .max(font_size); - let y_line = y + (line_idx as f64 * line_height); - let line = Rect::new( - x, - y_line + font_size * 0.85, - x + width, - y_line + font_size * 0.95, - ); - scene.fill( - Fill::NonZero, - scene_transform, - to_peniko(color.clone()), - None, - &line, - ); - } - } - } - } - } - - fn fill_shape( - scene: &mut vello::Scene, - scene_transform: kurbo::Affine, - shape: &impl kurbo::Shape, - x: f64, - y: f64, - width: f64, - height: f64, - fill: &FillPaint, - ) { - use peniko::Fill; - - match fill { - FillPaint::Solid(color) => { - scene.fill( - Fill::NonZero, - scene_transform, - to_peniko(color.clone()), - None, - shape, - ); - } - FillPaint::GradientLinear { transform, stops } => { - if let Some(gradient) = - build_linear_gradient(x, y, width, height, transform.as_ref(), stops) - { - scene.fill(Fill::NonZero, scene_transform, &gradient, None, shape); - } - } - FillPaint::GradientRadial { transform, stops } => { - if let Some((gradient, brush_xform)) = - build_radial_gradient(x, y, width, height, transform.as_ref(), stops) - { - scene.fill( - Fill::NonZero, - scene_transform, - &gradient, - brush_xform, - shape, - ); - } - } - FillPaint::GradientAngular { transform, stops } => { - if let Some(gradient) = - build_angular_gradient(x, y, width, height, transform.as_ref(), stops) - { - scene.fill(Fill::NonZero, scene_transform, &gradient, None, shape); - } - } - FillPaint::Image { - image_hash: _, - data_base64, - alpha, - scale_mode, - image_transform, - } => { - if let Some(image_brush) = decode_image_brush(data_base64, *alpha, width, height) { - let iw = image_brush.image.width as f64; - let ih = image_brush.image.height as f64; - if iw > 0.0 && ih > 0.0 { - let brush_transform = image_brush_transform( - *scale_mode, - image_transform, - x, - y, - width, - height, - iw, - ih, - ); - scene.fill( - Fill::NonZero, - scene_transform, - image_brush.as_ref(), - Some(brush_transform), - shape, - ); - } - } - } - } - } - - fn paint_effect( - scene: &mut vello::Scene, - scene_transform: kurbo::Affine, - base_rect: &Rect, - corner_radius: f64, - effect: &NodeEffect, - blur_color_hint: Option<&Rgba>, - ) { - match effect.kind { - EffectKind::DropShadow => { - // Spread expands the shadow rect outward in all directions - let shadow_rect = Rect::new( - base_rect.x0 + effect.offset_x - effect.spread, - base_rect.y0 + effect.offset_y - effect.spread, - base_rect.x1 + effect.offset_x + effect.spread, - base_rect.y1 + effect.offset_y + effect.spread, - ); - // Corner radius grows additively with spread - let shadow_corner = (corner_radius + effect.spread).max(0.0); - // Figma radius is CSS blur radius (≈ 2×sigma); draw_box_shadow expects sigma - scene.draw_blurred_rounded_rect( - scene_transform, - shadow_rect, - to_peniko(effect.color.clone()), - shadow_corner, - (effect.radius * 0.5).max(0.0), - ); - } - EffectKind::InnerShadow => { - // Inner shadow: fill element with shadow color, then punch a - // blurred hole at the offset position using DestOut compositing. - // (Pattern from Blitz reference implementation.) - let rr = RoundedRect::from_rect(*base_rect, corner_radius); - - // Layer 1: isolate inner shadow compositing group - scene.push_layer(Fill::NonZero, peniko::Mix::Normal, 1.0, scene_transform, &rr); - - // Fill entire clipped area with shadow color - scene.fill( - peniko::Fill::NonZero, - scene_transform, - to_peniko(effect.color.clone()), - None, - &rr, - ); - - // Layer 2: DestOut — punch a blurred hole at offset position - scene.push_layer(Fill::NonZero, peniko::Compose::DestOut, 1.0, scene_transform, &rr); - - // Spread shrinks the hole (opposite of drop shadow) - let hole_rect = Rect::new( - base_rect.x0 + effect.offset_x + effect.spread, - base_rect.y0 + effect.offset_y + effect.spread, - base_rect.x1 + effect.offset_x - effect.spread, - base_rect.y1 + effect.offset_y - effect.spread, - ); - let hole_corner = (corner_radius - effect.spread).max(0.0); - // Figma radius is CSS blur radius (≈ 2×sigma) - scene.draw_blurred_rounded_rect( - scene_transform, - hole_rect, - to_peniko(Rgba { - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - }), - hole_corner, - (effect.radius * 0.5).max(0.0), - ); - - scene.pop_layer(); // DestOut - scene.pop_layer(); // isolation - } - EffectKind::LayerBlur => { - // Single GPU-native Gaussian blur of the bounding rect. - let sigma = (effect.radius * 0.5).max(0.5); - let hint = blur_color_hint.cloned().unwrap_or(Rgba { - r: 0.75, - g: 0.78, - b: 0.82, - a: 1.0, - }); - scene.draw_blurred_rounded_rect( - scene_transform, - *base_rect, - to_peniko(hint), - corner_radius.max(effect.spread), - sigma, - ); - } - EffectKind::BackgroundBlur => { - // Single blurred rect approximation for backdrop blur. - // True background blur requires backdrop sampling which vello doesn't support. - let sigma = (effect.radius * 0.5).max(0.5); - let hint_color = blur_color_hint.cloned().unwrap_or(Rgba { - r: 1.0, - g: 1.0, - b: 1.0, - a: 0.3, - }); - scene.draw_blurred_rounded_rect( - scene_transform, - *base_rect, - to_peniko(hint_color), - corner_radius.max(effect.spread), - sigma, - ); - } - } - } - - fn paint_blurred_rect_content( - scene: &mut vello::Scene, - scene_transform: kurbo::Affine, - x: f64, - y: f64, - width: f64, - height: f64, - corner_radius: f64, - fill: Option<&FillPaint>, - stroke: Option<&StrokeStyle>, - effect: &NodeEffect, - ) { - let sigma = (effect.radius * 0.5).max(0.5); - let rect = Rect::new(x, y, x + width, y + height); - - // For solid fills, use vello's GPU-native Gaussian blur on the rectangle. - // This produces a proper blur rather than shifted semi-transparent copies. - if let Some(fill) = fill { - match fill { - FillPaint::Solid(color) => { - scene.draw_blurred_rounded_rect( - scene_transform, - rect, - to_peniko(color.clone()), - corner_radius, - sigma, - ); - } - // For gradient/image fills, fall back to shifted copies - _ => { - for (dx, dy, weight) in layer_blur_kernel(effect.radius.max(0.5)) { - let shifted_transform = - scene_transform * kurbo::Affine::translate((dx, dy)); - if corner_radius > 0.0 { - let rr = RoundedRect::from_rect(rect, corner_radius); - let f = fill.clone().with_opacity(weight); - fill_shape(scene, shifted_transform, &rr, x, y, width, height, &f); - } else { - let f = fill.clone().with_opacity(weight); - fill_shape(scene, shifted_transform, &rect, x, y, width, height, &f); - } - } - } - } - } - // Stroke blur: single blurred rect with stroke color - if let Some(stroke) = stroke { - if stroke.width > 0.0 { - let stroke_rect = stroke_aligned_rect(rect, Some(stroke)); - let stroke_radius = stroke_aligned_corner_radius(corner_radius, Some(stroke)); - scene.draw_blurred_rounded_rect( - scene_transform, - stroke_rect, - to_peniko(stroke.color.clone()), - stroke_radius, - sigma, - ); - } - } - } - - fn paint_blurred_path_content( - scene: &mut vello::Scene, - path_transform: kurbo::Affine, - fill_paths: &[String], - stroke_paths: &[String], - fill: Option<&FillPaint>, - stroke: Option<&StrokeStyle>, - stroke_scale: f64, - x: f64, - y: f64, - width: f64, - height: f64, - effect: &NodeEffect, - ) { - let radius = effect.radius.max(0.5); - for (dx, dy, weight) in layer_blur_kernel(radius) { - let shifted = kurbo::Affine::translate((dx, dy)) * path_transform; - if let Some(fill_paint) = fill.cloned().map(|f| f.with_opacity(weight)) { - for path_data in fill_paths { - if let Some(path) = cached_bez_path(path_data) { - fill_path_shape(scene, shifted, &*path, x, y, width, height, &fill_paint); - } - } - } - if let Some(stroke) = stroke { - if stroke.width > 0.0 { - let mut stroke_blur = stroke.clone(); - stroke_blur.color.a = (stroke_blur.color.a * weight).clamp(0.0, 1.0); - let stroke_style = to_kurbo_stroke(&stroke_blur, stroke_scale); - for path_data in stroke_paths { - if let Some(path) = cached_bez_path(path_data) { - scene.stroke( - &stroke_style, - shifted, - to_peniko(stroke_blur.color.clone()), - None, - &*path, - ); - } - } - } - } - } - } - - fn paint_blurred_svg_content( - scene: &mut vello::Scene, - scene_transform: kurbo::Affine, - svg_text: &str, - x: f64, - y: f64, - width: f64, - height: f64, - effect: &NodeEffect, - ) { - let radius = effect.radius.max(0.5); - let clip_rect = Rect::new(x, y, x + width, y + height); - let svg_fit = cached_svg_fit_transform(svg_text, width, height); - // Cache the parsed SVG tree once — blur kernel loops would otherwise - // re-parse the XML 6-20 times per blurred element per frame. - let Some(tree) = cached_svg_tree(svg_text) else { - return; - }; - for (dx, dy, weight) in layer_blur_kernel(radius) { - let svg_transform = - scene_transform * kurbo::Affine::translate((x + dx, y + dy)) * svg_fit; - scene.push_layer( - Fill::NonZero, - peniko::BlendMode { - mix: peniko::Mix::Normal, - compose: peniko::Compose::SrcOver, - }, - weight as f32, - scene_transform, - &clip_rect, - ); - { - let mut painter = anyrender_vello::VelloScenePainter::new(scene); - anyrender_svg::render_svg_tree(&mut painter, &tree, svg_transform); - } - scene.pop_layer(); - } - } - - fn fill_path_shape( - scene: &mut vello::Scene, - path_transform: kurbo::Affine, - path: &BezPath, - x: f64, - y: f64, - width: f64, - height: f64, - fill: &FillPaint, - ) { - use peniko::Fill; - - // Gradient coordinates must be in the same space as the path shape. - // fillGeometry paths are in local coords (0,0 origin); path_transform - // maps them to screen space. Build gradients at (0,0) so they align. - match fill { - FillPaint::Solid(color) => { - scene.fill( - Fill::NonZero, - path_transform, - to_peniko(color.clone()), - None, - path, - ); - } - FillPaint::GradientLinear { transform, stops } => { - if let Some(gradient) = - build_linear_gradient(0.0, 0.0, width, height, transform.as_ref(), stops) - { - scene.fill(Fill::NonZero, path_transform, &gradient, None, path); - } - } - FillPaint::GradientRadial { transform, stops } => { - if let Some((gradient, brush_xform)) = - build_radial_gradient(0.0, 0.0, width, height, transform.as_ref(), stops) - { - scene.fill(Fill::NonZero, path_transform, &gradient, brush_xform, path); - } - } - FillPaint::GradientAngular { transform, stops } => { - if let Some(gradient) = - build_angular_gradient(0.0, 0.0, width, height, transform.as_ref(), stops) - { - scene.fill(Fill::NonZero, path_transform, &gradient, None, path); - } - } - FillPaint::Image { - image_hash: _, - data_base64, - alpha, - scale_mode, - image_transform, - } => { - if let Some(image_brush) = decode_image_brush(data_base64, *alpha, width, height) { - let iw = image_brush.image.width as f64; - let ih = image_brush.image.height as f64; - if iw > 0.0 && ih > 0.0 { - // Use local-space origin (0, 0) — same as gradient fix above - let brush_transform = image_brush_transform( - *scale_mode, - image_transform, - 0.0, - 0.0, - width, - height, - iw, - ih, - ); - scene.fill( - Fill::NonZero, - path_transform, - image_brush.as_ref(), - Some(brush_transform), - path, - ); - } - } - } - } - } - - fn image_brush_transform( - scale_mode: ImageScaleMode, - image_transform: &Option, - x: f64, - y: f64, - width: f64, - height: f64, - iw: f64, - ih: f64, - ) -> kurbo::Affine { - match scale_mode { - ImageScaleMode::Fill => { - // Scale uniformly to cover the entire node, center the image - let scale = if width > 0.0 && height > 0.0 { - (width / iw).max(height / ih) - } else { - 1.0 - }; - let offset_x = x + (width - iw * scale) * 0.5; - let offset_y = y + (height - ih * scale) * 0.5; - kurbo::Affine::translate((offset_x, offset_y)) * kurbo::Affine::scale(scale) - } - ImageScaleMode::Fit => { - // Scale uniformly to fit inside the node, center the image - let scale = if width > 0.0 && height > 0.0 { - (width / iw).min(height / ih) - } else { - 1.0 - }; - let offset_x = x + (width - iw * scale) * 0.5; - let offset_y = y + (height - ih * scale) * 0.5; - kurbo::Affine::translate((offset_x, offset_y)) * kurbo::Affine::scale(scale) - } - ImageScaleMode::Crop => { - // Figma imageTransform maps node-normalized [0,1]×[0,1] to - // image-normalized [0,1]×[0,1]. The brush transform maps image - // pixels → scene pixels, so we need the INVERSE of imageTransform: - // translate(node_origin) * scale(node_size) * inv(T) * scale(1/img_size) - if let Some(t) = image_transform { - let det = t.determinant(); - if det.abs() < 1e-12 { - let scale = (width / iw).max(height / ih); - let ox = x + (width - iw * scale) * 0.5; - let oy = y + (height - ih * scale) * 0.5; - return kurbo::Affine::translate((ox, oy)) * kurbo::Affine::scale(scale); - } - // Invert the 2x3 affine: node-norm → image-norm becomes - // image-norm → node-norm - let inv = kurbo::Affine::new([ - t.d / det, - -t.c / det, - -t.b / det, - t.a / det, - (t.b * t.ty - t.d * t.tx) / det, - (t.c * t.tx - t.a * t.ty) / det, - ]); - kurbo::Affine::translate((x, y)) - * kurbo::Affine::scale_non_uniform(width, height) - * inv - * kurbo::Affine::scale_non_uniform(1.0 / iw, 1.0 / ih) - } else { - // No transform — fall back to Fill - let scale = (width / iw).max(height / ih); - let ox = x + (width - iw * scale) * 0.5; - let oy = y + (height - ih * scale) * 0.5; - kurbo::Affine::translate((ox, oy)) * kurbo::Affine::scale(scale) - } - } - ImageScaleMode::Tile => { - // Simple 1:1 tiling from the node origin (Vello Pad extend handles the rest) - kurbo::Affine::translate((x, y)) - } - ImageScaleMode::Stretch => { - // Non-uniform stretch to fill node exactly - let sx = if width > 0.0 { width / iw } else { 1.0 }; - let sy = if height > 0.0 { height / ih } else { 1.0 }; - kurbo::Affine::translate((x, y)) * kurbo::Affine::scale_non_uniform(sx, sy) - } - } - } - - fn layer_blur_kernel(radius: f64) -> Vec<(f64, f64, f64)> { - let sigma = (radius * 0.5).max(0.5); - // Cap the integer grid to at most 7x7 = 49 samples to avoid - // overwhelming the GPU with draw calls per blurred element. - let half_grid: i32 = 3; // grid goes -3..+3 → 7 taps per axis - // Step size spreads the grid to cover 2.5σ spatially - let step = (sigma * 2.5) / half_grid as f64; - let mut kernel = Vec::new(); - let mut total_weight = 0.0; - - for iy in -half_grid..=half_grid { - for ix in -half_grid..=half_grid { - let dx = ix as f64 * step; - let dy = iy as f64 * step; - let w = (-0.5 * (dx * dx + dy * dy) / (sigma * sigma)).exp(); - kernel.push((dx, dy, w)); - total_weight += w; - } - } - - // Normalize weights to sum to 1.0 - for entry in &mut kernel { - entry.2 /= total_weight; - } - kernel - } - - fn build_linear_gradient( - x: f64, - y: f64, - width: f64, - height: f64, - transform: Option<&AffineTransform2D>, - stops: &[GradientStop], - ) -> Option { - use kurbo::Point; - - if stops.is_empty() || width <= 0.0 || height <= 0.0 { - return None; - } - // Figma linear gradient runs from UV (0, 0.5) to (1, 0.5) - let (snx, sny) = gradient_transform_point(transform, 0.0, 0.5); - let (enx, eny) = gradient_transform_point(transform, 1.0, 0.5); - let start = Point::new(x + snx * width, y + sny * height); - let end = Point::new(x + enx * width, y + eny * height); - Some(peniko::Gradient::new_linear(start, end).with_stops(to_peniko_stops(stops).as_slice())) - } - - fn build_radial_gradient( - x: f64, - y: f64, - width: f64, - height: f64, - transform: Option<&AffineTransform2D>, - stops: &[GradientStop], - ) -> Option<(peniko::Gradient, Option)> { - use kurbo::Point; - - if stops.is_empty() || width <= 0.0 || height <= 0.0 { - return None; - } - // Figma radial center is at UV (0.5, 0.5) - let (cnx, cny) = gradient_transform_point(transform, 0.5, 0.5); - let center = Point::new(x + cnx * width, y + cny * height); - let peniko_stops = to_peniko_stops(stops); - - if let Some(t) = transform { - // Ellipse axes from transform column vectors scaled to pixel space - let ax = (t.a * width, t.c * height); - let ay = (t.b * width, t.d * height); - let rx = (ax.0 * ax.0 + ax.1 * ax.1).sqrt() * 0.5; - let ry = (ay.0 * ay.0 + ay.1 * ay.1).sqrt() * 0.5; - let angle = ax.1.atan2(ax.0); - - // Unit circle gradient at origin; brush_transform maps to ellipse - let gradient = - peniko::Gradient::new_two_point_radial(Point::ZERO, 0.0, Point::ZERO, 1.0) - .with_stops(peniko_stops.as_slice()); - - let brush_xform = kurbo::Affine::translate(center.to_vec2()) - * kurbo::Affine::rotate(angle) - * kurbo::Affine::scale_non_uniform(rx.max(0.5), ry.max(0.5)); - - Some((gradient, Some(brush_xform))) - } else { - let radius = (width.min(height) * 0.5).max(0.5) as f32; - let gradient = peniko::Gradient::new_two_point_radial(center, 0.0, center, radius) - .with_stops(peniko_stops.as_slice()); - Some((gradient, None)) - } - } - - fn build_angular_gradient( - x: f64, - y: f64, - width: f64, - height: f64, - transform: Option<&AffineTransform2D>, - stops: &[GradientStop], - ) -> Option { - use kurbo::Point; - - if stops.is_empty() || width <= 0.0 || height <= 0.0 { - return None; - } - // Figma angular/sweep center is at UV (0.5, 0.5) - let (cnx, cny) = gradient_transform_point(transform, 0.5, 0.5); - let center = Point::new(x + cnx * width, y + cny * height); - // Extract rotation angle from transform's first column - let start_angle = if let Some(t) = transform { - (t.c * height).atan2(t.a * width) as f32 - } else { - 0.0 - }; - let end_angle = start_angle + std::f32::consts::PI * 2.0; - Some( - peniko::Gradient::new_sweep(center, start_angle, end_angle) - .with_stops(to_peniko_stops(stops).as_slice()), - ) - } - - fn gradient_transform_point(transform: Option<&AffineTransform2D>, u: f64, v: f64) -> (f64, f64) { - if let Some(t) = transform { - t.transform_point(u, v) - } else { - (u, v) - } - } - - fn to_peniko_stops(stops: &[GradientStop]) -> Vec { - use peniko::color::DynamicColor; - - stops - .iter() - .map(|stop| peniko::ColorStop { - offset: stop.offset as f32, - color: DynamicColor::from_alpha_color(to_peniko(stop.color.clone())), - }) - .collect() - } - - fn to_kurbo_stroke(style: &StrokeStyle, width_scale: f64) -> Stroke { - let cap = match style.cap { - StrokeCap::None => kurbo::Cap::Butt, - StrokeCap::Round => kurbo::Cap::Round, - StrokeCap::Square => kurbo::Cap::Square, - StrokeCap::LineArrow - | StrokeCap::TriangleArrow - | StrokeCap::DiamondFilled - | StrokeCap::CircleFilled - | StrokeCap::TriangleFilled - | StrokeCap::WashiTape1 - | StrokeCap::WashiTape2 - | StrokeCap::WashiTape3 - | StrokeCap::WashiTape4 - | StrokeCap::WashiTape5 - | StrokeCap::WashiTape6 => kurbo::Cap::Butt, - }; - let join = match style.join { - StrokeJoin::Miter => kurbo::Join::Miter, - StrokeJoin::Round => kurbo::Join::Round, - StrokeJoin::Bevel => kurbo::Join::Bevel, - }; - let mut stroke = Stroke::new((style.width * width_scale).max(0.0)) - .with_caps(cap) - .with_join(join) - .with_miter_limit(style.miter_limit.max(1.0)); - if !style.dash_pattern.is_empty() { - stroke = stroke.with_dashes( - style.dash_offset * width_scale, - style - .dash_pattern - .iter() - .copied() - .map(|v| (v * width_scale).max(0.0)), - ); - } - stroke - } - - fn to_peniko_mix(blend: &BlendMix) -> peniko::Mix { - match blend { - BlendMix::Normal => peniko::Mix::Normal, - BlendMix::Multiply => peniko::Mix::Multiply, - BlendMix::Screen => peniko::Mix::Screen, - BlendMix::Overlay => peniko::Mix::Overlay, - BlendMix::Darken => peniko::Mix::Darken, - BlendMix::Lighten => peniko::Mix::Lighten, - BlendMix::ColorDodge => peniko::Mix::ColorDodge, - BlendMix::ColorBurn => peniko::Mix::ColorBurn, - BlendMix::HardLight => peniko::Mix::HardLight, - BlendMix::SoftLight => peniko::Mix::SoftLight, - BlendMix::Difference => peniko::Mix::Difference, - BlendMix::Exclusion => peniko::Mix::Exclusion, - BlendMix::Hue => peniko::Mix::Hue, - BlendMix::Saturation => peniko::Mix::Saturation, - BlendMix::Color => peniko::Mix::Color, - BlendMix::Luminosity => peniko::Mix::Luminosity, - } - } - - fn stroke_aligned_rect(base: Rect, style: Option<&StrokeStyle>) -> Rect { - let Some(style) = style else { - return base; - }; - let half = (style.width * 0.5).max(0.0); - let snap_half = |v: f64| ((v * 2.0).round()) * 0.5; - match style.align { - StrokeAlign::Inside => Rect::new( - base.x0 + half, - base.y0 + half, - base.x1 - half, - base.y1 - half, - ), - StrokeAlign::Outside => Rect::new( - base.x0 - half, - base.y0 - half, - base.x1 + half, - base.y1 + half, - ), - StrokeAlign::Center => Rect::new( - snap_half(base.x0), - snap_half(base.y0), - snap_half(base.x1), - snap_half(base.y1), - ), - } - } - - fn stroke_aligned_corner_radius(base: f64, style: Option<&StrokeStyle>) -> f64 { - let Some(style) = style else { - return base.max(0.0); - }; - let half = (style.width * 0.5).max(0.0); - match style.align { - StrokeAlign::Inside => (base - half).max(0.0), - StrokeAlign::Outside => (base + half).max(0.0), - StrokeAlign::Center => base.max(0.0), - } - } - - fn decode_image_brush( - data_base64: &str, - alpha: f64, - display_w: f64, - display_h: f64, - ) -> Option { - use std::collections::HashMap; - use std::hash::{Hash, Hasher}; - use std::sync::{LazyLock, Mutex}; - - static CACHE: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - - // Include display dimensions in cache key (rounded to nearest 64px to avoid - // excessive cache entries from sub-pixel differences) - let bucket_w = ((display_w / 64.0).ceil() as u32).max(1) * 64; - let bucket_h = ((display_h / 64.0).ceil() as u32).max(1) * 64; - - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - data_base64.hash(&mut hasher); - alpha.to_bits().hash(&mut hasher); - bucket_w.hash(&mut hasher); - bucket_h.hash(&mut hasher); - let cache_key = hasher.finish(); - - if let Ok(cache) = CACHE.lock() { - if let Some(cached) = cache.get(&cache_key) { - return Some(cached.clone()); - } - } - - let bytes = base64::engine::general_purpose::STANDARD - .decode(data_base64) - .ok()?; - let decoded = image::load_from_memory(&bytes).ok()?.into_rgba8(); - let (orig_w, orig_h) = (decoded.width(), decoded.height()); - - // Downscale to 2x display size (for retina quality) if the source image - // is significantly larger than the display area. This avoids uploading - // multi-megabyte textures to the GPU when only a fraction of pixels are visible. - let max_w = (display_w * 2.0).ceil() as u32; - let max_h = (display_h * 2.0).ceil() as u32; - let (final_img, width, height) = if orig_w > max_w.max(64) || orig_h > max_h.max(64) { - let scale = (max_w as f64 / orig_w as f64).min(max_h as f64 / orig_h as f64); - let new_w = ((orig_w as f64 * scale).round() as u32).max(1); - let new_h = ((orig_h as f64 * scale).round() as u32).max(1); - let resized = image::imageops::resize( - &decoded, - new_w, - new_h, - image::imageops::FilterType::Triangle, - ); - let (w, h) = (resized.width(), resized.height()); - (resized.into_vec(), w, h) - } else { - (decoded.into_vec(), orig_w, orig_h) - }; - - let brush = ImageBrush { - image: ImageData { - data: Blob::new(Arc::new(final_img)), - format: peniko::ImageFormat::Rgba8, - alpha_type: peniko::ImageAlphaType::Alpha, - width, - height, - }, - sampler: ImageSampler { - x_extend: peniko::Extend::Pad, - y_extend: peniko::Extend::Pad, - quality: peniko::ImageQuality::Medium, - alpha: alpha as f32, - }, - }; - - if let Ok(mut cache) = CACHE.lock() { - cache.insert(cache_key, brush.clone()); - } - - Some(brush) - } - - /// Render buffered mask-definition primitives into the scene. - /// These are the shape primitives captured between MaskDefStart..MaskDefEnd. - fn render_mask_primitives( - scene: &mut vello::Scene, - prims: &[&PaintPrimitive], - scene_transform: kurbo::Affine, - _text_font: Option>, - _font_data: &Option, - _font_ref: Option<&FontRef<'_>>, - ) { - for prim in prims { - match prim { - PaintPrimitive::Rect { - x, y, width, height, fills, corner_radii, rotation, .. - } => { - let (x, y, width, height, rotation) = (*x, *y, *width, *height, *rotation); - let (node_transform, lx, ly) = if rotation.abs() > 1e-6 { - let t = scene_transform - * kurbo::Affine::translate((x, y)) - * kurbo::Affine::rotate(rotation); - (t, 0.0, 0.0) - } else { - (scene_transform, x, y) - }; - let rect = Rect::new(lx, ly, lx + width, ly + height); - if !corner_radii.is_zero() { - let radii = kurbo::RoundedRectRadii::new( - corner_radii.top_left, - corner_radii.top_right, - corner_radii.bottom_right, - corner_radii.bottom_left, - ); - let rr = RoundedRect::from_rect(rect, radii); - for (fill, _) in fills { - fill_shape(scene, node_transform, &rr, lx, ly, width, height, fill); - } - } else { - for (fill, _) in fills { - fill_shape(scene, node_transform, &rect, lx, ly, width, height, fill); - } - } - } - PaintPrimitive::Path { - x: path_x, y: path_y, width, height, fill_paths, fills, rotation, - normalize_to_origin, stroke_paths, .. - } => { - let (width, height, rotation) = (*width, *height, *rotation); - let (base_transform, lx, ly) = if rotation.abs() > 1e-6 { - let t = scene_transform - * kurbo::Affine::translate((*path_x, *path_y)) - * kurbo::Affine::rotate(rotation); - (t, 0.0, 0.0) - } else { - (scene_transform, *path_x, *path_y) - }; - let path_origin_transform = base_transform * kurbo::Affine::translate((lx, ly)); - let path_fit = compute_path_fit_transform( - fill_paths, stroke_paths, lx, ly, width, height, *normalize_to_origin, - ); - let path_transform = path_origin_transform * path_fit; - for (fill_paint, _) in fills { - for path_data in fill_paths { - if let Some(path) = cached_bez_path(path_data) { - fill_path_shape(scene, path_transform, &*path, lx, ly, width, height, fill_paint); - } - } - } - } - _ => { - // Other primitive types in mask defs are uncommon; skip them. - } - } - } - } - - fn apply_text_case(text: &str, text_case: &TextCase) -> String { - match text_case { - TextCase::Original => text.to_string(), - TextCase::Upper | TextCase::SmallCaps | TextCase::SmallCapsForced => { - text.to_uppercase() - } - TextCase::Lower => text.to_lowercase(), - TextCase::Title => { - let mut out = String::with_capacity(text.len()); - let mut new_word = true; - for ch in text.chars() { - if ch.is_whitespace() { - new_word = true; - out.push(ch); - } else if new_word { - out.extend(ch.to_uppercase()); - new_word = false; - } else { - out.push(ch); - } - } - out - } - } - } -} diff --git a/crates/frame-ui/src/anyrender_renderer/tests.rs b/crates/frame-ui/src/anyrender_renderer/tests.rs deleted file mode 100644 index f8d8c3b9..00000000 --- a/crates/frame-ui/src/anyrender_renderer/tests.rs +++ /dev/null @@ -1,398 +0,0 @@ -use super::*; -use frame_proto::FrameDocument; - -#[test] -fn parses_stroke_style_with_dash_and_caps() { - use super::extract::parse_stroke_style; - - let raw = serde_json::json!({ - "strokes": [{ - "type": "SOLID", - "color": { "r": 1.0, "g": 0.5, "b": 0.0, "a": 1.0 }, - "opacity": 1.0 - }], - "strokeWeight": 2.0, - "strokeCap": "ROUND", - "strokeJoin": "BEVEL", - "strokeAlign": "INSIDE", - "strokeMiterLimit": 8.0, - "dashPattern": [4, 2] - }); - - let stroke = parse_stroke_style(&raw, 0.75).expect("expected stroke"); - assert_eq!(stroke.width, 2.0); - assert!(matches!(stroke.cap, StrokeCap::Round)); - assert!(matches!(stroke.join, StrokeJoin::Bevel)); - assert!(matches!(stroke.align, StrokeAlign::Inside)); - assert_eq!(stroke.miter_limit, 8.0); - assert_eq!(stroke.dash_pattern, vec![4.0, 2.0]); - assert!((stroke.color.a - 0.75).abs() < 1e-9); -} - -#[test] -fn parses_image_fill_from_hydrated_assets() { - use super::extract::image_fill_from_paint; - - let raw = serde_json::json!({ - "imageFillAssets": { - "abc123": "Zm9vYmFy" - } - }); - let paint = serde_json::json!({ - "type": "IMAGE", - "imageHash": "abc123", - "opacity": 0.5 - }); - - let parsed = image_fill_from_paint(&raw, &paint).expect("expected image fill"); - assert_eq!(parsed.image_hash.as_deref(), Some("abc123")); - assert_eq!(parsed.data_base64, "Zm9vYmFy"); - assert!((parsed.alpha - 0.5).abs() < 1e-9); - assert_eq!(parsed.scale_mode, ImageScaleMode::Fill); // default when no scaleMode -} - -#[test] -fn parses_image_fill_without_hydrated_assets_uses_placeholder() { - use super::extract::image_fill_from_paint; - - let raw = serde_json::json!({}); - let paint = serde_json::json!({ - "type": "IMAGE", - "imageHash": "abc123", - "opacity": 1.0 - }); - - let parsed = image_fill_from_paint(&raw, &paint).expect("expected image fill"); - assert_eq!( - parsed.data_base64, - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - ); -} - -#[test] -fn inherited_opacity_applies_to_child_fills() { - let json = serde_json::json!({ - "schema": "fts.figma.export/v1", - "generatedAt": "2026-02-16T00:00:00.000Z", - "source": { "editorType": "figma", "page": { "id": "0:1", "name": "Canvas" } }, - "selection": { "ids": [], "names": [], "totalRoots": 0, "totalNodes": 0 }, - "options": { "includeSvg": false, "includePng": false, "maxDepth": 4 }, - "nodes": [{ - "id": "1:1", - "name": "RootFrame", - "type": "FRAME", - "visible": true, - "opacity": 0.5, - "relativeTransform": [[1.0,0.0,0.0],[0.0,1.0,0.0]], - "size": { "x": 100.0, "y": 100.0 }, - "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0 }, - "children": [{ - "id": "1:2", - "name": "ChildRect", - "type": "RECTANGLE", - "visible": true, - "relativeTransform": [[1.0,0.0,10.0],[0.0,1.0,10.0]], - "size": { "x": 20.0, "y": 20.0 }, - "absoluteBoundingBox": { "x": 10.0, "y": 10.0, "width": 20.0, "height": 20.0 }, - "fills": [{ - "type": "SOLID", - "visible": true, - "opacity": 1.0, - "color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } - }], - "children": [] - }] - }] - }); - let bytes = serde_json::to_vec(&json).expect("json bytes"); - let doc = FrameDocument::from_source_bytes(&bytes).expect("import"); - let root = doc.pages[0]; - let primitives = build_paint_primitives(&doc, root); - - let mut found = false; - for p in primitives { - if let PaintPrimitive::Rect { fills, .. } = &p { - // One rect is the frame background (may be missing fill); we only care - // that at least one solid fill carries inherited alpha 0.5. - for (fill, _blend) in fills { - if let FillPaint::Solid(c) = fill { - if (c.a - 0.5).abs() < 1e-9 { - found = true; - break; - } - } - } - if found { - break; - } - } - } - assert!(found, "expected child fill alpha to include parent opacity"); -} - -#[test] -fn emits_layer_primitives_for_non_normal_blend_nodes() { - let json = serde_json::json!({ - "schema": "fts.figma.export/v1", - "generatedAt": "2026-02-16T00:00:00.000Z", - "source": { "editorType": "figma", "page": { "id": "0:1", "name": "Canvas" } }, - "selection": { "ids": [], "names": [], "totalRoots": 0, "totalNodes": 0 }, - "options": { "includeSvg": false, "includePng": false, "maxDepth": 4 }, - "nodes": [{ - "id": "2:1", - "name": "BlendFrame", - "type": "FRAME", - "visible": true, - "blendMode": "MULTIPLY", - "relativeTransform": [[1.0,0.0,0.0],[0.0,1.0,0.0]], - "size": { "x": 50.0, "y": 50.0 }, - "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 50.0, "height": 50.0 }, - "children": [] - }] - }); - let bytes = serde_json::to_vec(&json).expect("json bytes"); - let doc = FrameDocument::from_source_bytes(&bytes).expect("import"); - let root = doc.pages[0]; - let primitives = build_paint_primitives(&doc, root); - - let mut start = 0usize; - let mut end = 0usize; - for p in primitives { - match p { - PaintPrimitive::LayerStart { .. } => start += 1, - PaintPrimitive::LayerEnd { .. } => end += 1, - _ => {} - } - } - assert!(start >= 1, "expected at least one layer start"); - assert_eq!(start, end, "layer boundaries should be balanced"); -} - -#[test] -fn extracts_rotation_from_relative_transform() { - let json = serde_json::json!({ - "schema": "fts.figma.export/v1", - "generatedAt": "2026-02-16T00:00:00.000Z", - "source": { "editorType": "figma", "page": { "id": "0:1", "name": "Canvas" } }, - "selection": { "ids": [], "names": [], "totalRoots": 0, "totalNodes": 0 }, - "options": { "includeSvg": false, "includePng": false, "maxDepth": 4 }, - "nodes": [{ - "id": "3:1", - "name": "RotatedParent", - "type": "FRAME", - "visible": true, - "relativeTransform": [[1.0,0.0,0.0],[0.0,1.0,0.0]], - "size": { "x": 100.0, "y": 100.0 }, - "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0 }, - "children": [{ - "id": "3:2", - "name": "RotatedRect", - "type": "RECTANGLE", - "visible": true, - "relativeTransform": [[0.934,-0.358,4.584],[0.358,0.934,22.253]], - "size": { "x": 15.0, "y": 1.0 }, - "absoluteBoundingBox": { "x": 4.584, "y": 22.253, "width": 15.0, "height": 1.0 }, - "fills": [{ - "type": "SOLID", - "visible": true, - "opacity": 1.0, - "color": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 } - }], - "children": [] - }] - }] - }); - let bytes = serde_json::to_vec(&json).expect("json bytes"); - let doc = FrameDocument::from_source_bytes(&bytes).expect("import"); - let root = doc.pages[0]; - let primitives = build_paint_primitives(&doc, root); - - let mut found_rotation = false; - for p in primitives { - if let PaintPrimitive::Rect { rotation, .. } = &p { - // atan2(0.358, 0.934) ≈ 0.3649 radians ≈ 20.9° - if rotation.abs() > 0.3 && rotation.abs() < 0.4 { - found_rotation = true; - break; - } - } - } - assert!( - found_rotation, - "expected rotated rect with ~0.365 radian rotation" - ); -} - -#[test] -fn per_fill_blend_mode_is_extracted() { - let json = serde_json::json!({ - "schema": "fts.figma.export/v1", - "generatedAt": "2026-02-16T00:00:00.000Z", - "source": { "editorType": "figma", "page": { "id": "0:1", "name": "Canvas" } }, - "selection": { "ids": [], "names": [], "totalRoots": 0, "totalNodes": 0 }, - "options": { "includeSvg": false, "includePng": false, "maxDepth": 4 }, - "nodes": [{ - "id": "4:1", - "name": "MultiBlendRect", - "type": "RECTANGLE", - "visible": true, - "relativeTransform": [[1.0,0.0,0.0],[0.0,1.0,0.0]], - "size": { "x": 100.0, "y": 100.0 }, - "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0 }, - "fills": [ - { - "type": "SOLID", - "visible": true, - "opacity": 1.0, - "color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } - }, - { - "type": "SOLID", - "visible": true, - "opacity": 0.5, - "blendMode": "SOFT_LIGHT", - "color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 } - } - ], - "children": [] - }] - }); - let bytes = serde_json::to_vec(&json).expect("json bytes"); - let doc = FrameDocument::from_source_bytes(&bytes).expect("import"); - let root = doc.pages[0]; - let primitives = build_paint_primitives(&doc, root); - - let mut found_soft_light = false; - for p in primitives { - if let PaintPrimitive::Rect { fills, .. } = &p { - for (_fill, blend) in fills { - if *blend == BlendMix::SoftLight { - found_soft_light = true; - break; - } - } - } - } - assert!( - found_soft_light, - "expected at least one fill with SoftLight blend mode" - ); -} - -#[test] -fn ellipse_with_empty_fill_geometry_emits_rect_not_path() { - // Figma ELLIPSE nodes may export fillGeometry with empty path strings. - // These should fall through to Rect rendering (with circular corner radii) - // instead of being routed to the Path branch where nothing renders. - let json = serde_json::json!({ - "schema": "fts.figma.export/v1", - "generatedAt": "2026-02-16T00:00:00.000Z", - "source": { "editorType": "figma", "page": { "id": "0:1", "name": "Canvas" } }, - "selection": { "ids": [], "names": [], "totalRoots": 0, "totalNodes": 0 }, - "options": { "includeSvg": false, "includePng": false, "maxDepth": 4 }, - "nodes": [{ - "id": "5:1", - "name": "Parent", - "type": "FRAME", - "visible": true, - "relativeTransform": [[1.0,0.0,0.0],[0.0,1.0,0.0]], - "size": { "x": 200.0, "y": 200.0 }, - "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 200.0, "height": 200.0 }, - "children": [{ - "id": "5:2", - "name": "MetalKnob", - "type": "ELLIPSE", - "visible": true, - "relativeTransform": [[1.0,0.0,10.0],[0.0,1.0,10.0]], - "size": { "x": 40.0, "y": 40.0 }, - "absoluteBoundingBox": { "x": 10.0, "y": 10.0, "width": 40.0, "height": 40.0 }, - "fillGeometry": [{ "path": "" }], - "fills": [{ - "type": "SOLID", - "visible": true, - "opacity": 1.0, - "color": { "r": 0.5, "g": 0.5, "b": 0.5, "a": 1.0 } - }], - "children": [] - }] - }] - }); - let bytes = serde_json::to_vec(&json).expect("json bytes"); - let doc = FrameDocument::from_source_bytes(&bytes).expect("import"); - let root = doc.pages[0]; - let primitives = build_paint_primitives(&doc, root); - - let mut found_rect = false; - for p in primitives { - if let PaintPrimitive::Rect { - width, - height, - corner_radii, - .. - } = &p - { - // ELLIPSE 40×40 should produce a Rect with corner radii = 20 (half width) - if (*width - 40.0).abs() < 1e-6 && (*height - 40.0).abs() < 1e-6 { - assert!( - (corner_radii.top_left - 20.0).abs() < 1e-6, - "expected circular corner radius 20.0, got {}", - corner_radii.top_left - ); - found_rect = true; - break; - } - } - } - assert!( - found_rect, - "ELLIPSE with empty fillGeometry should emit a Rect, not a Path" - ); -} - -#[test] -fn gradient_paint_opacity_applied_to_stops() { - use super::extract::gradient_stops_from_paint_test; - - let paint = serde_json::json!({ - "type": "GRADIENT_LINEAR", - "opacity": 0.5, - "gradientStops": [ - { "position": 0.0, "color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } }, - { "position": 1.0, "color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 } } - ] - }); - - let stops = gradient_stops_from_paint_test(&paint).expect("expected stops"); - assert_eq!(stops.len(), 2); - // Paint-level opacity 0.5 should multiply into each stop's alpha - assert!( - (stops[0].color.a - 0.5).abs() < 1e-9, - "first stop alpha: {}", - stops[0].color.a - ); - assert!( - (stops[1].color.a - 0.5).abs() < 1e-9, - "second stop alpha: {}", - stops[1].color.a - ); -} - -#[test] -#[cfg(feature = "anyrender")] -fn path_fit_preserves_local_geometry_without_forced_scaling() { - use super::geometry::compute_path_fit_transform; - - let fill_paths = vec!["M 12 12 L 20 20 L 12 20 Z".to_string()]; - let transform = compute_path_fit_transform(&fill_paths, &[], 100.0, 200.0, 100.0, 100.0); - assert_eq!(transform, kurbo::Affine::IDENTITY); -} - -#[test] -#[cfg(feature = "anyrender")] -fn path_fit_cancels_node_origin_for_absolute_geometry() { - use super::geometry::compute_path_fit_transform; - - let fill_paths = vec!["M 100 200 L 200 200 L 200 300 L 100 300 Z".to_string()]; - let transform = compute_path_fit_transform(&fill_paths, &[], 100.0, 200.0, 100.0, 100.0); - assert_eq!(transform, kurbo::Affine::translate((-100.0, -200.0))); -} diff --git a/crates/frame-ui/src/anyrender_renderer/transform.rs b/crates/frame-ui/src/anyrender_renderer/transform.rs deleted file mode 100644 index 3d452666..00000000 --- a/crates/frame-ui/src/anyrender_renderer/transform.rs +++ /dev/null @@ -1,362 +0,0 @@ -/// A 2D affine transform in Figma row-major format. -/// -/// Figma stores transforms as `[[a, b, tx], [c, d, ty]]` where: -/// - Row 0: `[a, b, tx]` → `x' = a*x + b*y + tx` -/// - Row 1: `[c, d, ty]` → `y' = c*x + d*y + ty` -/// -/// Note: SVG's `matrix(a,b,c,d,e,f)` uses column-major convention — to emit -/// an SVG matrix string use [`AffineTransform2D::to_svg_matrix`] or the -/// [`std::fmt::Display`] impl, which reorders fields to `(a, c, b, d, tx, ty)`. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct AffineTransform2D { - /// Row 0, col 0: x-scale / cos(θ) - pub a: f64, - /// Row 0, col 1: -sin(θ) for pure rotation - pub b: f64, - /// Row 0, col 2: x-translation - pub tx: f64, - /// Row 1, col 0: sin(θ) for pure rotation - pub c: f64, - /// Row 1, col 1: y-scale / cos(θ) - pub d: f64, - /// Row 1, col 2: y-translation - pub ty: f64, -} - -impl AffineTransform2D { - /// The identity transform. - pub fn identity() -> Self { - Self { - a: 1.0, - b: 0.0, - tx: 0.0, - c: 0.0, - d: 1.0, - ty: 0.0, - } - } - - /// A pure rotation by `degrees` (counter-clockwise). - pub fn rotation(degrees: f64) -> Self { - let r = degrees.to_radians(); - let (sin_r, cos_r) = r.sin_cos(); - Self { - a: cos_r, - b: -sin_r, - tx: 0.0, - c: sin_r, - d: cos_r, - ty: 0.0, - } - } - - /// A pure translation. - pub fn translation(tx: f64, ty: f64) -> Self { - Self { - a: 1.0, - b: 0.0, - tx, - c: 0.0, - d: 1.0, - ty, - } - } - - /// A non-uniform scale. - pub fn scale(sx: f64, sy: f64) -> Self { - Self { - a: sx, - b: 0.0, - tx: 0.0, - c: 0.0, - d: sy, - ty: 0.0, - } - } - - /// Apply this transform to point `(u, v)` and return the transformed point. - /// - /// `x' = a*u + b*v + tx`, `y' = c*u + d*v + ty` - pub fn transform_point(&self, u: f64, v: f64) -> (f64, f64) { - (self.a * u + self.b * v + self.tx, self.c * u + self.d * v + self.ty) - } - - /// Determinant of the 2×2 linear part: `a*d - b*c`. - pub fn determinant(&self) -> f64 { - self.a * self.d - self.b * self.c - } - - /// Returns `true` if this is (approximately) the identity transform. - pub fn is_identity(&self) -> bool { - (self.a - 1.0).abs() <= f64::EPSILON - && self.b.abs() <= f64::EPSILON - && self.tx.abs() <= f64::EPSILON - && self.c.abs() <= f64::EPSILON - && (self.d - 1.0).abs() <= f64::EPSILON - && self.ty.abs() <= f64::EPSILON - } - - /// Extract the rotation angle in radians from the first column of the - /// linear part: `atan2(c, a)`. - /// - /// For a pure rotation matrix `[[cos θ, -sin θ], [sin θ, cos θ]]` this - /// recovers θ exactly. In the presence of scale the result is the rotation - /// of the x-axis. - pub fn decompose_rotation(&self) -> f64 { - self.c.atan2(self.a) - } - - /// Return the inverse transform, or `None` if the matrix is singular - /// (determinant ≈ 0). - pub fn inverse(&self) -> Option { - let det = self.determinant(); - if det.abs() < 1e-12 { - return None; - } - Some(Self { - a: self.d / det, - b: -self.b / det, - tx: (self.b * self.ty - self.d * self.tx) / det, - c: -self.c / det, - d: self.a / det, - ty: (self.c * self.tx - self.a * self.ty) / det, - }) - } - - /// Return the SVG `matrix()` coefficients in column-major order: - /// `(a, c, b, d, tx, ty)`. - /// - /// SVG `matrix(a,b,c,d,e,f)` encodes `x' = a*x + c*y + e` and - /// `y' = b*x + d*y + f`, so the column-major reordering is required. - pub fn to_svg_matrix_coeffs(&self) -> (f64, f64, f64, f64, f64, f64) { - (self.a, self.c, self.b, self.d, self.tx, self.ty) - } -} - -/// Compose two transforms: `self` applied *after* `rhs`. -/// -/// `(self * rhs)(v) = self(rhs(v))` -impl std::ops::Mul for AffineTransform2D { - type Output = Self; - - fn mul(self, rhs: Self) -> Self { - Self { - a: self.a * rhs.a + self.b * rhs.c, - b: self.a * rhs.b + self.b * rhs.d, - tx: self.a * rhs.tx + self.b * rhs.ty + self.tx, - c: self.c * rhs.a + self.d * rhs.c, - d: self.c * rhs.b + self.d * rhs.d, - ty: self.c * rhs.tx + self.d * rhs.ty + self.ty, - } - } -} - -/// Format as an SVG `matrix(a,b,c,d,e,f)` string using column-major order. -impl std::fmt::Display for AffineTransform2D { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let (a, b, c, d, e, ff) = self.to_svg_matrix_coeffs(); - write!(f, "matrix({a} {b} {c} {d} {e} {ff})") - } -} - -/// Parse from Figma's row-major `[[a, b, tx], [c, d, ty]]` representation. -impl From<[[f64; 3]; 2]> for AffineTransform2D { - fn from(t: [[f64; 3]; 2]) -> Self { - Self { - a: t[0][0], - b: t[0][1], - tx: t[0][2], - c: t[1][0], - d: t[1][1], - ty: t[1][2], - } - } -} - -impl From for [[f64; 3]; 2] { - fn from(t: AffineTransform2D) -> Self { - [[t.a, t.b, t.tx], [t.c, t.d, t.ty]] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn approx_eq(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-10 - } - - fn transform_approx_eq(a: &AffineTransform2D, b: &AffineTransform2D) -> bool { - approx_eq(a.a, b.a) - && approx_eq(a.b, b.b) - && approx_eq(a.tx, b.tx) - && approx_eq(a.c, b.c) - && approx_eq(a.d, b.d) - && approx_eq(a.ty, b.ty) - } - - #[test] - fn test_identity() { - let id = AffineTransform2D::identity(); - assert!(id.is_identity()); - let (x, y) = id.transform_point(3.0, 7.0); - assert_eq!(x, 3.0); - assert_eq!(y, 7.0); - } - - #[test] - fn test_translation() { - let t = AffineTransform2D::translation(5.0, -3.0); - let (x, y) = t.transform_point(1.0, 2.0); - assert_eq!(x, 6.0); - assert_eq!(y, -1.0); - } - - #[test] - fn test_scale() { - let s = AffineTransform2D::scale(2.0, 3.0); - let (x, y) = s.transform_point(4.0, 5.0); - assert_eq!(x, 8.0); - assert_eq!(y, 15.0); - } - - #[test] - fn test_rotation_90() { - // 90° CCW: (1,0) → (0,1), (0,1) → (-1,0) - let r = AffineTransform2D::rotation(90.0); - let (x, y) = r.transform_point(1.0, 0.0); - assert!(approx_eq(x, 0.0), "x={x}"); - assert!(approx_eq(y, 1.0), "y={y}"); - } - - #[test] - fn test_decompose_rotation() { - for deg in [0.0f64, 30.0, 45.0, 90.0, 135.0, 180.0, -45.0, -90.0] { - let r = AffineTransform2D::rotation(deg); - let recovered = r.decompose_rotation().to_degrees(); - let diff = (recovered - deg).abs(); - // Allow wrap-around for ±180° - assert!(diff < 1e-9 || (180.0 - diff).abs() < 1e-9, "deg={deg} recovered={recovered}"); - } - } - - #[test] - fn test_determinant() { - let id = AffineTransform2D::identity(); - assert_eq!(id.determinant(), 1.0); - - let s = AffineTransform2D::scale(2.0, 3.0); - assert_eq!(s.determinant(), 6.0); - - // Reflection: det should be -1 - let reflect = AffineTransform2D { a: -1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }; - assert_eq!(reflect.determinant(), -1.0); - } - - #[test] - fn test_composition_identity() { - let t = AffineTransform2D::translation(3.0, 4.0); - let _r = AffineTransform2D::rotation(45.0); - let id = AffineTransform2D::identity(); - assert!(transform_approx_eq(&(t * id), &t)); - assert!(transform_approx_eq(&(id * t), &t)); - } - - #[test] - fn test_composition_translate_then_scale() { - // Scale * Translate: translate first, then scale - let translate = AffineTransform2D::translation(1.0, 2.0); - let scale = AffineTransform2D::scale(2.0, 2.0); - let composed = scale * translate; - // Point (0,0) → translate → (1,2) → scale → (2,4) - let (x, y) = composed.transform_point(0.0, 0.0); - assert!(approx_eq(x, 2.0), "x={x}"); - assert!(approx_eq(y, 4.0), "y={y}"); - } - - #[test] - fn test_inverse_identity() { - let id = AffineTransform2D::identity(); - let inv = id.inverse().unwrap(); - assert!(transform_approx_eq(&inv, &id)); - } - - #[test] - fn test_inverse_translation() { - let t = AffineTransform2D::translation(5.0, -3.0); - let inv = t.inverse().unwrap(); - let composed = t * inv; - assert!(transform_approx_eq(&composed, &AffineTransform2D::identity())); - } - - #[test] - fn test_inverse_scale() { - let s = AffineTransform2D::scale(2.0, 4.0); - let inv = s.inverse().unwrap(); - let composed = s * inv; - assert!(transform_approx_eq(&composed, &AffineTransform2D::identity())); - } - - #[test] - fn test_inverse_rotation() { - let r = AffineTransform2D::rotation(37.5); - let inv = r.inverse().unwrap(); - let composed = r * inv; - assert!(transform_approx_eq(&composed, &AffineTransform2D::identity())); - } - - #[test] - fn test_inverse_singular() { - let singular = AffineTransform2D { a: 1.0, b: 2.0, tx: 0.0, c: 2.0, d: 4.0, ty: 0.0 }; - assert!(singular.inverse().is_none()); - } - - #[test] - fn test_inverse_near_zero_det() { - // det = 1e-14 < 1e-12 threshold — treated as singular - let t = AffineTransform2D { a: 1e-7, b: 0.0, tx: 0.0, c: 0.0, d: 1e-7, ty: 0.0 }; - assert!(t.inverse().is_none()); - - // det = 1e-11 > 1e-12 threshold — invertible despite being small - let t2 = AffineTransform2D { a: 1e-5, b: 0.0, tx: 0.0, c: 0.0, d: 1e-6, ty: 0.0 }; - assert!(t2.inverse().is_some()); - } - - #[test] - fn test_from_raw_array() { - let raw: [[f64; 3]; 2] = [[0.5, -0.866, 10.0], [0.866, 0.5, 20.0]]; - let t = AffineTransform2D::from(raw); - assert_eq!(t.a, 0.5); - assert_eq!(t.b, -0.866); - assert_eq!(t.tx, 10.0); - assert_eq!(t.c, 0.866); - assert_eq!(t.d, 0.5); - assert_eq!(t.ty, 20.0); - } - - #[test] - fn test_to_svg_matrix_coeffs() { - // SVG matrix(a,b,c,d,e,f) = column-major: x' = a*x + c*y + e, y' = b*x + d*y + f - // Our fields: x' = a*x + b*y + tx, y' = c*x + d*y + ty - // Mapping: svg_a=a, svg_b=c, svg_c=b, svg_d=d, svg_e=tx, svg_f=ty - let t = AffineTransform2D { a: 1.0, b: 2.0, tx: 5.0, c: 3.0, d: 4.0, ty: 6.0 }; - assert_eq!(t.to_svg_matrix_coeffs(), (1.0, 3.0, 2.0, 4.0, 5.0, 6.0)); - } - - #[test] - fn test_display() { - let t = AffineTransform2D { a: 1.0, b: 0.0, tx: 10.0, c: 0.0, d: 1.0, ty: 20.0 }; - let s = t.to_string(); - assert_eq!(s, "matrix(1 0 0 1 10 20)"); - } - - #[test] - fn test_reflection_inverse() { - // Reflection across x-axis: det = -1 - let reflect = AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: -1.0, ty: 0.0 }; - let inv = reflect.inverse().unwrap(); - let composed = reflect * inv; - assert!(transform_approx_eq(&composed, &AffineTransform2D::identity())); - } -} diff --git a/crates/frame-ui/src/context.rs b/crates/frame-ui/src/context.rs deleted file mode 100644 index f5773d5a..00000000 --- a/crates/frame-ui/src/context.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Frame context — dependency injection for rendering. -//! -//! Follows dock-dioxus context.rs pattern: PluginRenderer wraps an Rc -//! with Rc::ptr_eq for PartialEq (required by Dioxus props). - -use crate::prelude::*; -use std::rc::Rc; - -/// Custom renderer callback for plugin data on nodes. -/// -/// Downstream crates (Surface, Architect) use this to inject interactive -/// elements (knobs, equipment labels) into Frame design nodes. -#[derive(Clone)] -pub struct PluginRenderer(Rc Option>); - -impl PluginRenderer { - pub fn new( - f: impl Fn(&str, &serde_json::Value) -> Option + 'static, - ) -> Self { - Self(Rc::new(f)) - } - - /// Attempt to render plugin data. Returns None if the plugin key is unknown. - pub fn render(&self, plugin_key: &str, data: &serde_json::Value) -> Option { - (self.0)(plugin_key, data) - } -} - -impl PartialEq for PluginRenderer { - fn eq(&self, other: &Self) -> bool { - Rc::ptr_eq(&self.0, &other.0) - } -} - -/// Context for Frame UI rendering. -#[derive(Clone)] -pub struct FrameContext { - pub plugin_renderer: Option, - pub editable: bool, -} - -/// Hook to access the Frame context. -pub fn use_frame_context() -> FrameContext { - use_context::() -} - -/// Provider component that injects Frame context into the component tree. -#[component] -pub fn FrameProvider( - #[props(default)] plugin_renderer: Option, - #[props(default = true)] editable: bool, - children: Element, -) -> Element { - use_context_provider(move || FrameContext { - plugin_renderer: plugin_renderer.clone(), - editable, - }); - children -} diff --git a/crates/frame-ui/src/dioxus_renderer.rs b/crates/frame-ui/src/dioxus_renderer.rs deleted file mode 100644 index c2c25e24..00000000 --- a/crates/frame-ui/src/dioxus_renderer.rs +++ /dev/null @@ -1,283 +0,0 @@ -use dioxus::prelude::*; -use frame_proto::{FrameDocument, NodeId, RenderNodeProjection}; - -#[component] -pub fn ProjectionTree(document: FrameDocument, root: NodeId) -> Element { - let bounds = compute_bounds(&document, root); - let width = bounds.width.max(1.0); - let height = bounds.height.max(1.0); - - rsx! { - div { - class: "frame-projection-root", - style: "position: relative; width: {width}px; height: {height}px; overflow: auto; background: #0a0a0a;", - NodeLayer { - document: document.clone(), - node_id: root, - offset_x: bounds.min_x, - offset_y: bounds.min_y, - } - } - } -} - -#[component] -fn NodeLayer(document: FrameDocument, node_id: NodeId, offset_x: f64, offset_y: f64) -> Element { - let Some(projection) = document.project_node(node_id) else { - return rsx! {}; - }; - if !projection.visible { - return rsx! {}; - } - let Some(node) = document.get_node(node_id) else { - return rsx! {}; - }; - - let style = node_style(&projection, &node.raw, offset_x, offset_y); - let text_label = projection - .text - .as_ref() - .map(|t| t.characters.clone()) - .unwrap_or_default(); - let text_css = text_style(&node.raw); - - let is_document = projection.figma_type == "DOCUMENT"; - let is_canvas = projection.figma_type == "CANVAS"; - let is_text = projection.figma_type == "TEXT"; - - let children = projection.children.clone(); - - rsx! { - div { - class: "frame-node-layer", - style: "{style}", - if is_text && !text_label.is_empty() { - div { - style: "{text_css}", - "{text_label}" - } - } - if !(is_document || is_canvas || is_text) && children.is_empty() && projection.name != "Unnamed" { - div { - style: "position: absolute; left: 4px; top: 2px; color: rgba(255,255,255,0.45); font-size: 10px; white-space: nowrap;", - "{projection.name}" - } - } - for child_id in children { - NodeLayer { - document: document.clone(), - node_id: child_id, - offset_x, - offset_y, - } - } - } - } -} - -fn node_style( - projection: &RenderNodeProjection, - raw: &serde_json::Value, - offset_x: f64, - offset_y: f64, -) -> String { - if projection.figma_type == "DOCUMENT" { - return "position: relative; width: 100%; height: 100%;".to_string(); - } - - let mut css = String::from("position: absolute; box-sizing: border-box;"); - let (x, y) = extract_xy(projection); - let x = x - offset_x; - let y = y - offset_y; - css.push_str(&format!(" left: {x}px; top: {y}px;")); - - if let Some(size) = &projection.size { - if size.x > 0.0 { - css.push_str(&format!(" width: {}px;", size.x)); - } - if size.y > 0.0 { - css.push_str(&format!(" height: {}px;", size.y)); - } - } - - if let Some(opacity) = projection.opacity { - css.push_str(&format!(" opacity: {};", opacity.clamp(0.0, 1.0))); - } - - if let Some(fill) = first_solid_rgba(raw, "fills") { - css.push_str(&format!(" background: {fill};")); - } - - if let Some(stroke) = first_solid_rgba(raw, "strokes") { - let weight = raw - .get("strokeWeight") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .max(0.5); - css.push_str(&format!(" border: {weight}px solid {stroke};")); - } - - if let Some(radius) = raw.get("cornerRadius").and_then(|v| v.as_f64()) { - css.push_str(&format!(" border-radius: {radius}px;")); - } - - if raw - .get("clipsContent") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { - css.push_str(" overflow: hidden;"); - } - - if projection.figma_type == "CANVAS" { - css.push_str(" position: relative;"); - css.push_str(" left: 0; top: 0;"); - if let Some(bg) = raw.get("backgroundColor").and_then(rgba_from_color_value) { - css.push_str(&format!(" background: {bg};")); - } - } - - css -} - -fn text_style(raw: &serde_json::Value) -> String { - let mut css = - String::from("position: absolute; inset: 0; overflow: hidden; white-space: pre-wrap;"); - if let Some(color) = first_solid_rgba(raw, "fills") { - css.push_str(&format!(" color: {color};")); - } else { - css.push_str(" color: rgba(255,255,255,0.92);"); - } - - if let Some(style) = raw.get("style") { - if let Some(font_size) = style.get("fontSize").and_then(|v| v.as_f64()) { - css.push_str(&format!(" font-size: {font_size}px;")); - } - if let Some(font_family) = style.get("fontFamily").and_then(|v| v.as_str()) { - css.push_str(&format!(" font-family: '{font_family}', sans-serif;")); - } - if let Some(font_weight) = style.get("fontWeight").and_then(|v| v.as_u64()) { - css.push_str(&format!(" font-weight: {font_weight};")); - } - if let Some(line_height) = style.get("lineHeightPx").and_then(|v| v.as_f64()) { - css.push_str(&format!(" line-height: {line_height}px;")); - } - if let Some(letter_spacing) = style.get("letterSpacing").and_then(|v| v.as_f64()) { - css.push_str(&format!(" letter-spacing: {letter_spacing}px;")); - } - } - - css -} - -fn first_solid_rgba(raw: &serde_json::Value, key: &str) -> Option { - let fills = raw.get(key)?.as_array()?; - for fill in fills { - let fill_type = fill.get("type").and_then(|v| v.as_str()); - if fill_type != Some("SOLID") { - continue; - } - let visible = fill - .get("visible") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if !visible { - continue; - } - let opacity = fill.get("opacity").and_then(|v| v.as_f64()).unwrap_or(1.0); - if let Some(rgba) = fill.get("color").and_then(rgba_from_color_value) { - return Some(replace_alpha(rgba, opacity)); - } - } - None -} - -fn rgba_from_color_value(v: &serde_json::Value) -> Option { - let r = (v.get("r")?.as_f64()?.clamp(0.0, 1.0) * 255.0).round(); - let g = (v.get("g")?.as_f64()?.clamp(0.0, 1.0) * 255.0).round(); - let b = (v.get("b")?.as_f64()?.clamp(0.0, 1.0) * 255.0).round(); - let a = v - .get("a") - .and_then(|x| x.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - Some(format!("rgba({r},{g},{b},{a})")) -} - -fn replace_alpha(rgba: String, alpha: f64) -> String { - let Some(prefix) = rgba.strip_suffix(")") else { - return rgba; - }; - let Some((rgb_prefix, _old_alpha)) = prefix.rsplit_once(',') else { - return rgba; - }; - format!("{rgb_prefix},{})", alpha.clamp(0.0, 1.0)) -} - -fn extract_xy(projection: &RenderNodeProjection) -> (f64, f64) { - if let Some(t) = &projection.relative_transform { - if t.len() >= 2 && t[0].len() >= 3 && t[1].len() >= 3 { - return (t[0][2], t[1][2]); - } - } - (0.0, 0.0) -} - -#[derive(Debug, Clone, Copy)] -struct Bounds { - min_x: f64, - min_y: f64, - width: f64, - height: f64, -} - -fn compute_bounds(document: &FrameDocument, root: NodeId) -> Bounds { - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - - for node_id in document.walk_subtree(root) { - let Some(node) = document.get_node(node_id) else { - continue; - }; - let Some(bounds) = node.raw.get("absoluteBoundingBox") else { - continue; - }; - let x = bounds.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); - let y = bounds.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); - let w = bounds.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0); - let h = bounds.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0); - - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x + w); - max_y = max_y.max(y + h); - } - - if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { - if let Some(root_projection) = document.project_node(root) { - if let Some(size) = root_projection.size { - return Bounds { - min_x: 0.0, - min_y: 0.0, - width: size.x.max(1.0), - height: size.y.max(1.0), - }; - } - } - return Bounds { - min_x: 0.0, - min_y: 0.0, - width: 1024.0, - height: 768.0, - }; - } - - Bounds { - min_x, - min_y, - width: (max_x - min_x).max(1.0), - height: (max_y - min_y).max(1.0), - } -} diff --git a/crates/frame-ui/src/editor/canvas.rs b/crates/frame-ui/src/editor/canvas.rs deleted file mode 100644 index 998243ef..00000000 --- a/crates/frame-ui/src/editor/canvas.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Canvas — zoom/pan wrapper for the Frame design surface. - -use crate::prelude::*; -use crate::render::FrameNodeRenderer; -use crate::signals::*; -use frame_proto::NodeId; - -/// The main canvas component that wraps the design surface with zoom/pan. -#[component] -pub fn FrameCanvas() -> Element { - let zoom = FRAME_ZOOM.read(); - let pan = FRAME_PAN_OFFSET.read(); - let active_page = FRAME_ACTIVE_PAGE.read(); - - let page_id: Option = *active_page; - - let transform = format!( - "transform: translate({}px, {}px) scale({}); transform-origin: 0 0;", - pan.x, pan.y, *zoom, - ); - - rsx! { - div { - class: "frame-canvas", - style: "position: relative; overflow: hidden; width: 100%; height: 100%;", - - div { - class: "frame-canvas-content", - style: "{transform}", - - if let Some(page_id) = page_id { - FrameNodeRenderer { node_id: page_id } - } - } - } - } -} diff --git a/crates/frame-ui/src/editor/layer_panel.rs b/crates/frame-ui/src/editor/layer_panel.rs deleted file mode 100644 index a2878c5e..00000000 --- a/crates/frame-ui/src/editor/layer_panel.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Layer panel — tree view of the document's node hierarchy. - -use crate::prelude::*; -use crate::signals::*; -use frame_proto::NodeId; - -/// Layer tree panel showing the document hierarchy. -#[component] -pub fn LayerPanel() -> Element { - let active_page = FRAME_ACTIVE_PAGE.read(); - let doc = FRAME_DOCUMENT.read(); - - let page_id: Option = *active_page; - - rsx! { - div { - class: "frame-layer-panel", - style: "padding: 8px; font-size: 12px;", - - if let Some(page_id) = page_id { - if doc.get_node(page_id).is_some() { - LayerTreeNode { node_id: page_id, depth: 0 } - } - } - } - } -} - -/// A single entry in the layer tree, rendered recursively. -#[component] -fn LayerTreeNode(node_id: NodeId, depth: u32) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let name = node.name.clone(); - let children_ids: Vec = node.children.clone(); - let indent = depth * 16; - - rsx! { - div { - class: "frame-layer-item", - - div { - style: "padding: 2px 4px; padding-left: {indent}px; cursor: pointer;", - "{name}" - } - - for child_id in children_ids { - LayerTreeNode { key: "{child_id}", node_id: child_id, depth: depth + 1 } - } - } - } -} diff --git a/crates/frame-ui/src/editor/mod.rs b/crates/frame-ui/src/editor/mod.rs deleted file mode 100644 index 6eafa2fe..00000000 --- a/crates/frame-ui/src/editor/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Editor components — canvas, selection overlay, property panel, layer tree, toolbar. -//! -//! These provide the interactive design editing experience. - -pub mod canvas; -pub mod layer_panel; -pub mod property_panel; -pub mod selection; -pub mod toolbar; diff --git a/crates/frame-ui/src/editor/property_panel.rs b/crates/frame-ui/src/editor/property_panel.rs deleted file mode 100644 index e3b74250..00000000 --- a/crates/frame-ui/src/editor/property_panel.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Property panel — inspector for the selected node's properties. - -use crate::prelude::*; -use crate::signals::*; - -/// Property inspector panel for the currently selected node. -#[component] -pub fn PropertyPanel() -> Element { - let selection = FRAME_SELECTION.read(); - let doc = FRAME_DOCUMENT.read(); - - let selected_node = selection.first().and_then(|id| doc.get_node(*id)); - - match selected_node { - Some(node) => { - rsx! { - div { - class: "frame-property-panel", - style: "padding: 12px; font-size: 13px;", - - // Name - div { - style: "font-weight: 600; margin-bottom: 8px;", - "{node.name}" - } - - // Position - div { - style: "margin-bottom: 4px;", - "X: {node.absolute_position.x:.1} Y: {node.absolute_position.y:.1}" - } - - // Size - div { - style: "margin-bottom: 4px;", - "W: {node.size.width:.1} H: {node.size.height:.1}" - } - - // Opacity - { - let opacity_pct = node.opacity * 100.0; - rsx! { - div { - style: "margin-bottom: 4px;", - "Opacity: {opacity_pct:.0}%" - } - } - } - } - } - } - None => { - rsx! { - div { - class: "frame-property-panel", - style: "padding: 12px; color: #888; font-size: 13px;", - "No selection" - } - } - } - } -} diff --git a/crates/frame-ui/src/editor/selection.rs b/crates/frame-ui/src/editor/selection.rs deleted file mode 100644 index 5835b5b1..00000000 --- a/crates/frame-ui/src/editor/selection.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Selection overlay — draws selection rectangles and resize handles -//! on top of the canvas. - -use crate::prelude::*; -use crate::signals::*; - -/// Overlay component that renders selection indicators on selected nodes. -#[component] -pub fn SelectionOverlay() -> Element { - let selection = FRAME_SELECTION.read(); - let doc = FRAME_DOCUMENT.read(); - - if selection.is_empty() { - return rsx! {}; - } - - rsx! { - div { - class: "frame-selection-overlay", - style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;", - - for &node_id in selection.iter() { - if let Some(node) = doc.get_node(node_id) { - { - let left = node.absolute_position.x; - let top = node.absolute_position.y; - let w = node.size.width; - let h = node.size.height; - rsx! { - div { - key: "{node_id}", - class: "frame-selection-box", - style: "position: absolute; left: {left}px; top: {top}px; width: {w}px; height: {h}px; border: 2px solid #0d99ff; pointer-events: none;", - } - } - } - } - } - } - } -} diff --git a/crates/frame-ui/src/editor/toolbar.rs b/crates/frame-ui/src/editor/toolbar.rs deleted file mode 100644 index 030c4d0a..00000000 --- a/crates/frame-ui/src/editor/toolbar.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Toolbar — tool selection bar for the editor. - -use crate::prelude::*; -use crate::signals::*; - -/// Toolbar component for selecting the active editing tool. -#[component] -pub fn FrameToolbar() -> Element { - let active_tool = FRAME_ACTIVE_TOOL.read(); - - let tools = [ - (FrameTool::Select, "Select", "V"), - (FrameTool::Frame, "Frame", "F"), - (FrameTool::Rectangle, "Rectangle", "R"), - (FrameTool::Ellipse, "Ellipse", "O"), - (FrameTool::Line, "Line", "L"), - (FrameTool::Text, "Text", "T"), - (FrameTool::Pen, "Pen", "P"), - (FrameTool::Hand, "Hand", "H"), - ]; - - rsx! { - div { - class: "frame-toolbar", - style: "display: flex; gap: 4px; padding: 4px;", - - for (tool, label, shortcut) in tools { - button { - class: if *active_tool == tool { "frame-tool-btn active" } else { "frame-tool-btn" }, - title: "{label} ({shortcut})", - onclick: move |_| { - *FRAME_ACTIVE_TOOL.write() = tool; - }, - "{label}" - } - } - } - } -} diff --git a/crates/frame-ui/src/hooks.rs b/crates/frame-ui/src/hooks.rs deleted file mode 100644 index b3ec4daa..00000000 --- a/crates/frame-ui/src/hooks.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Hooks for common Frame UI operations. - -use crate::prelude::*; -use crate::signals::*; -use frame_proto::*; - -/// Hook that provides selection actions. -pub fn use_frame_selection() -> FrameSelectionActions { - FrameSelectionActions -} - -/// Actions for manipulating the current selection. -pub struct FrameSelectionActions; - -impl FrameSelectionActions { - /// Select a single node, replacing the current selection. - pub fn select(&self, node_id: NodeId) { - FRAME_SELECTION.write().clear(); - FRAME_SELECTION.write().push(node_id); - } - - /// Toggle a node in the selection (for Cmd/Ctrl-click multi-select). - pub fn toggle(&self, node_id: NodeId) { - let mut sel = FRAME_SELECTION.write(); - if let Some(pos) = sel.iter().position(|id| *id == node_id) { - sel.remove(pos); - } else { - sel.push(node_id); - } - } - - /// Clear the selection. - pub fn clear(&self) { - FRAME_SELECTION.write().clear(); - } - - /// Check if a node is selected. - pub fn is_selected(&self, node_id: NodeId) -> bool { - FRAME_SELECTION.read().contains(&node_id) - } -} - -/// Hook that provides document mutation actions with undo support. -pub fn use_frame_actions() -> FrameDocumentActions { - FrameDocumentActions -} - -/// Actions for mutating the Frame document. -pub struct FrameDocumentActions; - -impl FrameDocumentActions { - /// Insert a new node as a child of the given parent. - pub fn insert_node(&self, node: FrameNode, parent_id: NodeId) { - let mut doc = FRAME_DOCUMENT.write(); - doc.insert_node(node, parent_id); - } - - /// Remove a node from the document. - pub fn remove_node(&self, node_id: NodeId) { - let mut doc = FRAME_DOCUMENT.write(); - doc.remove_node(node_id); - } - - /// Set the active page. - pub fn set_active_page(&self, page_id: NodeId) { - *FRAME_ACTIVE_PAGE.write() = Some(page_id); - } - - /// Set the zoom level. - pub fn set_zoom(&self, zoom: f64) { - *FRAME_ZOOM.write() = zoom; - } - - /// Set the pan offset. - pub fn set_pan(&self, offset: Point) { - *FRAME_PAN_OFFSET.write() = offset; - } - - /// Set the active tool. - pub fn set_tool(&self, tool: FrameTool) { - *FRAME_ACTIVE_TOOL.write() = tool; - } -} diff --git a/crates/frame-ui/src/lib.rs b/crates/frame-ui/src/lib.rs deleted file mode 100644 index bda20008..00000000 --- a/crates/frame-ui/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Frame UI built on projection models from frame-proto. -//! -//! This crate provides two output paths over the same source: -//! - Dioxus component tree (`dioxus_renderer`) -//! - Anyrender-target paint primitives (`anyrender_renderer`) - -pub mod anyrender_renderer; -pub mod dioxus_renderer; -pub mod model; -#[cfg(feature = "svg_export")] -pub mod svg; - -pub use anyrender_renderer::{ - build_layout_boxes, build_paint_primitives, collect_render_diagnostics, LayoutBox, - PaintPrimitive, RenderDiagnostics, Rgba, -}; -#[cfg(feature = "anyrender")] -pub use anyrender_renderer::{ - paint_into_scene, paint_into_scene_with, paint_into_scene_with_font, - paint_primitives_into_scene_with, TextFontRef, -}; -pub use dioxus_renderer::ProjectionTree; -pub use model::{build_ui_nodes, build_ui_pages, UiNode, UiPage}; diff --git a/crates/frame-ui/src/model.rs b/crates/frame-ui/src/model.rs deleted file mode 100644 index 9f918fd1..00000000 --- a/crates/frame-ui/src/model.rs +++ /dev/null @@ -1,44 +0,0 @@ -use frame_proto::{FrameDocument, NodeId, RenderNodeProjection}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct UiNode { - pub projection: RenderNodeProjection, - pub depth: usize, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct UiPage { - pub page_id: NodeId, - pub nodes: Vec, -} - -pub fn build_ui_nodes(doc: &FrameDocument, root: NodeId) -> Vec { - let mut out = Vec::new(); - push_nodes(doc, root, 0, &mut out); - out -} - -pub fn build_ui_pages(doc: &FrameDocument) -> Vec { - doc.pages - .iter() - .copied() - .map(|page_id| UiPage { - page_id, - nodes: build_ui_nodes(doc, page_id), - }) - .collect() -} - -fn push_nodes(doc: &FrameDocument, node_id: NodeId, depth: usize, out: &mut Vec) { - let Some(projection) = doc.project_node(node_id) else { - return; - }; - - let children = projection.children.clone(); - out.push(UiNode { projection, depth }); - - for child in children { - push_nodes(doc, child, depth + 1, out); - } -} diff --git a/crates/frame-ui/src/prelude.rs b/crates/frame-ui/src/prelude.rs deleted file mode 100644 index b87ee5b9..00000000 --- a/crates/frame-ui/src/prelude.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Conditional prelude — imports the correct Dioxus backend. - -#[cfg(feature = "web")] -pub use dioxus::prelude::*; - -#[cfg(feature = "native")] -pub use dioxus_native::prelude::*; diff --git a/crates/frame-ui/src/render/ellipse_renderer.rs b/crates/frame-ui/src/render/ellipse_renderer.rs deleted file mode 100644 index bfb6c61e..00000000 --- a/crates/frame-ui/src/render/ellipse_renderer.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Ellipse renderer — CSS div with 50% border-radius for full ellipses, -//! falls back to Anyrender for arcs/partial ellipses. - -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use crate::style::css_builder::node_to_css; -use frame_proto::NodeId; - -/// Renders an ellipse node. Full ellipses use CSS border-radius: 50%. -/// Partial arcs will need Anyrender (TODO: wire up canvas). -#[component] -pub fn EllipseRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let mut style = node_to_css(node); - style.push_str("border-radius: 50%; "); - - rsx! { - div { - class: "frame-node frame-ellipse", - style: "{style}", - } - } -} diff --git a/crates/frame-ui/src/render/frame_renderer.rs b/crates/frame-ui/src/render/frame_renderer.rs deleted file mode 100644 index e422dab9..00000000 --- a/crates/frame-ui/src/render/frame_renderer.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Frame/container renderer — CSS div with auto-layout (flexbox). -//! -//! Handles NodeKind::Frame, Component, Section, and Page. -//! Children are recursively rendered via FrameNodeRenderer. - -use crate::context::use_frame_context; -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use crate::style::css_builder::node_to_css; -use frame_proto::NodeId; - -use super::node_renderer::FrameNodeRenderer; - -/// Renders a frame/container node as a CSS div with flex layout. -#[component] -pub fn FrameRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let style = node_to_css(node); - let children_ids: Vec = node.children.clone(); - let ctx = use_frame_context(); - - // Render plugin data overlays if a PluginRenderer is provided. - let plugin_elements: Vec = if let Some(ref renderer) = ctx.plugin_renderer { - node.plugin_data - .iter() - .filter_map(|(key, data)| renderer.render(key, data)) - .collect() - } else { - Vec::new() - }; - - rsx! { - div { - class: "frame-node frame-container", - style: "{style}", - // Recursive child rendering - for child_id in children_ids { - FrameNodeRenderer { key: "{child_id}", node_id: child_id } - } - // Plugin overlays - for element in plugin_elements { - {element} - } - } - } -} diff --git a/crates/frame-ui/src/render/group_renderer.rs b/crates/frame-ui/src/render/group_renderer.rs deleted file mode 100644 index fcf7e35f..00000000 --- a/crates/frame-ui/src/render/group_renderer.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Group renderer — transparent wrapper that just renders children. - -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use crate::style::css_builder::node_to_css; -use frame_proto::NodeId; - -use super::node_renderer::FrameNodeRenderer; - -/// Renders a group node — a transparent wrapper that passes through -/// opacity/blend-mode but otherwise just renders its children. -#[component] -pub fn GroupRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let style = node_to_css(node); - let children_ids: Vec = node.children.clone(); - - rsx! { - div { - class: "frame-node frame-group", - style: "{style}", - for child_id in children_ids { - FrameNodeRenderer { key: "{child_id}", node_id: child_id } - } - } - } -} diff --git a/crates/frame-ui/src/render/instance_renderer.rs b/crates/frame-ui/src/render/instance_renderer.rs deleted file mode 100644 index 3b7deb3c..00000000 --- a/crates/frame-ui/src/render/instance_renderer.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Instance renderer — renders a component instance with overrides applied. - -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use crate::style::css_builder::node_to_css; -use frame_proto::NodeId; - -use super::node_renderer::FrameNodeRenderer; - -/// Renders a component instance. For now this works like a frame — -/// it renders its children with CSS styling. Override application -/// will be expanded later (requires deep-cloning the component tree -/// and patching properties). -#[component] -pub fn InstanceRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let style = node_to_css(node); - let children_ids: Vec = node.children.clone(); - - rsx! { - div { - class: "frame-node frame-instance", - style: "{style}", - for child_id in children_ids { - FrameNodeRenderer { key: "{child_id}", node_id: child_id } - } - } - } -} diff --git a/crates/frame-ui/src/render/mod.rs b/crates/frame-ui/src/render/mod.rs deleted file mode 100644 index 264e7901..00000000 --- a/crates/frame-ui/src/render/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Dioxus render components for Frame document nodes. -//! -//! Follows the DockNodeRenderer recursive dispatch pattern: -//! FrameNodeRenderer matches NodeKind and dispatches to type-specific renderers. - -pub mod ellipse_renderer; -pub mod frame_renderer; -pub mod group_renderer; -pub mod instance_renderer; -pub mod node_renderer; -pub mod rectangle_renderer; -pub mod text_renderer; -pub mod vector_renderer; - -pub use node_renderer::FrameNodeRenderer; diff --git a/crates/frame-ui/src/render/node_renderer.rs b/crates/frame-ui/src/render/node_renderer.rs deleted file mode 100644 index 5647c806..00000000 --- a/crates/frame-ui/src/render/node_renderer.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Recursive node renderer — dispatches to type-specific Dioxus components. -//! -//! Follows the DockNodeRenderer pattern at dock-dioxus/src/components/dock_root.rs. - -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use frame_proto::node::NodeKind; -use frame_proto::NodeId; - -use super::ellipse_renderer::EllipseRenderer; -use super::frame_renderer::FrameRenderer; -use super::group_renderer::GroupRenderer; -use super::instance_renderer::InstanceRenderer; -use super::rectangle_renderer::RectangleRenderer; -use super::text_renderer::TextRenderer; -use super::vector_renderer::VectorRenderer; - -/// Recursive node renderer — reads the document signal and dispatches to -/// the correct Dioxus component based on NodeKind. -#[component] -pub fn FrameNodeRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => { - return rsx! { - div { class: "frame-missing-node", - "Missing node" - } - } - } - }; - - // Invisible nodes render nothing. - if !node.visible { - return rsx! {}; - } - - // CSS-renderable nodes get lightweight div-based components. - // Complex vectors/shapes fall through to the Anyrender canvas. - if node.is_css_renderable() { - match &node.kind { - NodeKind::Frame { .. } - | NodeKind::Component { .. } - | NodeKind::Section => rsx! { FrameRenderer { node_id } }, - - NodeKind::Rectangle { .. } => rsx! { RectangleRenderer { node_id } }, - NodeKind::Text { .. } => rsx! { TextRenderer { node_id } }, - NodeKind::Group => rsx! { GroupRenderer { node_id } }, - - NodeKind::Instance { .. } => rsx! { InstanceRenderer { node_id } }, - - NodeKind::Page { .. } => rsx! { FrameRenderer { node_id } }, - - // Slice has no visual — just render children. - NodeKind::Slice => rsx! { GroupRenderer { node_id } }, - - // Fallback: CSS-renderable but unmatched → frame renderer. - _ => rsx! { FrameRenderer { node_id } }, - } - } else { - // Ellipses, vectors, polygons, stars, lines, boolean ops → Anyrender. - match &node.kind { - NodeKind::Ellipse { .. } => rsx! { EllipseRenderer { node_id } }, - _ => rsx! { VectorRenderer { node_id } }, - } - } -} diff --git a/crates/frame-ui/src/render/rectangle_renderer.rs b/crates/frame-ui/src/render/rectangle_renderer.rs deleted file mode 100644 index aaff221d..00000000 --- a/crates/frame-ui/src/render/rectangle_renderer.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Rectangle renderer — CSS div for rectangular shapes. - -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use crate::style::css_builder::node_to_css; -use frame_proto::NodeId; - -/// Renders a rectangle node as a CSS div. -#[component] -pub fn RectangleRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let style = node_to_css(node); - - rsx! { - div { - class: "frame-node frame-rectangle", - style: "{style}", - } - } -} diff --git a/crates/frame-ui/src/render/text_renderer.rs b/crates/frame-ui/src/render/text_renderer.rs deleted file mode 100644 index 15cc50e7..00000000 --- a/crates/frame-ui/src/render/text_renderer.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Text renderer — CSS-based rich text rendering. - -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use crate::style::css_builder::node_to_css; -use frame_proto::node::NodeKind; -use frame_proto::typography::*; -use frame_proto::NodeId; - -/// Renders a text node with CSS font styling. -#[component] -pub fn TextRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let base_style = node_to_css(node); - - // Extract text-specific data. - let (characters, segments, style) = match &node.kind { - NodeKind::Text { - characters, - segments, - style, - } => (characters.clone(), segments.clone(), style.clone()), - _ => return rsx! {}, - }; - - // If there's only one segment (or no segments), render as a simple styled div. - if segments.len() <= 1 { - let text_css = text_style_to_css(&style); - rsx! { - div { - class: "frame-node frame-text", - style: "{base_style}{text_css}", - "{characters}" - } - } - } else { - // Multiple segments → span per segment for mixed formatting. - rsx! { - div { - class: "frame-node frame-text", - style: "{base_style}", - for segment in segments { - span { - style: "{text_style_to_css(&segment.style)}", - "{segment.text}" - } - } - } - } - } -} - -/// Convert a TextStyle into inline CSS properties. -fn text_style_to_css(style: &TextStyle) -> String { - let mut css = String::new(); - - css.push_str(&format!("font-family: '{}'; ", style.font_family)); - css.push_str(&format!("font-size: {}px; ", style.font_size)); - css.push_str(&format!("font-weight: {}; ", style.font_weight)); - - if style.italic { - css.push_str("font-style: italic; "); - } - - if style.letter_spacing != 0.0 { - css.push_str(&format!("letter-spacing: {}px; ", style.letter_spacing)); - } - - match style.line_height { - LineHeight::Auto => {} - LineHeight::Pixels(px) => css.push_str(&format!("line-height: {px}px; ")), - LineHeight::Percent(pct) => css.push_str(&format!("line-height: {}%; ", pct * 100.0)), - } - - match style.text_align_horizontal { - TextAlignHorizontal::Left => css.push_str("text-align: left; "), - TextAlignHorizontal::Center => css.push_str("text-align: center; "), - TextAlignHorizontal::Right => css.push_str("text-align: right; "), - TextAlignHorizontal::Justified => css.push_str("text-align: justify; "), - } - - match style.text_decoration { - TextDecoration::None => {} - TextDecoration::Underline => css.push_str("text-decoration: underline; "), - TextDecoration::Strikethrough => css.push_str("text-decoration: line-through; "), - } - - match style.text_case { - TextCase::Original => {} - TextCase::Upper => css.push_str("text-transform: uppercase; "), - TextCase::Lower => css.push_str("text-transform: lowercase; "), - TextCase::Title => css.push_str("text-transform: capitalize; "), - TextCase::SmallCaps | TextCase::SmallCapsForced => { - css.push_str("font-variant: small-caps; "); - } - } - - css -} diff --git a/crates/frame-ui/src/render/vector_renderer.rs b/crates/frame-ui/src/render/vector_renderer.rs deleted file mode 100644 index 5a6cf9b4..00000000 --- a/crates/frame-ui/src/render/vector_renderer.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Vector renderer — placeholder for Anyrender canvas fallback. -//! -//! This renders nodes that can't be expressed in CSS (vectors, polygons, -//! stars, lines, boolean ops). Currently a placeholder div; will be wired -//! to Anyrender's PaintScene when the `anyrender` feature is added. - -use crate::prelude::*; -use crate::signals::FRAME_DOCUMENT; -use frame_proto::NodeId; - -/// Renders a vector/shape node. Currently a sized placeholder div. -/// When Anyrender integration is wired up, this will use a canvas -/// element with the PaintScene trait to draw paths. -#[component] -pub fn VectorRenderer(node_id: NodeId) -> Element { - let doc = FRAME_DOCUMENT.read(); - let node = match doc.get_node(node_id) { - Some(n) => n, - None => return rsx! {}, - }; - - let width = node.size.width; - let height = node.size.height; - let opacity = node.opacity; - - rsx! { - div { - class: "frame-node frame-vector", - style: "width: {width}px; height: {height}px; opacity: {opacity};", - // TODO: Anyrender canvas element for actual path rendering - } - } -} diff --git a/crates/frame-ui/src/signals.rs b/crates/frame-ui/src/signals.rs deleted file mode 100644 index e3b12cc9..00000000 --- a/crates/frame-ui/src/signals.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Global signals for Frame document state. -//! -//! Follows the dock-dioxus GlobalSignal pattern. - -use crate::prelude::*; -use frame_proto::*; - -/// The current Frame document. -pub static FRAME_DOCUMENT: GlobalSignal = - Signal::global(|| FrameDocument::empty("Untitled")); - -/// Currently active page ID. -pub static FRAME_ACTIVE_PAGE: GlobalSignal> = Signal::global(|| None); - -/// Currently selected node IDs (supports multi-select). -pub static FRAME_SELECTION: GlobalSignal> = Signal::global(Vec::new); - -/// Hovered node ID (for hover highlight in editor). -pub static FRAME_HOVERED_NODE: GlobalSignal> = Signal::global(|| None); - -/// Canvas zoom level (1.0 = 100%). -pub static FRAME_ZOOM: GlobalSignal = Signal::global(|| 1.0); - -/// Canvas scroll/pan offset. -pub static FRAME_PAN_OFFSET: GlobalSignal = Signal::global(Point::default); - -/// Current editing tool. -pub static FRAME_ACTIVE_TOOL: GlobalSignal = Signal::global(|| FrameTool::Select); - -/// Active editing tool mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FrameTool { - Select, - Frame, - Rectangle, - Ellipse, - Line, - Text, - Pen, - Hand, -} diff --git a/crates/frame-ui/src/style/css_builder.rs b/crates/frame-ui/src/style/css_builder.rs deleted file mode 100644 index 0e81961b..00000000 --- a/crates/frame-ui/src/style/css_builder.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Master CSS builder — converts a FrameNode to inline CSS. - -use crate::style::effect_to_css::*; -use crate::style::layout_to_css::*; -use crate::style::paint_to_css::*; -use frame_proto::node::{FrameNode, NodeKind}; -use frame_proto::paint::BlendMode; - -/// Build an inline CSS style string from a FrameNode. -pub fn node_to_css(node: &FrameNode) -> String { - let mut css = String::new(); - - // Position and size - css.push_str(&format!( - "width: {}px; height: {}px; ", - node.size.width, node.size.height - )); - - // Opacity - if node.opacity < 1.0 { - css.push_str(&format!("opacity: {}; ", node.opacity)); - } - - // Fills → background - if let Some(bg) = fills_to_css_background(&node.fills) { - css.push_str(&format!("background: {bg}; ")); - } - - // Strokes → border - if let Some(border) = strokes_to_css_border(&node.strokes, node.stroke_style.weight) { - css.push_str(&format!("border: {border}; ")); - } - - // Corner radii → border-radius - if let Some(radius) = corner_radii_to_css(node) { - css.push_str(&format!("border-radius: {radius}; ")); - } - - // Effects → box-shadow - if let Some(shadow) = effects_to_css_box_shadow(&node.effects) { - css.push_str(&format!("box-shadow: {shadow}; ")); - } - - // Effects → filter (blur) - if let Some(filter) = effects_to_css_filter(&node.effects) { - css.push_str(&format!("filter: {filter}; ")); - } - - // Effects → backdrop-filter - if let Some(backdrop) = effects_to_css_backdrop_filter(&node.effects) { - css.push_str(&format!("backdrop-filter: {backdrop}; ")); - } - - // Auto-layout → flexbox - if let NodeKind::Frame { - auto_layout, - clip_content, - .. - } = &node.kind - { - css.push_str(&auto_layout_to_css(auto_layout)); - if *clip_content { - css.push_str("overflow: hidden; "); - } - } - - // Blend mode - if node.blend_mode != BlendMode::PassThrough && node.blend_mode != BlendMode::Normal { - if let Some(mode) = blend_mode_to_css(node.blend_mode) { - css.push_str(&format!("mix-blend-mode: {mode}; ")); - } - } - - css -} - -fn corner_radii_to_css(node: &FrameNode) -> Option { - let radii = match &node.kind { - NodeKind::Frame { corner_radii, .. } => corner_radii, - NodeKind::Rectangle { corner_radii } => corner_radii, - _ => return None, - }; - - if radii.is_zero() { - return None; - } - - if radii.is_uniform() { - Some(format!("{}px", radii.top_left)) - } else { - Some(format!( - "{}px {}px {}px {}px", - radii.top_left, radii.top_right, radii.bottom_right, radii.bottom_left - )) - } -} - -fn blend_mode_to_css(mode: BlendMode) -> Option<&'static str> { - match mode { - BlendMode::Multiply => Some("multiply"), - BlendMode::Screen => Some("screen"), - BlendMode::Overlay => Some("overlay"), - BlendMode::Darken => Some("darken"), - BlendMode::Lighten => Some("lighten"), - BlendMode::ColorDodge => Some("color-dodge"), - BlendMode::ColorBurn => Some("color-burn"), - BlendMode::HardLight => Some("hard-light"), - BlendMode::SoftLight => Some("soft-light"), - BlendMode::Difference => Some("difference"), - BlendMode::Exclusion => Some("exclusion"), - BlendMode::Hue => Some("hue"), - BlendMode::Saturation => Some("saturation"), - BlendMode::ColorBlend => Some("color"), - BlendMode::Luminosity => Some("luminosity"), - _ => None, - } -} diff --git a/crates/frame-ui/src/style/effect_to_css.rs b/crates/frame-ui/src/style/effect_to_css.rs deleted file mode 100644 index e0b39017..00000000 --- a/crates/frame-ui/src/style/effect_to_css.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Effect → CSS box-shadow/filter values. - -use frame_proto::effect::Effect; - -/// Convert effects to a CSS box-shadow string. -pub fn effects_to_css_box_shadow(effects: &[Effect]) -> Option { - let shadows: Vec = effects - .iter() - .filter_map(|e| e.to_css_box_shadow()) - .collect(); - - if shadows.is_empty() { - None - } else { - Some(shadows.join(", ")) - } -} - -/// Convert blur effects to a CSS filter string. -pub fn effects_to_css_filter(effects: &[Effect]) -> Option { - for effect in effects { - match effect { - Effect::LayerBlur { radius, visible } if *visible => { - return Some(format!("blur({radius}px)")); - } - _ => {} - } - } - None -} - -/// Convert background blur to a CSS backdrop-filter string. -pub fn effects_to_css_backdrop_filter(effects: &[Effect]) -> Option { - for effect in effects { - match effect { - Effect::BackgroundBlur { radius, visible } if *visible => { - return Some(format!("blur({radius}px)")); - } - _ => {} - } - } - None -} diff --git a/crates/frame-ui/src/style/layout_to_css.rs b/crates/frame-ui/src/style/layout_to_css.rs deleted file mode 100644 index 50008e48..00000000 --- a/crates/frame-ui/src/style/layout_to_css.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! AutoLayout → CSS flex properties. - -use frame_proto::layout::*; - -/// Convert auto-layout configuration to CSS flex properties. -pub fn auto_layout_to_css(layout: &AutoLayout) -> String { - if !layout.has_auto_layout() { - return String::new(); - } - - let mut css = String::new(); - css.push_str("display: flex; "); - - match layout.direction { - LayoutDirection::Horizontal => css.push_str("flex-direction: row; "), - LayoutDirection::Vertical => css.push_str("flex-direction: column; "), - LayoutDirection::Wrap => css.push_str("flex-direction: row; flex-wrap: wrap; "), - LayoutDirection::None => {} - } - - // justify-content (primary axis) - match layout.primary_axis_align { - PrimaryAxisAlign::Min => css.push_str("justify-content: flex-start; "), - PrimaryAxisAlign::Center => css.push_str("justify-content: center; "), - PrimaryAxisAlign::Max => css.push_str("justify-content: flex-end; "), - PrimaryAxisAlign::SpaceBetween => css.push_str("justify-content: space-between; "), - } - - // align-items (counter axis) - match layout.counter_axis_align { - CounterAxisAlign::Min => css.push_str("align-items: flex-start; "), - CounterAxisAlign::Center => css.push_str("align-items: center; "), - CounterAxisAlign::Max => css.push_str("align-items: flex-end; "), - CounterAxisAlign::Baseline => css.push_str("align-items: baseline; "), - } - - // gap - if layout.item_spacing > 0.0 { - css.push_str(&format!("gap: {}px; ", layout.item_spacing)); - } - - // padding - if layout.padding_top > 0.0 - || layout.padding_right > 0.0 - || layout.padding_bottom > 0.0 - || layout.padding_left > 0.0 - { - css.push_str(&format!( - "padding: {}px {}px {}px {}px; ", - layout.padding_top, layout.padding_right, layout.padding_bottom, layout.padding_left - )); - } - - css -} diff --git a/crates/frame-ui/src/style/mod.rs b/crates/frame-ui/src/style/mod.rs deleted file mode 100644 index 857fa9b0..00000000 --- a/crates/frame-ui/src/style/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! CSS generation from Frame document nodes. - -pub mod css_builder; -pub mod effect_to_css; -pub mod layout_to_css; -pub mod paint_to_css; diff --git a/crates/frame-ui/src/style/paint_to_css.rs b/crates/frame-ui/src/style/paint_to_css.rs deleted file mode 100644 index 555921ac..00000000 --- a/crates/frame-ui/src/style/paint_to_css.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! PaintLayer → CSS background/border values. - -use frame_proto::paint::{Paint, PaintLayer}; - -/// Convert fill paint layers to a CSS background value. -pub fn fills_to_css_background(fills: &[PaintLayer]) -> Option { - let visible_fills: Vec<_> = fills.iter().filter(|f| f.visible).collect(); - if visible_fills.is_empty() { - return None; - } - - // Multiple fills stack (last in list = frontmost = first in CSS). - let backgrounds: Vec = visible_fills - .iter() - .rev() - .filter_map(|layer| match &layer.paint { - Paint::Solid { color } => { - let mut c = *color; - c.a *= layer.opacity; - Some(c.to_css_rgba()) - } - Paint::Gradient(_) | Paint::Image(_) => None, // handled by Anyrender - }) - .collect(); - - if backgrounds.is_empty() { - None - } else { - Some(backgrounds.join(", ")) - } -} - -/// Convert stroke paint layers to a CSS border value. -pub fn strokes_to_css_border( - strokes: &[PaintLayer], - weight: f64, -) -> Option { - let visible = strokes.iter().find(|s| s.visible)?; - match &visible.paint { - Paint::Solid { color } => { - let mut c = *color; - c.a *= visible.opacity; - Some(format!("{weight}px solid {}", c.to_css_rgba())) - } - _ => None, - } -} diff --git a/crates/frame-ui/src/svg/cli.rs b/crates/frame-ui/src/svg/cli.rs deleted file mode 100644 index 3369cc5e..00000000 --- a/crates/frame-ui/src/svg/cli.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[cfg(feature = "svg_export")] -pub fn run_cli() -> Result<(), String> { - super::render::run_cli() -} diff --git a/crates/frame-ui/src/svg/source_bridge.rs b/crates/frame-ui/src/svg/source_bridge.rs deleted file mode 100644 index 6ea21c93..00000000 --- a/crates/frame-ui/src/svg/source_bridge.rs +++ /dev/null @@ -1,249 +0,0 @@ -#[cfg(feature = "svg_export")] -pub(super) type StrokeSourceMap = - std::collections::HashMap, Option)>; - -#[cfg(feature = "svg_export")] -pub(super) fn collect_source_absolute_bboxes( - bytes: &[u8], -) -> Result, String> { - fn walk( - node: &serde_json::Value, - out: &mut std::collections::HashMap, - ) { - let id = node.get("id").and_then(|v| v.as_str()); - let bb = node.get("absoluteBoundingBox"); - if let (Some(id), Some(bb)) = (id, bb) { - let x = bb.get("x").and_then(|v| v.as_f64()); - let y = bb.get("y").and_then(|v| v.as_f64()); - let w = bb.get("width").and_then(|v| v.as_f64()); - let h = bb.get("height").and_then(|v| v.as_f64()); - if let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) { - out.insert(id.to_string(), (x, y, w.max(0.0), h.max(0.0))); - } - } - if let Some(children) = node.get("children").and_then(|v| v.as_array()) { - for child in children { - walk(child, out); - } - } - } - - let json: serde_json::Value = - serde_json::from_slice(bytes).map_err(|e| format!("invalid export JSON: {e}"))?; - let mut out = std::collections::HashMap::new(); - if let Some(nodes) = json.get("nodes").and_then(|v| v.as_array()) { - for node in nodes { - walk(node, &mut out); - } - } - Ok(out) -} - -#[cfg(feature = "svg_export")] -pub(super) fn collect_source_stroke_data(bytes: &[u8]) -> Result { - fn walk(node: &serde_json::Value, out: &mut StrokeSourceMap) { - if let Some(id) = node.get("id").and_then(|v| v.as_str()) { - let paints = raw_stroke_paints(node); - let stroke_weight = node.get("strokeWeight").and_then(|v| v.as_f64()); - if !paints.is_empty() || stroke_weight.is_some() { - out.insert(id.to_string(), (paints, stroke_weight)); - } - } - if let Some(children) = node.get("children").and_then(|v| v.as_array()) { - for child in children { - walk(child, out); - } - } - } - - let json: serde_json::Value = - serde_json::from_slice(bytes).map_err(|e| format!("invalid export JSON: {e}"))?; - let mut out = std::collections::HashMap::new(); - if let Some(nodes) = json.get("nodes").and_then(|v| v.as_array()) { - for node in nodes { - walk(node, &mut out); - } - } - Ok(out) -} - -#[cfg(feature = "svg_export")] -pub(super) fn extract_export_root_name( - bytes: &[u8], - root_figma_id: Option<&str>, -) -> Result, String> { - let json: serde_json::Value = - serde_json::from_slice(bytes).map_err(|e| format!("invalid export JSON: {e}"))?; - let Some(nodes) = json.get("nodes").and_then(|n| n.as_array()) else { - return Ok(None); - }; - if nodes.is_empty() { - return Ok(None); - } - let target = if let Some(root_id) = root_figma_id { - nodes.iter() - .find(|node| node.get("id").and_then(|v| v.as_str()) == Some(root_id)) - .unwrap_or(&nodes[0]) - } else { - &nodes[0] - }; - Ok(target - .get("name") - .and_then(|v| v.as_str()) - .map(ToString::to_string)) -} - -#[cfg(feature = "svg_export")] -pub(super) fn extract_export_root_id( - bytes: &[u8], - root_figma_id: Option<&str>, -) -> Result, String> { - let json: serde_json::Value = - serde_json::from_slice(bytes).map_err(|e| format!("invalid export JSON: {e}"))?; - let Some(nodes) = json.get("nodes").and_then(|n| n.as_array()) else { - return Ok(None); - }; - if nodes.is_empty() { - return Ok(None); - } - let target = if let Some(root_id) = root_figma_id { - nodes.iter() - .find(|node| node.get("id").and_then(|v| v.as_str()) == Some(root_id)) - .unwrap_or(&nodes[0]) - } else { - &nodes[0] - }; - Ok(target - .get("id") - .and_then(|v| v.as_str()) - .map(ToString::to_string)) -} - -#[cfg(feature = "svg_export")] -fn raw_gradient_stops_from_paint( - paint: &serde_json::Value, -) -> Option> { - let stops = paint.get("gradientStops")?.as_array()?; - let paint_opacity = paint - .get("opacity") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - let mut out = Vec::new(); - for stop in stops { - let color = stop.get("color")?; - let r = color.get("r")?.as_f64()?.clamp(0.0, 1.0); - let g = color.get("g")?.as_f64()?.clamp(0.0, 1.0); - let b = color.get("b")?.as_f64()?.clamp(0.0, 1.0); - let a = color - .get("a") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0) - * paint_opacity; - let position = stop - .get("position") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .clamp(0.0, 1.0); - out.push(crate::anyrender_renderer::GradientStop { - offset: position, - color: crate::anyrender_renderer::Rgba { r, g, b, a }, - }); - } - if out.is_empty() { - None - } else { - Some(out) - } -} - -#[cfg(feature = "svg_export")] -fn raw_gradient_transform_from_paint( - paint: &serde_json::Value, -) -> Option { - let t = paint.get("gradientTransform")?.as_array()?; - if t.len() < 2 { - return None; - } - let r0 = t[0].as_array()?; - let r1 = t[1].as_array()?; - if r0.len() < 3 || r1.len() < 3 { - return None; - } - Some(crate::anyrender_renderer::AffineTransform2D::from([ - [ - r0[0].as_f64().unwrap_or(1.0), - r0[1].as_f64().unwrap_or(0.0), - r0[2].as_f64().unwrap_or(0.0), - ], - [ - r1[0].as_f64().unwrap_or(0.0), - r1[1].as_f64().unwrap_or(1.0), - r1[2].as_f64().unwrap_or(0.0), - ], - ])) -} - -#[cfg(feature = "svg_export")] -pub(super) fn raw_stroke_paints( - node_raw: &serde_json::Value, -) -> Vec { - let Some(strokes) = node_raw.get("strokes").and_then(|v| v.as_array()) else { - return Vec::new(); - }; - let mut out = Vec::new(); - for paint in strokes { - let visible = paint - .get("visible") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if !visible { - continue; - } - let paint_type = paint - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let parsed = match paint_type { - "SOLID" => { - let color = paint.get("color"); - color.and_then(|c| { - Some(crate::anyrender_renderer::FillPaint::Solid( - crate::anyrender_renderer::Rgba { - r: c.get("r")?.as_f64()?.clamp(0.0, 1.0), - g: c.get("g")?.as_f64()?.clamp(0.0, 1.0), - b: c.get("b")?.as_f64()?.clamp(0.0, 1.0), - a: c.get("a") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0) - * paint - .get("opacity") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0) - .clamp(0.0, 1.0), - }, - )) - }) - } - "GRADIENT_LINEAR" => raw_gradient_stops_from_paint(paint).map(|stops| { - crate::anyrender_renderer::FillPaint::GradientLinear { - transform: raw_gradient_transform_from_paint(paint), - stops, - } - }), - "GRADIENT_RADIAL" => raw_gradient_stops_from_paint(paint).map(|stops| { - crate::anyrender_renderer::FillPaint::GradientRadial { - transform: raw_gradient_transform_from_paint(paint), - stops, - } - }), - _ => None, - }; - if let Some(fill) = parsed { - out.push(fill); - } - } - out -} diff --git a/crates/frame/Cargo.toml b/crates/frame/Cargo.toml index 96270006..01d55c65 100644 --- a/crates/frame/Cargo.toml +++ b/crates/frame/Cargo.toml @@ -1,11 +1,116 @@ [package] name = "frame" -description = "Frame — Figma-compatible design system (facade crate)" version.workspace = true edition.workspace = true - -[dependencies] -frame-proto = { path = "../frame-proto" } +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Frame: the design and control-surface engine — documents, surfaces, motion, feeds and themes, with rendering, IO, libraries, Figma import and the live and embedded hosts behind features" [lints] workspace = true + +[features] +default = ["vox"] +anyrender = ["dep:anyrender", "dep:anyrender_svg", "dep:anyrender_vello", "dep:image", "dep:peniko", "dep:pollster", "dep:skrifa", "dep:vello", "dep:winit", "svg_export"] +anyrender-0-11 = ["dep:anyrender_0_11", "dep:anyrender_svg_0_12"] +codec = ["io"] +cpu = ["dep:image", "dep:vello_cpu"] +design = ["io", "render", "runtime"] +dsp = [] +embed = ["cpu", "dep:peniko", "dep:skrifa", "render", "runtime"] +figma = ["dep:base64", "dep:figma-api", "dep:zip"] +io = ["dep:chrono", "dep:crdt", "dep:facet-styx", "dep:zip"] +library = ["architect/local", "architect/platform", "architect/vox", "codec", "dep:architect", "dep:tokio", "dep:vox-core", "dep:vox-websocket", "io", "library-proto-vox"] +library-proto-vox = ["architect/vox", "dep:vox", "vox"] +live = ["anyrender", "dep:peniko", "dep:vello", "io", "render", "runtime"] +nice-plug = ["cpu", "dep:nice-plug-core"] +remote = ["architect/vox", "dep:architect", "dep:tokio", "dep:vox-core", "dep:vox-websocket", "surface-runtime-vox"] +render = ["dep:base64", "dep:peniko", "dep:roxmltree", "dep:skrifa", "dep:taffy", "image/gif", "image/jpeg", "image/webp"] +rpc = ["dep:architect"] +runtime = ["architect/local", "architect/vox", "dep:architect", "dep:miniz_oxide", "surface-runtime-vox"] +surface-runtime-rpc = ["rpc"] +surface-runtime-vox = ["surface-runtime-rpc", "vox"] +svg_export = ["dep:fixed", "dep:rust_decimal", "dep:svg"] +theme = ["render"] +vox = ["architect/vox", "dep:vox", "rpc"] + +[dependencies] +anyrender = { git = "https://github.com/DioxusLabs/anyrender.git", branch = "main", optional = true } +# The host scene anyrender (0.11): the blitz fork the workspace patches +# crates.io anyrender to, named directly — dx's metadata reader cannot +# match a renamed dependency to a patched source. +anyrender_0_11 = { workspace = true, optional = true } +anyrender_svg_0_12 = { workspace = true, optional = true } +anyrender_svg = { workspace = true, optional = true } +anyrender_vello = { git = "https://github.com/DioxusLabs/anyrender.git", branch = "main", optional = true } +architect = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } +blake3 = { workspace = true } +chrono = { workspace = true, features = ["clock", "serde", "std", "wasmbind"], optional = true } +crdt = { workspace = true, optional = true } +facet = { workspace = true } +facet-styx = { workspace = true, optional = true } +figma-api = { workspace = true, optional = true } +fixed = { workspace = true, optional = true } +image = { workspace = true, features = ["png"], optional = true } +kurbo = { workspace = true } +miniz_oxide = { workspace = true, optional = true } +nice-plug-core = { workspace = true, optional = true } +peniko = { workspace = true, optional = true } +pollster = { workspace = true, optional = true } +roxmltree = { workspace = true, optional = true } +rust_decimal = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true, features = ["float_roundtrip"] } +skrifa = { version = "0.26", optional = true } +svg = { workspace = true, optional = true } +taffy = { workspace = true, optional = true } +thiserror = { workspace = true } +tokio = { workspace = true, optional = true } +uuid = { workspace = true } +vello = { version = "0.10.0", optional = true } +vello_cpu = { workspace = true, features = ["std", "u8_pipeline"], optional = true } +vox = { workspace = true, optional = true } +vox-core = { workspace = true, optional = true } +vox-websocket = { workspace = true, optional = true } +winit = { workspace = true, optional = true } +zip = { workspace = true, features = ["deflate"], optional = true } + +[dev-dependencies] +# Its own tests and examples, with the features they need (see their +# required-features): a plain `cargo test` of the workspace builds them all. +frame = { path = ".", features = ["anyrender", "cpu", "design", "dsp", "embed", "figma", "io", "library", "live", "render", "runtime", "surface-runtime-rpc", "theme"] } +architect = { workspace = true, features = ["vox", "local"] } +facet = { workspace = true } +facet-json = { workspace = true } +kurbo = { workspace = true } +peniko = { workspace = true } +pollster = { workspace = true } +proptest = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +vello = { version = "0.10.0" } +vox-core = { workspace = true } +vox-websocket = { workspace = true } +wgpu = { version = "29" } + +[[example]] +name = "strips" +required-features = ["cpu", "embed", "runtime"] + +[[example]] +name = "strip_bench" +path = "examples/strip_bench.rs" +required-features = ["theme"] + +[[example]] +name = "face_bench" +path = "examples/face_bench.rs" +required-features = ["live"] + +[[example]] +name = "face_frames" +path = "examples/face_frames.rs" +required-features = ["live"] + diff --git a/crates/frame/assets/README.md b/crates/frame/assets/README.md new file mode 100644 index 00000000..08441ab2 --- /dev/null +++ b/crates/frame/assets/README.md @@ -0,0 +1,38 @@ +# Bundled fonts + +Every family here is licensed under the **SIL Open Font License 1.1**; its +`OFL.txt` (copyright line and full licence) sits beside its fonts. They are +embedded into frame binaries by `render-fonts` (`src/bundled.rs`). + +Fonts **without** a Reserved Font Name were subset with fontTools +(`fonttools subset --unicodes=… --layout-features='*' --no-hinting`) to +Basic Latin, Latin-1, a few Latin Extended letters (Œ œ Š š Ÿ Ž ž ƒ ı), +general punctuation, super/subscripts, € ™ ℓ Ω, arrows ← → ↑ ↓, maths +(− ∞ ≈ ≠ ≤ ≥ √ ∑ …), geometric shapes and ♭ ♮ ♯. Fonts **with** a Reserved +Font Name ship exactly as released (only the file name changed), since the +OFL reserves that name for unmodified fonts. + +| Family | Files | Source | Reserved name | Treatment | Bytes | +|---|---|---|---|---|---| +| Inter | `inter/Inter-Variable.ttf`, `Inter-Italic-Variable.ttf` | rsms/inter (via google/fonts `ofl/inter`) | — | as released | 876,576 + 906,596 | +| Oswald | `oswald/Oswald-Variable.ttf` (wght 200–700) | google/fonts `ofl/oswald` | — | subset | 57,376 | +| Saira | `saira/Saira-Variable.ttf` (wdth 50–125, wght 100–900) | google/fonts `ofl/saira` | — | subset | 276,236 | +| Michroma | `michroma/Michroma-Regular.ttf` | google/fonts `ofl/michroma` | — | subset | 24,364 | +| Orbitron | `orbitron/Orbitron-Variable.ttf` (wght 400–900) | google/fonts `ofl/orbitron` | "Orbitron" | as released | 38,576 | +| Dancing Script | `dancing-script/DancingScript-Variable.ttf` (wght 400–700) | google/fonts `ofl/dancingscript` | "Dancing Script" | as released | 133,636 | +| Pacifico | `pacifico/Pacifico-Regular.ttf` | google/fonts `ofl/pacifico` | — | subset | 64,780 | +| Caveat Brush | `caveat-brush/CaveatBrush-Regular.ttf` | google/fonts `ofl/caveatbrush` | — | subset | 137,884 | +| Knewave | `knewave/Knewave-Regular.ttf` | google/fonts `ofl/knewave` | "Knewave" | as released | 32,460 | +| Playfair Display | `playfair-display/PlayfairDisplay-Variable.ttf` (wght 400–900) | google/fonts `ofl/playfairdisplay` | "Playfair Display" | as released | 300,724 | +| DSEG7 Classic | `dseg/DSEG7Classic-{Light,Regular,Bold,Italic,BoldItalic}.ttf` | keshikan/DSEG v0.46 release | "DSEG" | as released | 116,468 | +| DSEG14 Classic | `dseg/DSEG14Classic-{Regular,Bold,Italic}.ttf` | keshikan/DSEG v0.46 release | "DSEG" | as released | 86,188 | +| Doto | `doto/Doto-Variable.ttf` (ROND, wght 100–900) | google/fonts `ofl/doto` | — | subset | 286,684 | +| VT323 | `vt323/VT323-Regular.ttf` | google/fonts `ofl/vt323` | — | subset | 56,612 | +| JetBrains Mono | `jetbrains-mono/JetBrainsMono-Variable.ttf` (wght 100–800) | google/fonts `ofl/jetbrainsmono` | — | subset | 102,200 | +| UnifrakturCook | `unifraktur-cook/UnifrakturCook-Bold.ttf` | google/fonts `ofl/unifrakturcook` | "UnifrakturCook" | as released | 42,688 | +| Pirata One | `pirata-one/PirataOne-Regular.ttf` | google/fonts `ofl/pirataone` | "Pirata" | as released | 56,316 | + +Total: 3.67 MB of fonts (1.78 MB of it Inter, which frame already shipped). + +Not bundled: Permanent Marker and Rock Salt are Apache-2.0, not OFL — +Caveat Brush and Knewave cover marker lettering instead. diff --git a/crates/frame/assets/caveat-brush/CaveatBrush-Regular.ttf b/crates/frame/assets/caveat-brush/CaveatBrush-Regular.ttf new file mode 100644 index 00000000..f4dbb6c1 Binary files /dev/null and b/crates/frame/assets/caveat-brush/CaveatBrush-Regular.ttf differ diff --git a/crates/frame/assets/caveat-brush/OFL.txt b/crates/frame/assets/caveat-brush/OFL.txt new file mode 100644 index 00000000..25cc0c21 --- /dev/null +++ b/crates/frame/assets/caveat-brush/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/dancing-script/DancingScript-Variable.ttf b/crates/frame/assets/dancing-script/DancingScript-Variable.ttf new file mode 100644 index 00000000..9f521e7f Binary files /dev/null and b/crates/frame/assets/dancing-script/DancingScript-Variable.ttf differ diff --git a/crates/frame/assets/dancing-script/OFL.txt b/crates/frame/assets/dancing-script/OFL.txt new file mode 100644 index 00000000..07ff8df9 --- /dev/null +++ b/crates/frame/assets/dancing-script/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Dancing Script Project Authors (https://github.com/googlefonts/DancingScript), with Reserved Font Name 'Dancing Script'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/doto/Doto-Variable.ttf b/crates/frame/assets/doto/Doto-Variable.ttf new file mode 100644 index 00000000..7cf5683a Binary files /dev/null and b/crates/frame/assets/doto/Doto-Variable.ttf differ diff --git a/crates/frame/assets/doto/OFL.txt b/crates/frame/assets/doto/OFL.txt new file mode 100644 index 00000000..e0b8ec15 --- /dev/null +++ b/crates/frame/assets/doto/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Doto Project Authors (https://github.com/oliverlalan/Doto) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/dseg/DSEG14Classic-Bold.ttf b/crates/frame/assets/dseg/DSEG14Classic-Bold.ttf new file mode 100644 index 00000000..9e5a4233 Binary files /dev/null and b/crates/frame/assets/dseg/DSEG14Classic-Bold.ttf differ diff --git a/crates/frame/assets/dseg/DSEG14Classic-Italic.ttf b/crates/frame/assets/dseg/DSEG14Classic-Italic.ttf new file mode 100644 index 00000000..61a82617 Binary files /dev/null and b/crates/frame/assets/dseg/DSEG14Classic-Italic.ttf differ diff --git a/crates/frame/assets/dseg/DSEG14Classic-Regular.ttf b/crates/frame/assets/dseg/DSEG14Classic-Regular.ttf new file mode 100644 index 00000000..7c289347 Binary files /dev/null and b/crates/frame/assets/dseg/DSEG14Classic-Regular.ttf differ diff --git a/crates/frame/assets/dseg/DSEG7Classic-Bold.ttf b/crates/frame/assets/dseg/DSEG7Classic-Bold.ttf new file mode 100644 index 00000000..5f71db4e Binary files /dev/null and b/crates/frame/assets/dseg/DSEG7Classic-Bold.ttf differ diff --git a/crates/frame/assets/dseg/DSEG7Classic-BoldItalic.ttf b/crates/frame/assets/dseg/DSEG7Classic-BoldItalic.ttf new file mode 100644 index 00000000..de0b43a7 Binary files /dev/null and b/crates/frame/assets/dseg/DSEG7Classic-BoldItalic.ttf differ diff --git a/crates/frame/assets/dseg/DSEG7Classic-Italic.ttf b/crates/frame/assets/dseg/DSEG7Classic-Italic.ttf new file mode 100644 index 00000000..56383ba3 Binary files /dev/null and b/crates/frame/assets/dseg/DSEG7Classic-Italic.ttf differ diff --git a/crates/frame/assets/dseg/DSEG7Classic-Light.ttf b/crates/frame/assets/dseg/DSEG7Classic-Light.ttf new file mode 100644 index 00000000..642e9824 Binary files /dev/null and b/crates/frame/assets/dseg/DSEG7Classic-Light.ttf differ diff --git a/crates/frame/assets/dseg/DSEG7Classic-Regular.ttf b/crates/frame/assets/dseg/DSEG7Classic-Regular.ttf new file mode 100644 index 00000000..5e02edcc Binary files /dev/null and b/crates/frame/assets/dseg/DSEG7Classic-Regular.ttf differ diff --git a/crates/frame/assets/dseg/OFL.txt b/crates/frame/assets/dseg/OFL.txt new file mode 100755 index 00000000..ad4c0bf5 --- /dev/null +++ b/crates/frame/assets/dseg/OFL.txt @@ -0,0 +1,95 @@ +Copyright (c) 2017, keshikan (http://www.keshikan.net), +with Reserved Font Name "DSEG". + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame-ui/assets/inter/Inter-Italic-Variable.ttf b/crates/frame/assets/inter/Inter-Italic-Variable.ttf similarity index 100% rename from crates/frame-ui/assets/inter/Inter-Italic-Variable.ttf rename to crates/frame/assets/inter/Inter-Italic-Variable.ttf diff --git a/crates/frame-ui/assets/inter/Inter-Variable.ttf b/crates/frame/assets/inter/Inter-Variable.ttf similarity index 100% rename from crates/frame-ui/assets/inter/Inter-Variable.ttf rename to crates/frame/assets/inter/Inter-Variable.ttf diff --git a/crates/frame/assets/inter/OFL.txt b/crates/frame/assets/inter/OFL.txt new file mode 100644 index 00000000..21f6aff9 --- /dev/null +++ b/crates/frame/assets/inter/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/jetbrains-mono/JetBrainsMono-Variable.ttf b/crates/frame/assets/jetbrains-mono/JetBrainsMono-Variable.ttf new file mode 100644 index 00000000..a0ad11cf Binary files /dev/null and b/crates/frame/assets/jetbrains-mono/JetBrainsMono-Variable.ttf differ diff --git a/crates/frame/assets/jetbrains-mono/OFL.txt b/crates/frame/assets/jetbrains-mono/OFL.txt new file mode 100644 index 00000000..821a3dac --- /dev/null +++ b/crates/frame/assets/jetbrains-mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/knewave/Knewave-Regular.ttf b/crates/frame/assets/knewave/Knewave-Regular.ttf new file mode 100644 index 00000000..2b9ec11b Binary files /dev/null and b/crates/frame/assets/knewave/Knewave-Regular.ttf differ diff --git a/crates/frame/assets/knewave/OFL.txt b/crates/frame/assets/knewave/OFL.txt new file mode 100644 index 00000000..ce82823e --- /dev/null +++ b/crates/frame/assets/knewave/OFL.txt @@ -0,0 +1,105 @@ + Copyright (c) 2010, Tyler Finck , with Reserved Font Name: "Knewave". + + This Font Software is licensed under the SIL Open Font License, Version 1.1. + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + + Version 1.1 - 26 February 2007 + + +SIL Open Font License +==================================================== + + +Preamble +---------- + +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +Definitions +------------- + +`"Font Software"` refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +`"Reserved Font Name"` refers to any names specified as such after the +copyright statement(s). + +`"Original Version"` refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +`"Modified Version"` refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +`"Author"` refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +Permission & Conditions +------------------------ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1. Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2. Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3. No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5. The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +Termination +----------- + +This license becomes null and void if any of the above conditions are +not met. + + + DISCLAIMER + + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/michroma/Michroma-Regular.ttf b/crates/frame/assets/michroma/Michroma-Regular.ttf new file mode 100644 index 00000000..029b36d4 Binary files /dev/null and b/crates/frame/assets/michroma/Michroma-Regular.ttf differ diff --git a/crates/frame/assets/michroma/OFL.txt b/crates/frame/assets/michroma/OFL.txt new file mode 100644 index 00000000..2d0610cd --- /dev/null +++ b/crates/frame/assets/michroma/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/orbitron/OFL.txt b/crates/frame/assets/orbitron/OFL.txt new file mode 100644 index 00000000..91ede459 --- /dev/null +++ b/crates/frame/assets/orbitron/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Orbitron Project Authors (https://github.com/theleagueof/orbitron), with Reserved Font Name: "Orbitron" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/orbitron/Orbitron-Variable.ttf b/crates/frame/assets/orbitron/Orbitron-Variable.ttf new file mode 100644 index 00000000..1fc54ce7 Binary files /dev/null and b/crates/frame/assets/orbitron/Orbitron-Variable.ttf differ diff --git a/crates/frame/assets/oswald/OFL.txt b/crates/frame/assets/oswald/OFL.txt new file mode 100644 index 00000000..4d4e319b --- /dev/null +++ b/crates/frame/assets/oswald/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/oswald/Oswald-Variable.ttf b/crates/frame/assets/oswald/Oswald-Variable.ttf new file mode 100644 index 00000000..c85fdd1e Binary files /dev/null and b/crates/frame/assets/oswald/Oswald-Variable.ttf differ diff --git a/crates/frame/assets/pacifico/OFL.txt b/crates/frame/assets/pacifico/OFL.txt new file mode 100644 index 00000000..33864cc8 --- /dev/null +++ b/crates/frame/assets/pacifico/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/pacifico/Pacifico-Regular.ttf b/crates/frame/assets/pacifico/Pacifico-Regular.ttf new file mode 100644 index 00000000..79e04f24 Binary files /dev/null and b/crates/frame/assets/pacifico/Pacifico-Regular.ttf differ diff --git a/crates/frame/assets/pirata-one/OFL.txt b/crates/frame/assets/pirata-one/OFL.txt new file mode 100644 index 00000000..95567022 --- /dev/null +++ b/crates/frame/assets/pirata-one/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2012, Rodrigo Fuenzalida, Nicolas Massi (www.taip.com.ar / abc.taip.com.ar), with Reserved Font Name 'Pirata' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/pirata-one/PirataOne-Regular.ttf b/crates/frame/assets/pirata-one/PirataOne-Regular.ttf new file mode 100644 index 00000000..fadd34f3 Binary files /dev/null and b/crates/frame/assets/pirata-one/PirataOne-Regular.ttf differ diff --git a/crates/frame/assets/playfair-display/OFL.txt b/crates/frame/assets/playfair-display/OFL.txt new file mode 100644 index 00000000..13153890 --- /dev/null +++ b/crates/frame/assets/playfair-display/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/playfair-display/PlayfairDisplay-Variable.ttf b/crates/frame/assets/playfair-display/PlayfairDisplay-Variable.ttf new file mode 100644 index 00000000..7a09eb71 Binary files /dev/null and b/crates/frame/assets/playfair-display/PlayfairDisplay-Variable.ttf differ diff --git a/crates/frame/assets/saira/OFL.txt b/crates/frame/assets/saira/OFL.txt new file mode 100644 index 00000000..38d9b881 --- /dev/null +++ b/crates/frame/assets/saira/OFL.txt @@ -0,0 +1,94 @@ +Copyright 2020 The Saira Project Authors (https://github.com/Omnibus-Type/Saira) + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/saira/Saira-Variable.ttf b/crates/frame/assets/saira/Saira-Variable.ttf new file mode 100644 index 00000000..1c9f95ea Binary files /dev/null and b/crates/frame/assets/saira/Saira-Variable.ttf differ diff --git a/crates/frame/assets/unifraktur-cook/OFL.txt b/crates/frame/assets/unifraktur-cook/OFL.txt new file mode 100644 index 00000000..2c2ff5d3 --- /dev/null +++ b/crates/frame/assets/unifraktur-cook/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2010, j. 'mach' wust, with Reserved Font Name UnifrakturCook. +Copyright (c) 2009, Peter Wiegel. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/unifraktur-cook/UnifrakturCook-Bold.ttf b/crates/frame/assets/unifraktur-cook/UnifrakturCook-Bold.ttf new file mode 100644 index 00000000..cf9a8f9d Binary files /dev/null and b/crates/frame/assets/unifraktur-cook/UnifrakturCook-Bold.ttf differ diff --git a/crates/frame/assets/vt323/OFL.txt b/crates/frame/assets/vt323/OFL.txt new file mode 100644 index 00000000..bcb092f7 --- /dev/null +++ b/crates/frame/assets/vt323/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/frame/assets/vt323/VT323-Regular.ttf b/crates/frame/assets/vt323/VT323-Regular.ttf new file mode 100644 index 00000000..71654cd7 Binary files /dev/null and b/crates/frame/assets/vt323/VT323-Regular.ttf differ diff --git a/crates/frame/examples/embed/compare.png b/crates/frame/examples/embed/compare.png new file mode 100644 index 00000000..fa8ebe97 Binary files /dev/null and b/crates/frame/examples/embed/compare.png differ diff --git a/crates/frame/examples/embed/featured.png b/crates/frame/examples/embed/featured.png new file mode 100644 index 00000000..14db6d5a Binary files /dev/null and b/crates/frame/examples/embed/featured.png differ diff --git a/crates/frame/examples/embed/mixer-returns.png b/crates/frame/examples/embed/mixer-returns.png new file mode 100644 index 00000000..ecc41892 Binary files /dev/null and b/crates/frame/examples/embed/mixer-returns.png differ diff --git a/crates/frame/examples/embed/mixer-row-open.png b/crates/frame/examples/embed/mixer-row-open.png new file mode 100644 index 00000000..b690ae71 Binary files /dev/null and b/crates/frame/examples/embed/mixer-row-open.png differ diff --git a/crates/frame/examples/embed/mixer-row.png b/crates/frame/examples/embed/mixer-row.png new file mode 100644 index 00000000..be4f3596 Binary files /dev/null and b/crates/frame/examples/embed/mixer-row.png differ diff --git a/crates/frame/examples/embed/panel-comp.png b/crates/frame/examples/embed/panel-comp.png new file mode 100644 index 00000000..dcbcbd4e Binary files /dev/null and b/crates/frame/examples/embed/panel-comp.png differ diff --git a/crates/frame/examples/embed/panel-deess.png b/crates/frame/examples/embed/panel-deess.png new file mode 100644 index 00000000..7b21a349 Binary files /dev/null and b/crates/frame/examples/embed/panel-deess.png differ diff --git a/crates/frame/examples/embed/panel-delay.png b/crates/frame/examples/embed/panel-delay.png new file mode 100644 index 00000000..8a3d6dca Binary files /dev/null and b/crates/frame/examples/embed/panel-delay.png differ diff --git a/crates/frame/examples/embed/panel-eq.png b/crates/frame/examples/embed/panel-eq.png new file mode 100644 index 00000000..3d908f8b Binary files /dev/null and b/crates/frame/examples/embed/panel-eq.png differ diff --git a/crates/frame/examples/embed/panel-gate.png b/crates/frame/examples/embed/panel-gate.png new file mode 100644 index 00000000..69935c01 Binary files /dev/null and b/crates/frame/examples/embed/panel-gate.png differ diff --git a/crates/frame/examples/embed/panel-reverb.png b/crates/frame/examples/embed/panel-reverb.png new file mode 100644 index 00000000..e8ce409e Binary files /dev/null and b/crates/frame/examples/embed/panel-reverb.png differ diff --git a/crates/frame/examples/embed/panel-sat.png b/crates/frame/examples/embed/panel-sat.png new file mode 100644 index 00000000..c3ec5d1e Binary files /dev/null and b/crates/frame/examples/embed/panel-sat.png differ diff --git a/crates/frame/examples/face_bench.rs b/crates/frame/examples/face_bench.rs new file mode 100644 index 00000000..51e7af13 --- /dev/null +++ b/crates/frame/examples/face_bench.rs @@ -0,0 +1,79 @@ +//! Render a face over and over, moving, and time it: what one animated +//! face costs a host per frame. +//! +//! `cargo run --release -p frame-live --example face_bench -- [w h frames] [addr=value…]` + +use std::time::Instant; + +fn main() -> Result<(), String> { + let mut args = std::env::args().skip(1); + let markup = args + .next() + .ok_or("usage: face_bench [w h frames] [addr=value…]")?; + let rest: Vec = args.collect(); + let num = |i: usize, d: u32| rest.get(i).and_then(|v| v.parse().ok()).unwrap_or(d); + let (w, h, frames) = (num(0, 600), num(1, 400), num(2, 480)); + let values: Vec<(String, f64)> = rest + .iter() + .filter_map(|a| { + a.split_once('=') + .and_then(|(k, v)| v.parse().ok().map(|v| (k.to_string(), v))) + }) + .collect(); + + let path = std::path::PathBuf::from(&markup); + let dir = path + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_default(); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (markup, catalog) = frame::live::face_paths(&dir, &stem); + let mut live = frame::live::LiveSurface::open(&markup, catalog)?; + live.apply_real(values); + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .map_err(|e| format!("adapter: {e}"))?; + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) + .map_err(|e| format!("device: {e}"))?; + live.attach_gpu(device.clone(), queue)?; + live.set_max_fps(0); + + // CPU: building and submitting a frame (what the host's thread pays); + // total: that and the GPU finishing it. + let (mut cpu, mut total) = ( + Vec::with_capacity(frames as usize), + Vec::with_capacity(frames as usize), + ); + let mut moving = 0; + for _ in 0..frames { + moving += usize::from(live.is_moving()); + let at = Instant::now(); + live.render(w, h, 1.0)?; + cpu.push(at.elapsed().as_secs_f64() * 1000.0); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + total.push(at.elapsed().as_secs_f64() * 1000.0); + std::thread::sleep(std::time::Duration::from_millis(4)); + } + let stats = |mut v: Vec| { + v.sort_by(f64::total_cmp); + let mean = v.iter().sum::() / v.len() as f64; + format!( + "mean {mean:.3} ms p50 {:.3} p95 {:.3}", + v[v.len() / 2], + v[v.len() * 95 / 100] + ) + }; + println!( + "{} {w}x{h}: {frames} frames ({moving} moving)", + path.display() + ); + println!(" cpu {}", stats(cpu)); + println!(" total {}", stats(total)); + Ok(()) +} diff --git a/crates/frame/examples/face_frames.rs b/crates/frame/examples/face_frames.rs new file mode 100644 index 00000000..6185ce6c --- /dev/null +++ b/crates/frame/examples/face_frames.rs @@ -0,0 +1,126 @@ +//! Render a face through `LiveSurface` (the path a host takes, caches and +//! all) at intervals, and write each frame out: what actually moves. +//! +//! `cargo run --release -p frame-live --example face_frames -- [w h frames every_ms] [addr=value…]` +//! +//! Writes `/frame-NNN.ppm` and prints, per frame, how many pixels +//! changed from the one before. + +fn read(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture) -> Vec { + let (width, rows) = (texture.width(), texture.height()); + let unpadded = width * 4; + let padded = unpadded.div_ceil(256) * 256; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("face-frames-readback"), + size: u64::from(padded) * u64::from(rows), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = + device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(rows), + }, + }, + wgpu::Extent3d { + width, + height: rows, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + let slice = buffer.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + let mapped = slice.get_mapped_range(); + let mut rgba = vec![0_u8; (unpadded * rows) as usize]; + for row in 0..rows as usize { + rgba[row * unpadded as usize..(row + 1) * unpadded as usize].copy_from_slice( + &mapped[row * padded as usize..row * padded as usize + unpadded as usize], + ); + } + rgba +} + +fn main() -> Result<(), String> { + let mut args = std::env::args().skip(1); + let usage = "usage: face_frames [w h frames every_ms] [addr=value…]"; + let markup = args.next().ok_or(usage)?; + let out = std::path::PathBuf::from(args.next().ok_or(usage)?); + let rest: Vec = args.collect(); + let num = |i: usize, d: u32| rest.get(i).and_then(|v| v.parse().ok()).unwrap_or(d); + let (w, h, frames, every) = (num(0, 960), num(1, 164), num(2, 12), num(3, 100)); + let values: Vec<(String, f64)> = rest + .iter() + .filter_map(|a| { + a.split_once('=') + .and_then(|(k, v)| v.parse().ok().map(|v| (k.to_string(), v))) + }) + .collect(); + std::fs::create_dir_all(&out).map_err(|e| e.to_string())?; + + let path = std::path::PathBuf::from(&markup); + let dir = path + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_default(); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (markup, catalog) = frame::live::face_paths(&dir, &stem); + let mut live = frame::live::LiveSurface::open(&markup, catalog)?; + live.apply_real(values); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .map_err(|e| format!("adapter: {e}"))?; + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) + .map_err(|e| format!("device: {e}"))?; + live.attach_gpu(device.clone(), queue.clone())?; + live.set_max_fps( + std::env::var("FPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + ); + live.set_stretch(std::env::var_os("STRETCH").is_some()); + live.set_responsive(std::env::var_os("RESPONSIVE").is_some()); + + let mut last: Option> = None; + let mut prev: Option = None; + for i in 0..frames { + let (moving, redraw) = (live.is_moving(), live.needs_redraw()); + let (texture, fresh) = live.render(w, h, 1.0)?; + let px = read(&device, &queue, &texture); + let changed = last.as_ref().map_or(0, |l| { + l.chunks(4) + .zip(px.chunks(4)) + .filter(|(a, b)| a != b) + .count() + }); + println!( + "frame {i:3}: moving {moving} redraw {redraw} fresh {fresh} same texture {} changed {changed} px", + prev.as_ref().is_some_and(|p| *p == texture) + ); + prev = Some(texture.clone()); + let mut ppm = format!("P6\n{} {}\n255\n", texture.width(), texture.height()).into_bytes(); + ppm.extend(px.chunks(4).flat_map(|p| [p[0], p[1], p[2]])); + std::fs::write(out.join(format!("frame-{i:03}.ppm")), ppm).map_err(|e| e.to_string())?; + last = Some(px); + std::thread::sleep(std::time::Duration::from_millis(u64::from(every))); + } + Ok(()) +} diff --git a/crates/frame/examples/strip_bench.rs b/crates/frame/examples/strip_bench.rs new file mode 100644 index 00000000..d9b1003f --- /dev/null +++ b/crates/frame/examples/strip_bench.rs @@ -0,0 +1,90 @@ +//! How long a strip takes to record and to draw live, through a +//! [`Recording`] (the cost of building the commands a host's scene holds). +//! +//! ```sh +//! cargo run --release -p theme-runtime --example strip_bench +//! ``` + +use std::time::Instant; + +use frame::theme::runtime::{Recording, Rgba, Schema, Theme, Value}; +use kurbo::Affine; + +const STRIP: &str = r##" + + + + + + + + + + + + + + + + + + + + + Track + + + +"##; + +fn main() { + let schema = Schema::new() + .field("track.name") + .field("track.color") + .field("track.mute") + .live("track.fader") + .live("meter.level"); + let theme = Theme::from_markup(STRIP, schema, None).expect("compiles"); + let fields = |i: usize| { + vec![ + Value::text(&format!("Track {i}")), + Value::Color(Rgba::from_hex("#3366cc").unwrap()), + Value::Bool(i.is_multiple_of(3)), + Value::Num((i % 10) as f64 / 10.0), + Value::Num((i % 7) as f64 / 7.0), + ] + }; + let strips = 200; + let size = (86.0, 400.0); + + let t = Instant::now(); + theme.prepare("mcp.strip", size, &fields(0)); + println!( + "layout, once per size: {:>8.1} µs", + t.elapsed().as_secs_f64() * 1e6 + ); + + for pass in ["record", "live"] { + let mut best = f64::MAX; + let mut cmds = 0; + for _ in 0..20 { + let mut rec = Recording::new(); + let t = Instant::now(); + for i in 0..strips { + let at = Affine::translate((i as f64 * 87.0, 0.0)); + if pass == "record" { + theme.record("mcp.strip", size, &fields(i), &mut rec, at); + } else { + theme.live("mcp.strip", size, &fields(i), &mut rec, at); + } + } + best = best.min(t.elapsed().as_secs_f64()); + cmds = rec.len(); + } + println!( + "{pass:>6}, {strips} strips: {:>8.1} µs ({:.2} µs/strip, {cmds} commands)", + best * 1e6, + best * 1e6 / strips as f64 + ); + } +} diff --git a/crates/frame/examples/strips.rs b/crates/frame/examples/strips.rs new file mode 100644 index 00000000..8940f5d1 --- /dev/null +++ b/crates/frame/examples/strips.rs @@ -0,0 +1,196 @@ +//! Render the Tone rack and its panels at strip widths, as PNG strips. +//! +//! `cargo run -p frame-embed --example strips -- [out_dir] [--compare session.png]` +//! +//! Writes, into `out_dir` (default `examples/embed/`): +//! - `mixer-row.png`: the channel rack at session's widths — a rail (40), +//! REAPER's strip (86), a working strip (133), a focused one (300) — with +//! demo settings and feeds. +//! - `mixer-returns.png`: bus, delay and reverb returns at 86 and 133. +//! - `panel-.png`: each unit alone at MCP widths 60, 96, 160 (x1) and +//! 96 at x2. +//! - `featured.png`: the generic featured-param embed at REAPER's sizes. +//! - `compare.png` (with `--compare`): the mixer row beside a crop of +//! session's own rendering. + +use std::path::PathBuf; +use std::time::Instant; + +use frame::embed::cpu::{Pixels, Rasterizer, snapshot}; +use frame::embed::tone::{Which, chains, demo}; +use frame::embed::{Embed, EmbedHost, FeaturedView, TonePanel, ToneRack}; + +const BG: [u8; 4] = [0x09, 0x09, 0x0b, 0xff]; + +fn fed(mut e: Embed, units: &[Which], index: usize) -> Embed { + let values = demo::values(e.catalog(), units, index); + e.apply_values(values); + for f in demo::frames(e.catalog(), units, index, 40) { + e.apply_frame(f, 1000.0); + } + e +} + +fn rack(units: &[Which], width: f64, index: usize) -> Embed { + let mut e = Embed::new(ToneRack::new(units), EmbedHost::Strip); + let e0 = { + e.set_size(width, 100.0); + e.height_for(width).unwrap_or(600.0) + }; + e.set_size(width, e0); + fed(e, units, index) +} + +fn main() { + let mut args = std::env::args().skip(1); + let mut out = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/examples/embed")); + let mut compare: Option = None; + while let Some(a) = args.next() { + if a == "--compare" { + compare = args.next().map(PathBuf::from); + } else { + out = PathBuf::from(a); + } + } + std::fs::create_dir_all(&out).expect("out dir"); + let mut raster = Rasterizer::new(); + + // The mixer row. + let widths = [40.0, 86.0, 133.0, 300.0]; + let t0 = Instant::now(); + let row: Vec = widths + .iter() + .enumerate() + .map(|(i, w)| { + snapshot( + &mut raster, + &mut rack(&chains::CHANNEL, *w, i + 1), + 1.0, + 1000.0, + ) + }) + .collect(); + let mixer = Pixels::row(&row, 2, BG); + mixer.save_png(out.join("mixer-row.png")).expect("png"); + eprintln!( + "mixer-row: {}x{} in {:?}", + mixer.width, + mixer.height, + t0.elapsed() + ); + + // The same row with Rescue unfolded: every unit of the chain. + let open: Vec = widths + .iter() + .enumerate() + .map(|(i, w)| { + let mut e = rack(&chains::CHANNEL, *w, i + 1); + e.apply_values([frame::surface::proto::ParamValue { + addr: frame::surface::proto::ParamAddr::new("rack/fold_rescue"), + value: 0.0, + }]); + let h = e.height_for(*w).unwrap_or(1800.0); + e.set_size(*w, h); + snapshot(&mut raster, &mut e, 1.0, 1000.0) + }) + .collect(); + Pixels::row(&open, 2, BG) + .save_png(out.join("mixer-row-open.png")) + .expect("png"); + + let mut returns = Vec::new(); + for w in [86.0, 133.0] { + for (i, units) in [ + &chains::BUS[..], + &chains::DELAY[..], + &chains::REVERB[..], + &chains::FUND[..], + ] + .into_iter() + .enumerate() + { + returns.push(snapshot( + &mut raster, + &mut rack(units, w, 10 + i), + 1.0, + 1000.0, + )); + } + } + Pixels::row(&returns, 2, BG) + .save_png(out.join("mixer-returns.png")) + .expect("png"); + + // Each unit alone at MCP widths. + for which in Which::ALL { + if matches!( + which, + Which::RescueEq | Which::RescueComp | Which::PolishEq | Which::Space + ) { + continue; + } + let mut shots = Vec::new(); + for (w, scale) in [ + (60.0, 1.0), + (96.0, 1.0), + (160.0, 1.0), + (300.0, 1.0), + (96.0, 2.0), + ] { + let mut e = Embed::new(TonePanel::new(which), EmbedHost::Mcp); + e.set_size(w, which.natural()); + let mut e = fed(e, &[which], 3); + shots.push(snapshot(&mut raster, &mut e, scale, 1000.0)); + } + let name = which.ns(); + Pixels::row(&shots, 4, BG) + .save_png(out.join(format!("panel-{name}.png"))) + .expect("png"); + } + + // The generic embed at REAPER's sizes. + let catalog = frame::surface::memory::demo_catalog(); + let mut shots = Vec::new(); + for (w, h) in [(60.0, 120.0), (96.0, 200.0), (160.0, 300.0), (300.0, 40.0)] { + let host = if h < w { + EmbedHost::Tcp + } else { + EmbedHost::Mcp + }; + let mut e = Embed::new(FeaturedView::new(catalog.clone()), host); + e.set_size(w, h); + shots.push(snapshot(&mut raster, &mut e, 1.0, 0.0)); + } + Pixels::row(&shots, 4, BG) + .save_png(out.join("featured.png")) + .expect("png"); + + if let Some(path) = compare { + let img = image::open(&path).expect("session png").to_rgba8(); + // session's golden scenes are 2560 x 1440; the first strips' racks + // start about 44 px in and 30 px down. + let (cw, ch) = ( + row.iter() + .map(|p| p.width) + .sum::() + .min(img.width() - 40), + mixer.height.min(img.height() - 30).min(900), + ); + let crop = image::imageops::crop_imm(&img, 44, 30, cw, ch).to_image(); + let theirs = Pixels { + width: cw, + height: ch, + rgba: crop.into_raw(), + }; + let ours = Pixels::column(std::slice::from_ref(&mixer), 0, BG); + let ours = Pixels { + width: ours.width, + height: ch.min(ours.height), + rgba: ours.rgba[..(ours.width * ch.min(ours.height) * 4) as usize].to_vec(), + }; + Pixels::row(&[theirs, ours], 12, [0x30, 0x30, 0x36, 0xff]) + .save_png(out.join("compare.png")) + .expect("png"); + } + eprintln!("wrote {}", out.display()); +} diff --git a/crates/frame/examples/surfaces/01-stage.frame b/crates/frame/examples/surfaces/01-stage.frame new file mode 100644 index 00000000..34693613 --- /dev/null +++ b/crates/frame/examples/surfaces/01-stage.frame @@ -0,0 +1,92 @@ + + + + Sunday Set — Guitar Rig + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Reverb Mix + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/frame/examples/surfaces/02-pedalboard.frame b/crates/frame/examples/surfaces/02-pedalboard.frame new file mode 100644 index 00000000..797451da --- /dev/null +++ b/crates/frame/examples/surfaces/02-pedalboard.frame @@ -0,0 +1,119 @@ + + + + + OVERDRIVE + + + Amount + + + + + + + + + + + + + + + + + + + + + + + Overdrive On + + + + + ECHO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Echo On + + + + + HALL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hall On + + + + + + diff --git a/crates/frame/examples/surfaces/03-amp-head.frame b/crates/frame/examples/surfaces/03-amp-head.frame new file mode 100644 index 00000000..81afb69e --- /dev/null +++ b/crates/frame/examples/surfaces/03-amp-head.frame @@ -0,0 +1,82 @@ + + + + + FRAME 50 + hand-wired · two channel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/frame/examples/surfaces/04-mixer.frame b/crates/frame/examples/surfaces/04-mixer.frame new file mode 100644 index 00000000..e31ca11d --- /dev/null +++ b/crates/frame/examples/surfaces/04-mixer.frame @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + Amp Master + + + + + + + + + + + Main + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/frame/examples/surfaces/05-generated-dark.frame b/crates/frame/examples/surfaces/05-generated-dark.frame new file mode 100644 index 00000000..d671dd6f --- /dev/null +++ b/crates/frame/examples/surfaces/05-generated-dark.frame @@ -0,0 +1,240 @@ + + + + + + AMP + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DRIVE + + + + + + + + + + + + + + + + + + + + + + + + + DELAY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REVERB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OUTPUT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/frame/examples/surfaces/06-generated-daylight.frame b/crates/frame/examples/surfaces/06-generated-daylight.frame new file mode 100644 index 00000000..537504c5 --- /dev/null +++ b/crates/frame/examples/surfaces/06-generated-daylight.frame @@ -0,0 +1,240 @@ + + + + + + AMP + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DRIVE + + + + + + + + + + + + + + + + + + + + + + + + + DELAY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REVERB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OUTPUT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/frame/examples/surfaces/07-fet-1176.frame b/crates/frame/examples/surfaces/07-fet-1176.frame new file mode 100644 index 00000000..b38d82ea --- /dev/null +++ b/crates/frame/examples/surfaces/07-fet-1176.frame @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FET LIMITING AMPLIFIER + frame · 1176-style + + ∞ + + 48 + + 36 + + 30 + + 24 + + 18 + + 12 + + 6 + + 0 + + + + + + + INPUT + + + ∞ + + 48 + + 36 + + 30 + + 24 + + 18 + + 12 + + 6 + + 0 + + + + + + + OUTPUT + + + 1 + + 2 + + 3 + + 4 + + 5 + + 6 + + 7 + + + + + + + ATTACK + + + 1 + + 2 + + 3 + + 4 + + 5 + + 6 + + 7 + + + + + + + RELEASE + + RATIO + + + All + + + + 20 + + + + 12 + + + + 8 + + + + 4 + + + + + + + + + 20 + + 10 + + 7 + + 5 + + 3 + + + + 0 + + + + 3 + + VU + + + + + + LIMITING AMPLIFIER + PEAK LIMITER + METER + + + GR + + + + +8 + + + + +4 + + + + Off + + + + diff --git a/crates/frame/examples/surfaces/08-glow-demo.frame b/crates/frame/examples/surfaces/08-glow-demo.frame new file mode 100644 index 00000000..2f4f6966 --- /dev/null +++ b/crates/frame/examples/surfaces/08-glow-demo.frame @@ -0,0 +1,65 @@ + + + + LIGHT + SHADER EFFECTS + + + + + + 20 + 10 + 5 + 0 + +3 + VU + + + + + + JEWELS + + + + + + + + LEDS + + + + + + + + + + CRT READOUT + + -12.4 dB + + + LIT KNOB + + + + + + + TUBE + + + + + + BACKLIT PANEL + + LIMITING AMPLIFIER + + + frame · effects stage · --fx gpu | fallback | off + + + diff --git a/crates/frame/examples/surfaces/09-comp-viz.frame b/crates/frame/examples/surfaces/09-comp-viz.frame new file mode 100644 index 00000000..9996319d --- /dev/null +++ b/crates/frame/examples/surfaces/09-comp-viz.frame @@ -0,0 +1,135 @@ + + + + COMPRESSOR + viz.comp — input and gain-reduction history, transfer curve, drag the threshold (wheel: ratio) + + + + + + + + + + + + + + + + + + IN + OUT + + + + THR + + + + -48 + -36 + -24 + -12 + -48 + -36 + -24 + -12 + + + + + + + + + + + 0.0 dB + + + + GAIN REDUCTION + + -48 + -36 + -24 + -12 + + + + + + + + + + + + + + + + + Threshold + + + + + + + + + + + + + + + + + + Ratio + + + + + + + + + + + + + + + + + + Knee + + + + + + + + + + + + + + + + + + Makeup + + + + + diff --git a/crates/frame/examples/surfaces/10-eq-graph.frame b/crates/frame/examples/surfaces/10-eq-graph.frame new file mode 100644 index 00000000..7046cf29 --- /dev/null +++ b/crates/frame/examples/surfaces/10-eq-graph.frame @@ -0,0 +1,421 @@ + + + + PARAMETRIC EQ + viz.eq_graph — double-click to add a band, drag to move (Alt: freq only, Cmd: Q), drag out to delete, double-click a band to rename, right-click for its menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 20 + 50 + 100 + 200 + 500 + 1k + 2k + 5k + 10k + 20k + + + + + + + + + + + + + + + + + + + + ✓ + Bell + + + ✓ + Low Shelf + + + ✓ + High Shelf + + + ✓ + Low Cut + + + ✓ + High Cut + + + ✓ + Notch + + + ✓ + Band Pass + + + ✓ + Tilt Shelf + + + ✓ + Flat Tilt + + + ✓ + All Pass + + + + + + ✓ + 0 dB/oct + + + ✓ + 6 dB/oct + + + ✓ + 12 dB/oct + + + ✓ + 18 dB/oct + + + ✓ + 24 dB/oct + + + ✓ + 30 dB/oct + + + ✓ + 36 dB/oct + + + ✓ + 48 dB/oct + + + ✓ + 72 dB/oct + + + ✓ + 96 dB/oct + + + ✓ + Brickwall + + + + + + ✓ + Stereo + + + ✓ + Left + + + ✓ + Right + + + ✓ + Mid + + + ✓ + Side + + + + + + BAND {n} · + + + + + + + + + ✓ + Bypass + + + ✓ + Focus + + + Invert Gain + + + + + + Shape › + + + + + + Slope › + + + + + + Stereo Placement › + + + + + + + + + Reset Gain + + + Rename + + + Delete Band + + + + + + GRAPH + + + + + + Add Band Here + + + + + + + + + + ● + + + + + + + + Name… + + + + + + + + + + F + + + + × + + + + + + + + + + + + + + + + + + FREQ + + + + + + + + + + + + + + + + + + + + GAIN + + + + + + + + + + + + + + + + + + + + Q + + + + + + SLOPE + + + + + + + + + + + + + + + + + + + DYN + + + + + + + + + + + + Q + + + + + + + + + ±12 dB + + + + + diff --git a/crates/frame/examples/surfaces/11-adsr.frame b/crates/frame/examples/surfaces/11-adsr.frame new file mode 100644 index 00000000..50b308aa --- /dev/null +++ b/crates/frame/examples/surfaces/11-adsr.frame @@ -0,0 +1,139 @@ + + + + AMP ENVELOPE + viz.adsr — drag A, D, S, R; dots are the sounding voices + + + + + + + + + + + + + + + 1s + 2s + 3s + + + + A + + + + + + D + + + + + + S + + + + + + R + + + + + ATTACK + + + + DECAY + + + + SUSTAIN + + + + RELEASE + + + + + + + + + + + + + + + + + + + Attack + + + + + + + + + + + + + + + + + + Decay + + + + + + + + + + + + + + + + + + Sustain + + + + + + + + + + + + + + + + + + Release + + + + + diff --git a/crates/frame/examples/surfaces/12-lit-comp.frame b/crates/frame/examples/surfaces/12-lit-comp.frame new file mode 100644 index 00000000..83fcaa54 --- /dev/null +++ b/crates/frame/examples/surfaces/12-lit-comp.frame @@ -0,0 +1,81 @@ + + + + COMPRESSOR + viz.comp_scope — processor's compressor panel: input, gain reduction, transfer curve; drag the threshold (wheel: ratio) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Threshold + + + + + + + + + + + + + + + + + + Ratio + + + + + + + + + + + + + + + + + + Knee + + + + + diff --git a/crates/frame/examples/surfaces/12-lit-delay.frame b/crates/frame/examples/surfaces/12-lit-delay.frame new file mode 100644 index 00000000..48af9eaf --- /dev/null +++ b/crates/frame/examples/surfaces/12-lit-delay.frame @@ -0,0 +1,177 @@ + + + + DELAY + viz.delay_taps — processor's delay lane: repeats up (left) and down (right), the machine's marks, the sweep + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Time + + + + + + + + + + + + + + + + + + Feedback + + + + + + + + + + + + + + + + + + Left + + + + + + + + + + + + + + + + + + Right + + + + + + + + + + + + + + + + + + Machine + + + + + + + + + + + + + + + + + + Tempo + + + + + diff --git a/crates/frame/examples/surfaces/12-lit-eq-glow.frame b/crates/frame/examples/surfaces/12-lit-eq-glow.frame new file mode 100644 index 00000000..9e5292f6 --- /dev/null +++ b/crates/frame/examples/surfaces/12-lit-eq-glow.frame @@ -0,0 +1,127 @@ + + + + EQ GLOW + viz.eq_glow — processor's EQ light: the analyser as a field, a bloom under each band; the response curve drawn over it + + + + + + + + + + + + + + + + + + + + + + + + + + + Band 1 + + + + + + + + + + + + + + + + + + Band 2 + + + + + + + + + + + + + + + + + + Band 3 + + + + + + + + + + + + + + + + + + Band 4 + + + + + + + + + + + + + + + + + + Band 5 + + + + + + + + + + + + + + + + + + Band 6 + + + + + diff --git a/crates/frame/examples/surfaces/12-lit-mod.frame b/crates/frame/examples/surfaces/12-lit-mod.frame new file mode 100644 index 00000000..b3c65a1b --- /dev/null +++ b/crates/frame/examples/surfaces/12-lit-mod.frame @@ -0,0 +1,130 @@ + + + + MODULATION + viz.mod_lfo — processor's modulation panel: a picture per engine, moving at the LFO rate + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rate + + + + + + + + + + + + + + + + + + Depth + + + + + + + + + + + + + + + + + + Mix + + + + + + + + + + + + + + + + + + Engine + + + + + diff --git a/crates/frame/examples/surfaces/12-lit-reverb.frame b/crates/frame/examples/surfaces/12-lit-reverb.frame new file mode 100644 index 00000000..f2b8fc22 --- /dev/null +++ b/crates/frame/examples/surfaces/12-lit-reverb.frame @@ -0,0 +1,163 @@ + + + + REVERB + viz.reverb_space — processor's reverb lane: pre-delay, early reflections, the tail and its damped core + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Decay + + + + + + + + + + + + + + + + + + Pre-delay + + + + + + + + + + + + + + + + + + Density + + + + + + + + + + + + + + + + + + Damping + + + + + + + + + + + + + + + + + + Space + + + + + + + + + + + + + + + + + + Tempo + + + + + diff --git a/crates/frame/examples/surfaces/13-rig-panels.frame b/crates/frame/examples/surfaces/13-rig-panels.frame new file mode 100644 index 00000000..c7f947a2 --- /dev/null +++ b/crates/frame/examples/surfaces/13-rig-panels.frame @@ -0,0 +1,1264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 20 + 50 + 100 + 200 + 500 + 1k + 2k + 5k + 10k + 20k + + + + + + + + + + + + + + + + + + + + ✓ + Bell + + + ✓ + Low Shelf + + + ✓ + High Shelf + + + ✓ + Low Cut + + + ✓ + High Cut + + + ✓ + Notch + + + ✓ + Band Pass + + + ✓ + Tilt Shelf + + + ✓ + Flat Tilt + + + ✓ + All Pass + + + + + + ✓ + 0 dB/oct + + + ✓ + 6 dB/oct + + + ✓ + 12 dB/oct + + + ✓ + 18 dB/oct + + + ✓ + 24 dB/oct + + + ✓ + 30 dB/oct + + + ✓ + 36 dB/oct + + + ✓ + 48 dB/oct + + + ✓ + 72 dB/oct + + + ✓ + 96 dB/oct + + + ✓ + Brickwall + + + + + + ✓ + Stereo + + + ✓ + Left + + + ✓ + Right + + + ✓ + Mid + + + ✓ + Side + + + + + + BAND {n} · + + + + + + + + + ✓ + Bypass + + + ✓ + Focus + + + Invert Gain + + + + + + Shape › + + + + + + Slope › + + + + + + Stereo Placement › + + + + + + + + + Reset Gain + + + Rename + + + Delete Band + + + + + + GRAPH + + + + + + Add Band Here + + + + + + + + + + ● + + + + + + + + Name… + + + + + + + + + + F + + + + × + + + + + + + + + + + + + + + + + + FREQ + + + + + + + + + + + + + + + + + + + + GAIN + + + + + + + + + + + + + + + + + + + + Q + + + + + + SLOPE + + + + + + + + + + + + + + + + + + + DYN + + + + + + + + + + + + Q + + + + + + + + + ±12 dB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/frame/examples/surfaces/adsr.catalog.json b/crates/frame/examples/surfaces/adsr.catalog.json new file mode 100644 index 00000000..7d10a9f7 --- /dev/null +++ b/crates/frame/examples/surfaces/adsr.catalog.json @@ -0,0 +1,94 @@ +{ + "id": "viz.adsr", + "version": 1, + "params": [ + { + "addr": "env/attack", + "name": "Attack", + "short_name": null, + "group": "Envelope", + "kind": "Continuous", + "range": { + "min": 0.001, + "max": 2.0, + "scale": "Logarithmic", + "unit": "Seconds", + "steps": null + }, + "default": 0.5765139939630113, + "bipolar": false, + "featured": false + }, + { + "addr": "env/decay", + "name": "Decay", + "short_name": null, + "group": "Envelope", + "kind": "Continuous", + "range": { + "min": 0.005, + "max": 4.0, + "scale": "Logarithmic", + "unit": "Seconds", + "steps": null + }, + "default": 0.6731594673936465, + "bipolar": false, + "featured": false + }, + { + "addr": "env/sustain", + "name": "Sustain", + "short_name": null, + "group": "Envelope", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.6, + "bipolar": false, + "featured": false + }, + { + "addr": "env/release", + "name": "Release", + "short_name": null, + "group": "Envelope", + "kind": "Continuous", + "range": { + "min": 0.005, + "max": 6.0, + "scale": "Logarithmic", + "unit": "Seconds", + "steps": null + }, + "default": 0.7607290684265153, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [ + { + "id": "env/voices", + "name": "Voices", + "kind": { + "Envelope": { + "stages": [ + "A", + "D", + "S", + "R" + ], + "voices": 4 + } + }, + "rate_hz": 60.0 + } + ], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/comp-viz.catalog.json b/crates/frame/examples/surfaces/comp-viz.catalog.json new file mode 100644 index 00000000..6130732d --- /dev/null +++ b/crates/frame/examples/surfaces/comp-viz.catalog.json @@ -0,0 +1,116 @@ +{ + "id": "viz.comp", + "version": 1, + "params": [ + { + "addr": "comp/threshold", + "name": "Threshold", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": -60.0, + "max": 0.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.6, + "bipolar": false, + "featured": true + }, + { + "addr": "comp/ratio", + "name": "Ratio", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 1.0, + "max": 20.0, + "scale": "Logarithmic", + "unit": "Ratio", + "steps": null + }, + "default": 0.46275642631951835, + "bipolar": false, + "featured": true + }, + { + "addr": "comp/knee", + "name": "Knee", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 24.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.25, + "bipolar": false, + "featured": false + }, + { + "addr": "comp/makeup", + "name": "Makeup", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 24.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [ + { + "id": "comp/in", + "name": "Input", + "kind": { + "Level": { + "channels": 1 + } + }, + "rate_hz": 30.0 + }, + { + "id": "comp/gr", + "name": "Gain Reduction", + "kind": "GainReduction", + "rate_hz": 30.0 + }, + { + "id": "comp/in_history", + "name": "Input History", + "kind": { + "History": { + "of": "Level", + "len": 300 + } + }, + "rate_hz": 60.0 + }, + { + "id": "comp/gr_history", + "name": "GR History", + "kind": { + "History": { + "of": "GainReduction", + "len": 300 + } + }, + "rate_hz": 60.0 + } + ], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/design_examples.py b/crates/frame/examples/surfaces/design_examples.py new file mode 100644 index 00000000..8939c1e5 --- /dev/null +++ b/crates/frame/examples/surfaces/design_examples.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Design the example surfaces through frame-mcp's tools — exactly the calls +a model makes — against the demo guitar catalog. + + python3 examples/surfaces/design_examples.py # rewrites examples/surfaces/*.frame +""" +import json, subprocess, sys, pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[2] +MCP = ROOT / "target/release/frame-mcp" +OUT = ROOT / "examples/surfaces" + + +def design(name, calls): + path = OUT / name + path.unlink(missing_ok=True) + msgs = [{"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}] + for i, (tool, args) in enumerate(calls): + msgs.append({"jsonrpc": "2.0", "id": i + 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}) + out = subprocess.run([str(MCP), "--file", str(path)], input="\n".join(map(json.dumps, msgs)) + "\n", + capture_output=True, text=True, check=True).stdout + for line in out.splitlines(): + r = json.loads(line).get("result", {}) + if r.get("isError"): + sys.exit(f"{name}: {r['content'][0]['text']}") + last = json.loads(out.splitlines()[-1])["result"]["content"][0]["text"] + print(f"{name}: {last.splitlines()[0]}") + for l in last.splitlines()[1:4]: + print(" ", l[:150]) + + +def ctl(parent, kind, bind, size="medium", name=None): + a = {"parent": parent, "kind": kind, "bind": bind, "size": size} + if name: + a["name"] = name + return ("add_control", a) + + +def write(parent, markup): + return ("write", {"parent": parent, "markup": markup}) + + +# 1 — a stage rig: big knobs, output strip, a row of footswitches. +design("01-stage.frame", [ + write("Surface", ''' + Sunday Set — Guitar Rig + + + + + +'''), + *[ctl("Tone", "knob", f"param:{a}", "large") for a in ["amp/gain", "drive/amount", "delay/mix", "reverb/mix"]], + ctl("Out", "fader", "param:rig/volume", name="Volume"), + ctl("Out", "meter", "stream:out_level:0", name="L"), + ctl("Out", "meter", "stream:out_level:1", name="R"), + *[ctl("Switches", "footswitch", b) for b in ["param:drive/on", "param:delay/on", "param:reverb/on", "action:tap_tempo", "action:next_song"]], + ("lint", {}), +]) + +# 2 — a pedalboard: three coloured enclosures, knobs on top, a stomp below. +pedals = [ + ("Overdrive", "#1f6f43", ["drive/amount", "drive/tone"], "drive/on"), + ("Echo", "#1d4f8f", ["delay/time", "delay/feedback", "delay/mix"], "delay/on"), + ("Hall", "#5b2d8e", ["reverb/decay", "reverb/tone", "reverb/mix"], "reverb/on"), +] +calls = [write("Surface", '')] +for name, color, knobs, stomp in pedals: + calls.append(write("Board", f''' + {name.upper()} + + +''')) + calls += [ctl(f"{name} knobs", "knob", f"param:{k}", "medium", name=k.split("/")[1].title()) for k in knobs] + calls.append(ctl(f"{name} stomp", "footswitch", f"param:{stomp}", name=f"{name} On")) +calls.append(("lint", {})) +design("02-pedalboard.frame", calls) + +# 3 — an amp head, daylight theme: one long row of big knobs. +design("03-amp-head.frame", [ + ("set_theme", {"theme": "daylight"}), + write("Surface", ''' + + FRAME 50 + hand-wired · two channel + + +'''), + *[ctl("Controls", "knob", f"param:amp/{k}", "large") for k in ["channel", "gain", "bass", "mid", "treble", "presence", "master"]], + ("lint", {}), +]) + +# 4 — a mixer: input meter, amp master fader, main fader + stereo meters + pan. +design("04-mixer.frame", [ + write("Surface", ''' + + + +'''), + ctl("Input", "meter", "stream:in_level:0", name="In"), + ctl("Input", "readout", "stream:tempo", name="Tempo"), + ctl("Amp", "fader", "param:amp/master", name="Amp Master"), + ctl("Main bus", "fader", "param:rig/volume", name="Main"), + ctl("Main bus", "meter", "stream:out_level:0", name="Out L"), + ctl("Main bus", "meter", "stream:out_level:1", name="Out R"), + ctl("Main bus", "knob", "param:rig/pan", "small", name="Pan"), + ("lint", {}), +]) + +# 5/6 — the generator's full surface, both themes. +design("05-generated-dark.frame", [("generate", {"theme": "stage-dark"}), ("lint", {})]) +design("06-generated-daylight.frame", [("generate", {"theme": "daylight"}), ("lint", {})]) diff --git a/crates/frame/examples/surfaces/eq-graph.catalog.json b/crates/frame/examples/surfaces/eq-graph.catalog.json new file mode 100644 index 00000000..dc1f03cf --- /dev/null +++ b/crates/frame/examples/surfaces/eq-graph.catalog.json @@ -0,0 +1,5600 @@ +{ + "id": "viz.eq", + "version": 1, + "params": [ + { + "addr": "eq/b1_used", + "name": "Band 1 Used", + "short_name": "Used", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_on", + "name": "Band 1 On", + "short_name": "On", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_freq", + "name": "Band 1 Freq", + "short_name": "Freq", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.17314897791118378, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_gain", + "name": "Band 1 Gain", + "short_name": "Gain", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b1_q", + "name": "Band 1 Q", + "short_name": "Q", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.45357800204546916, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_shape", + "name": "Band 1 Shape", + "short_name": "Shape", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.3333333333333333, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_slope", + "name": "Band 1 Slope", + "short_name": "Slope", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_name", + "name": "Band 1 Name", + "short_name": "Name", + "group": "Band 1", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_placement", + "name": "Band 1 Placement", + "short_name": "Placement", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_focus", + "name": "Band 1 Focus", + "short_name": "Focus", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_dyn_range", + "name": "Band 1 Dyn Range", + "short_name": "Dyn", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b2_used", + "name": "Band 2 Used", + "short_name": "Used", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_on", + "name": "Band 2 On", + "short_name": "On", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_freq", + "name": "Band 2 Freq", + "short_name": "Freq", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.3103662964248948, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_gain", + "name": "Band 2 Gain", + "short_name": "Gain", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.55, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b2_q", + "name": "Band 2 Q", + "short_name": "Q", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.45357800204546916, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_shape", + "name": "Band 2 Shape", + "short_name": "Shape", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.1111111111111111, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_slope", + "name": "Band 2 Slope", + "short_name": "Slope", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_name", + "name": "Band 2 Name", + "short_name": "Name", + "group": "Band 2", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_placement", + "name": "Band 2 Placement", + "short_name": "Placement", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_focus", + "name": "Band 2 Focus", + "short_name": "Focus", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_dyn_range", + "name": "Band 2 Dyn Range", + "short_name": "Dyn", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b3_used", + "name": "Band 3 Used", + "short_name": "Used", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_on", + "name": "Band 3 On", + "short_name": "On", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_freq", + "name": "Band 3 Freq", + "short_name": "Freq", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.46683712516340553, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_gain", + "name": "Band 3 Gain", + "short_name": "Gain", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.43333333333333335, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b3_q", + "name": "Band 3 Q", + "short_name": "Q", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.5456062932940204, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_shape", + "name": "Band 3 Shape", + "short_name": "Shape", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_slope", + "name": "Band 3 Slope", + "short_name": "Slope", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_name", + "name": "Band 3 Name", + "short_name": "Name", + "group": "Band 3", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_placement", + "name": "Band 3 Placement", + "short_name": "Placement", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_focus", + "name": "Band 3 Focus", + "short_name": "Focus", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_dyn_range", + "name": "Band 3 Dyn Range", + "short_name": "Dyn", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b4_used", + "name": "Band 4 Used", + "short_name": "Used", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_on", + "name": "Band 4 On", + "short_name": "On", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_freq", + "name": "Band 4 Freq", + "short_name": "Freq", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.6845350125425831, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_gain", + "name": "Band 4 Gain", + "short_name": "Gain", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5416666666666666, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b4_q", + "name": "Band 4 Q", + "short_name": "Q", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_shape", + "name": "Band 4 Shape", + "short_name": "Shape", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_slope", + "name": "Band 4 Slope", + "short_name": "Slope", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_name", + "name": "Band 4 Name", + "short_name": "Name", + "group": "Band 4", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_placement", + "name": "Band 4 Placement", + "short_name": "Placement", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_focus", + "name": "Band 4 Focus", + "short_name": "Focus", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_dyn_range", + "name": "Band 4 Dyn Range", + "short_name": "Dyn", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b5_used", + "name": "Band 5 Used", + "short_name": "Used", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_on", + "name": "Band 5 On", + "short_name": "On", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_freq", + "name": "Band 5 Freq", + "short_name": "Freq", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.8496230913516148, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_gain", + "name": "Band 5 Gain", + "short_name": "Gain", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5416666666666666, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b5_q", + "name": "Band 5 Q", + "short_name": "Q", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.45357800204546916, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_shape", + "name": "Band 5 Shape", + "short_name": "Shape", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.2222222222222222, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_slope", + "name": "Band 5 Slope", + "short_name": "Slope", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_name", + "name": "Band 5 Name", + "short_name": "Name", + "group": "Band 5", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_placement", + "name": "Band 5 Placement", + "short_name": "Placement", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_focus", + "name": "Band 5 Focus", + "short_name": "Focus", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_dyn_range", + "name": "Band 5 Dyn Range", + "short_name": "Dyn", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b6_used", + "name": "Band 6 Used", + "short_name": "Used", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_on", + "name": "Band 6 On", + "short_name": "On", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_freq", + "name": "Band 6 Freq", + "short_name": "Freq", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_gain", + "name": "Band 6 Gain", + "short_name": "Gain", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b6_q", + "name": "Band 6 Q", + "short_name": "Q", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_shape", + "name": "Band 6 Shape", + "short_name": "Shape", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_slope", + "name": "Band 6 Slope", + "short_name": "Slope", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_name", + "name": "Band 6 Name", + "short_name": "Name", + "group": "Band 6", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_placement", + "name": "Band 6 Placement", + "short_name": "Placement", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_focus", + "name": "Band 6 Focus", + "short_name": "Focus", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_dyn_range", + "name": "Band 6 Dyn Range", + "short_name": "Dyn", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b7_used", + "name": "Band 7 Used", + "short_name": "Used", + "group": "Band 7", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_on", + "name": "Band 7 On", + "short_name": "On", + "group": "Band 7", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_freq", + "name": "Band 7 Freq", + "short_name": "Freq", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_gain", + "name": "Band 7 Gain", + "short_name": "Gain", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b7_q", + "name": "Band 7 Q", + "short_name": "Q", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_shape", + "name": "Band 7 Shape", + "short_name": "Shape", + "group": "Band 7", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_slope", + "name": "Band 7 Slope", + "short_name": "Slope", + "group": "Band 7", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_name", + "name": "Band 7 Name", + "short_name": "Name", + "group": "Band 7", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_placement", + "name": "Band 7 Placement", + "short_name": "Placement", + "group": "Band 7", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_focus", + "name": "Band 7 Focus", + "short_name": "Focus", + "group": "Band 7", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_dyn_range", + "name": "Band 7 Dyn Range", + "short_name": "Dyn", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b8_used", + "name": "Band 8 Used", + "short_name": "Used", + "group": "Band 8", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_on", + "name": "Band 8 On", + "short_name": "On", + "group": "Band 8", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_freq", + "name": "Band 8 Freq", + "short_name": "Freq", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_gain", + "name": "Band 8 Gain", + "short_name": "Gain", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b8_q", + "name": "Band 8 Q", + "short_name": "Q", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_shape", + "name": "Band 8 Shape", + "short_name": "Shape", + "group": "Band 8", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_slope", + "name": "Band 8 Slope", + "short_name": "Slope", + "group": "Band 8", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_name", + "name": "Band 8 Name", + "short_name": "Name", + "group": "Band 8", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_placement", + "name": "Band 8 Placement", + "short_name": "Placement", + "group": "Band 8", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_focus", + "name": "Band 8 Focus", + "short_name": "Focus", + "group": "Band 8", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_dyn_range", + "name": "Band 8 Dyn Range", + "short_name": "Dyn", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b9_used", + "name": "Band 9 Used", + "short_name": "Used", + "group": "Band 9", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_on", + "name": "Band 9 On", + "short_name": "On", + "group": "Band 9", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_freq", + "name": "Band 9 Freq", + "short_name": "Freq", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_gain", + "name": "Band 9 Gain", + "short_name": "Gain", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b9_q", + "name": "Band 9 Q", + "short_name": "Q", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_shape", + "name": "Band 9 Shape", + "short_name": "Shape", + "group": "Band 9", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_slope", + "name": "Band 9 Slope", + "short_name": "Slope", + "group": "Band 9", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_name", + "name": "Band 9 Name", + "short_name": "Name", + "group": "Band 9", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_placement", + "name": "Band 9 Placement", + "short_name": "Placement", + "group": "Band 9", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_focus", + "name": "Band 9 Focus", + "short_name": "Focus", + "group": "Band 9", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_dyn_range", + "name": "Band 9 Dyn Range", + "short_name": "Dyn", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b10_used", + "name": "Band 10 Used", + "short_name": "Used", + "group": "Band 10", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_on", + "name": "Band 10 On", + "short_name": "On", + "group": "Band 10", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_freq", + "name": "Band 10 Freq", + "short_name": "Freq", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_gain", + "name": "Band 10 Gain", + "short_name": "Gain", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b10_q", + "name": "Band 10 Q", + "short_name": "Q", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_shape", + "name": "Band 10 Shape", + "short_name": "Shape", + "group": "Band 10", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_slope", + "name": "Band 10 Slope", + "short_name": "Slope", + "group": "Band 10", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_name", + "name": "Band 10 Name", + "short_name": "Name", + "group": "Band 10", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_placement", + "name": "Band 10 Placement", + "short_name": "Placement", + "group": "Band 10", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_focus", + "name": "Band 10 Focus", + "short_name": "Focus", + "group": "Band 10", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_dyn_range", + "name": "Band 10 Dyn Range", + "short_name": "Dyn", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b11_used", + "name": "Band 11 Used", + "short_name": "Used", + "group": "Band 11", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_on", + "name": "Band 11 On", + "short_name": "On", + "group": "Band 11", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_freq", + "name": "Band 11 Freq", + "short_name": "Freq", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_gain", + "name": "Band 11 Gain", + "short_name": "Gain", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b11_q", + "name": "Band 11 Q", + "short_name": "Q", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_shape", + "name": "Band 11 Shape", + "short_name": "Shape", + "group": "Band 11", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_slope", + "name": "Band 11 Slope", + "short_name": "Slope", + "group": "Band 11", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_name", + "name": "Band 11 Name", + "short_name": "Name", + "group": "Band 11", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_placement", + "name": "Band 11 Placement", + "short_name": "Placement", + "group": "Band 11", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_focus", + "name": "Band 11 Focus", + "short_name": "Focus", + "group": "Band 11", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_dyn_range", + "name": "Band 11 Dyn Range", + "short_name": "Dyn", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b12_used", + "name": "Band 12 Used", + "short_name": "Used", + "group": "Band 12", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_on", + "name": "Band 12 On", + "short_name": "On", + "group": "Band 12", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_freq", + "name": "Band 12 Freq", + "short_name": "Freq", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_gain", + "name": "Band 12 Gain", + "short_name": "Gain", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b12_q", + "name": "Band 12 Q", + "short_name": "Q", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_shape", + "name": "Band 12 Shape", + "short_name": "Shape", + "group": "Band 12", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_slope", + "name": "Band 12 Slope", + "short_name": "Slope", + "group": "Band 12", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_name", + "name": "Band 12 Name", + "short_name": "Name", + "group": "Band 12", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_placement", + "name": "Band 12 Placement", + "short_name": "Placement", + "group": "Band 12", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_focus", + "name": "Band 12 Focus", + "short_name": "Focus", + "group": "Band 12", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_dyn_range", + "name": "Band 12 Dyn Range", + "short_name": "Dyn", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b13_used", + "name": "Band 13 Used", + "short_name": "Used", + "group": "Band 13", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_on", + "name": "Band 13 On", + "short_name": "On", + "group": "Band 13", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_freq", + "name": "Band 13 Freq", + "short_name": "Freq", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_gain", + "name": "Band 13 Gain", + "short_name": "Gain", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b13_q", + "name": "Band 13 Q", + "short_name": "Q", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_shape", + "name": "Band 13 Shape", + "short_name": "Shape", + "group": "Band 13", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_slope", + "name": "Band 13 Slope", + "short_name": "Slope", + "group": "Band 13", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_name", + "name": "Band 13 Name", + "short_name": "Name", + "group": "Band 13", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_placement", + "name": "Band 13 Placement", + "short_name": "Placement", + "group": "Band 13", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_focus", + "name": "Band 13 Focus", + "short_name": "Focus", + "group": "Band 13", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_dyn_range", + "name": "Band 13 Dyn Range", + "short_name": "Dyn", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b14_used", + "name": "Band 14 Used", + "short_name": "Used", + "group": "Band 14", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_on", + "name": "Band 14 On", + "short_name": "On", + "group": "Band 14", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_freq", + "name": "Band 14 Freq", + "short_name": "Freq", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_gain", + "name": "Band 14 Gain", + "short_name": "Gain", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b14_q", + "name": "Band 14 Q", + "short_name": "Q", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_shape", + "name": "Band 14 Shape", + "short_name": "Shape", + "group": "Band 14", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_slope", + "name": "Band 14 Slope", + "short_name": "Slope", + "group": "Band 14", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_name", + "name": "Band 14 Name", + "short_name": "Name", + "group": "Band 14", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_placement", + "name": "Band 14 Placement", + "short_name": "Placement", + "group": "Band 14", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_focus", + "name": "Band 14 Focus", + "short_name": "Focus", + "group": "Band 14", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_dyn_range", + "name": "Band 14 Dyn Range", + "short_name": "Dyn", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b15_used", + "name": "Band 15 Used", + "short_name": "Used", + "group": "Band 15", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_on", + "name": "Band 15 On", + "short_name": "On", + "group": "Band 15", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_freq", + "name": "Band 15 Freq", + "short_name": "Freq", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_gain", + "name": "Band 15 Gain", + "short_name": "Gain", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b15_q", + "name": "Band 15 Q", + "short_name": "Q", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_shape", + "name": "Band 15 Shape", + "short_name": "Shape", + "group": "Band 15", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_slope", + "name": "Band 15 Slope", + "short_name": "Slope", + "group": "Band 15", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_name", + "name": "Band 15 Name", + "short_name": "Name", + "group": "Band 15", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_placement", + "name": "Band 15 Placement", + "short_name": "Placement", + "group": "Band 15", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_focus", + "name": "Band 15 Focus", + "short_name": "Focus", + "group": "Band 15", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_dyn_range", + "name": "Band 15 Dyn Range", + "short_name": "Dyn", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b16_used", + "name": "Band 16 Used", + "short_name": "Used", + "group": "Band 16", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_on", + "name": "Band 16 On", + "short_name": "On", + "group": "Band 16", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_freq", + "name": "Band 16 Freq", + "short_name": "Freq", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_gain", + "name": "Band 16 Gain", + "short_name": "Gain", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b16_q", + "name": "Band 16 Q", + "short_name": "Q", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_shape", + "name": "Band 16 Shape", + "short_name": "Shape", + "group": "Band 16", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_slope", + "name": "Band 16 Slope", + "short_name": "Slope", + "group": "Band 16", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_name", + "name": "Band 16 Name", + "short_name": "Name", + "group": "Band 16", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_placement", + "name": "Band 16 Placement", + "short_name": "Placement", + "group": "Band 16", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_focus", + "name": "Band 16 Focus", + "short_name": "Focus", + "group": "Band 16", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_dyn_range", + "name": "Band 16 Dyn Range", + "short_name": "Dyn", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b17_used", + "name": "Band 17 Used", + "short_name": "Used", + "group": "Band 17", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_on", + "name": "Band 17 On", + "short_name": "On", + "group": "Band 17", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_freq", + "name": "Band 17 Freq", + "short_name": "Freq", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_gain", + "name": "Band 17 Gain", + "short_name": "Gain", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b17_q", + "name": "Band 17 Q", + "short_name": "Q", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_shape", + "name": "Band 17 Shape", + "short_name": "Shape", + "group": "Band 17", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_slope", + "name": "Band 17 Slope", + "short_name": "Slope", + "group": "Band 17", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_name", + "name": "Band 17 Name", + "short_name": "Name", + "group": "Band 17", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_placement", + "name": "Band 17 Placement", + "short_name": "Placement", + "group": "Band 17", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_focus", + "name": "Band 17 Focus", + "short_name": "Focus", + "group": "Band 17", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_dyn_range", + "name": "Band 17 Dyn Range", + "short_name": "Dyn", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b18_used", + "name": "Band 18 Used", + "short_name": "Used", + "group": "Band 18", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_on", + "name": "Band 18 On", + "short_name": "On", + "group": "Band 18", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_freq", + "name": "Band 18 Freq", + "short_name": "Freq", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_gain", + "name": "Band 18 Gain", + "short_name": "Gain", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b18_q", + "name": "Band 18 Q", + "short_name": "Q", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_shape", + "name": "Band 18 Shape", + "short_name": "Shape", + "group": "Band 18", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_slope", + "name": "Band 18 Slope", + "short_name": "Slope", + "group": "Band 18", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_name", + "name": "Band 18 Name", + "short_name": "Name", + "group": "Band 18", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_placement", + "name": "Band 18 Placement", + "short_name": "Placement", + "group": "Band 18", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_focus", + "name": "Band 18 Focus", + "short_name": "Focus", + "group": "Band 18", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_dyn_range", + "name": "Band 18 Dyn Range", + "short_name": "Dyn", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b19_used", + "name": "Band 19 Used", + "short_name": "Used", + "group": "Band 19", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_on", + "name": "Band 19 On", + "short_name": "On", + "group": "Band 19", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_freq", + "name": "Band 19 Freq", + "short_name": "Freq", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_gain", + "name": "Band 19 Gain", + "short_name": "Gain", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b19_q", + "name": "Band 19 Q", + "short_name": "Q", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_shape", + "name": "Band 19 Shape", + "short_name": "Shape", + "group": "Band 19", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_slope", + "name": "Band 19 Slope", + "short_name": "Slope", + "group": "Band 19", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_name", + "name": "Band 19 Name", + "short_name": "Name", + "group": "Band 19", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_placement", + "name": "Band 19 Placement", + "short_name": "Placement", + "group": "Band 19", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_focus", + "name": "Band 19 Focus", + "short_name": "Focus", + "group": "Band 19", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_dyn_range", + "name": "Band 19 Dyn Range", + "short_name": "Dyn", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b20_used", + "name": "Band 20 Used", + "short_name": "Used", + "group": "Band 20", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_on", + "name": "Band 20 On", + "short_name": "On", + "group": "Band 20", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_freq", + "name": "Band 20 Freq", + "short_name": "Freq", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_gain", + "name": "Band 20 Gain", + "short_name": "Gain", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b20_q", + "name": "Band 20 Q", + "short_name": "Q", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_shape", + "name": "Band 20 Shape", + "short_name": "Shape", + "group": "Band 20", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_slope", + "name": "Band 20 Slope", + "short_name": "Slope", + "group": "Band 20", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_name", + "name": "Band 20 Name", + "short_name": "Name", + "group": "Band 20", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_placement", + "name": "Band 20 Placement", + "short_name": "Placement", + "group": "Band 20", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_focus", + "name": "Band 20 Focus", + "short_name": "Focus", + "group": "Band 20", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_dyn_range", + "name": "Band 20 Dyn Range", + "short_name": "Dyn", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b21_used", + "name": "Band 21 Used", + "short_name": "Used", + "group": "Band 21", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_on", + "name": "Band 21 On", + "short_name": "On", + "group": "Band 21", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_freq", + "name": "Band 21 Freq", + "short_name": "Freq", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_gain", + "name": "Band 21 Gain", + "short_name": "Gain", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b21_q", + "name": "Band 21 Q", + "short_name": "Q", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_shape", + "name": "Band 21 Shape", + "short_name": "Shape", + "group": "Band 21", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_slope", + "name": "Band 21 Slope", + "short_name": "Slope", + "group": "Band 21", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_name", + "name": "Band 21 Name", + "short_name": "Name", + "group": "Band 21", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_placement", + "name": "Band 21 Placement", + "short_name": "Placement", + "group": "Band 21", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_focus", + "name": "Band 21 Focus", + "short_name": "Focus", + "group": "Band 21", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_dyn_range", + "name": "Band 21 Dyn Range", + "short_name": "Dyn", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b22_used", + "name": "Band 22 Used", + "short_name": "Used", + "group": "Band 22", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_on", + "name": "Band 22 On", + "short_name": "On", + "group": "Band 22", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_freq", + "name": "Band 22 Freq", + "short_name": "Freq", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_gain", + "name": "Band 22 Gain", + "short_name": "Gain", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b22_q", + "name": "Band 22 Q", + "short_name": "Q", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_shape", + "name": "Band 22 Shape", + "short_name": "Shape", + "group": "Band 22", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_slope", + "name": "Band 22 Slope", + "short_name": "Slope", + "group": "Band 22", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_name", + "name": "Band 22 Name", + "short_name": "Name", + "group": "Band 22", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_placement", + "name": "Band 22 Placement", + "short_name": "Placement", + "group": "Band 22", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_focus", + "name": "Band 22 Focus", + "short_name": "Focus", + "group": "Band 22", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_dyn_range", + "name": "Band 22 Dyn Range", + "short_name": "Dyn", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b23_used", + "name": "Band 23 Used", + "short_name": "Used", + "group": "Band 23", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_on", + "name": "Band 23 On", + "short_name": "On", + "group": "Band 23", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_freq", + "name": "Band 23 Freq", + "short_name": "Freq", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_gain", + "name": "Band 23 Gain", + "short_name": "Gain", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b23_q", + "name": "Band 23 Q", + "short_name": "Q", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_shape", + "name": "Band 23 Shape", + "short_name": "Shape", + "group": "Band 23", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_slope", + "name": "Band 23 Slope", + "short_name": "Slope", + "group": "Band 23", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_name", + "name": "Band 23 Name", + "short_name": "Name", + "group": "Band 23", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_placement", + "name": "Band 23 Placement", + "short_name": "Placement", + "group": "Band 23", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_focus", + "name": "Band 23 Focus", + "short_name": "Focus", + "group": "Band 23", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_dyn_range", + "name": "Band 23 Dyn Range", + "short_name": "Dyn", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b24_used", + "name": "Band 24 Used", + "short_name": "Used", + "group": "Band 24", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_on", + "name": "Band 24 On", + "short_name": "On", + "group": "Band 24", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_freq", + "name": "Band 24 Freq", + "short_name": "Freq", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_gain", + "name": "Band 24 Gain", + "short_name": "Gain", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b24_q", + "name": "Band 24 Q", + "short_name": "Q", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_shape", + "name": "Band 24 Shape", + "short_name": "Shape", + "group": "Band 24", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_slope", + "name": "Band 24 Slope", + "short_name": "Slope", + "group": "Band 24", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_name", + "name": "Band 24 Name", + "short_name": "Name", + "group": "Band 24", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_placement", + "name": "Band 24 Placement", + "short_name": "Placement", + "group": "Band 24", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_focus", + "name": "Band 24 Focus", + "short_name": "Focus", + "group": "Band 24", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_dyn_range", + "name": "Band 24 Dyn Range", + "short_name": "Dyn", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/range", + "name": "Display Range", + "short_name": "Range", + "group": "View", + "kind": { + "Choice": { + "options": [ + "±3 dB", + "±6 dB", + "±12 dB", + "±18 dB", + "±24 dB", + "±30 dB" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.4, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/show_pre", + "name": "Pre-EQ Analyser", + "short_name": "PRE", + "group": "View", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/show_post", + "name": "Post-EQ Analyser", + "short_name": "POST", + "group": "View", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/show_ext", + "name": "External Analyser", + "short_name": "EXT", + "group": "View", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [ + { + "id": "eq/spectrum", + "name": "Spectrum", + "kind": { + "Spectrum": { + "bins": 128, + "f_min": 20.0, + "f_max": 20000.0, + "floor_db": -90.0 + } + }, + "rate_hz": 30.0 + }, + { + "id": "eq/spectrum_pre", + "name": "Spectrum (pre)", + "kind": { + "Spectrum": { + "bins": 128, + "f_min": 20.0, + "f_max": 20000.0, + "floor_db": -90.0 + } + }, + "rate_hz": 30.0 + }, + { + "id": "eq/sidechain", + "name": "Sidechain Spectrum", + "kind": { + "Spectrum": { + "bins": 128, + "f_min": 20.0, + "f_max": 20000.0, + "floor_db": -90.0 + } + }, + "rate_hz": 30.0 + } + ], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/fet.catalog.json b/crates/frame/examples/surfaces/fet.catalog.json new file mode 100644 index 00000000..7ef7e78a --- /dev/null +++ b/crates/frame/examples/surfaces/fet.catalog.json @@ -0,0 +1,164 @@ +{ + "id": "comp.fet_1176", + "version": 1, + "params": [ + { + "addr": "1176/input", + "name": "Input", + "short_name": null, + "group": "FET", + "kind": "Continuous", + "range": { + "min": -48.0, + "max": 0.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.62, + "bipolar": false, + "featured": true + }, + { + "addr": "1176/output", + "name": "Output", + "short_name": null, + "group": "FET", + "kind": "Continuous", + "range": { + "min": -48.0, + "max": 0.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.75, + "bipolar": false, + "featured": true + }, + { + "addr": "1176/attack", + "name": "Attack", + "short_name": null, + "group": "FET", + "kind": "Continuous", + "range": { + "min": 1.0, + "max": 7.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": true + }, + { + "addr": "1176/release", + "name": "Release", + "short_name": null, + "group": "FET", + "kind": "Continuous", + "range": { + "min": 1.0, + "max": 7.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": true + }, + { + "addr": "1176/ratio", + "name": "Ratio", + "short_name": null, + "group": "FET", + "kind": { + "Choice": { + "options": [ + "4", + "8", + "12", + "20", + "All" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": true + }, + { + "addr": "1176/meter", + "name": "Meter", + "short_name": null, + "group": "FET", + "kind": { + "Choice": { + "options": [ + "GR", + "+8", + "+4", + "Off" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 4 + }, + "default": 0.0, + "bipolar": false, + "featured": true + }, + { + "addr": "1176/on", + "name": "Power", + "short_name": null, + "group": "FET", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": true + } + ], + "actions": [], + "streams": [ + { + "id": "1176/gr", + "name": "Gain Reduction", + "kind": "GainReduction", + "rate_hz": 30.0 + }, + { + "id": "1176/out", + "name": "Output", + "kind": { + "Level": { + "channels": 2 + } + }, + "rate_hz": 30.0 + } + ], + "collections": [] +} \ No newline at end of file diff --git a/crates/frame/examples/surfaces/lit-comp.catalog.json b/crates/frame/examples/surfaces/lit-comp.catalog.json new file mode 100644 index 00000000..d2745acc --- /dev/null +++ b/crates/frame/examples/surfaces/lit-comp.catalog.json @@ -0,0 +1,133 @@ +{ + "id": "viz.comp_scope", + "version": 1, + "params": [ + { + "addr": "comp/threshold", + "name": "Threshold", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": -60.0, + "max": 0.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.6, + "bipolar": false, + "featured": true + }, + { + "addr": "comp/ratio", + "name": "Ratio", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 1.0, + "max": 20.0, + "scale": "Logarithmic", + "unit": "Ratio", + "steps": null + }, + "default": 0.46275642631951835, + "bipolar": false, + "featured": true + }, + { + "addr": "comp/knee", + "name": "Knee", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 24.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.25, + "bipolar": false, + "featured": false + }, + { + "addr": "comp/makeup", + "name": "Makeup", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 24.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "comp/on", + "name": "On", + "short_name": "On", + "group": "Compressor", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [ + { + "id": "comp/in", + "name": "Input", + "kind": { + "Level": { + "channels": 1 + } + }, + "rate_hz": 30.0 + }, + { + "id": "comp/gr", + "name": "Gain Reduction", + "kind": "GainReduction", + "rate_hz": 30.0 + }, + { + "id": "comp/in_history", + "name": "Input History", + "kind": { + "History": { + "of": "Level", + "len": 128 + } + }, + "rate_hz": 60.0 + }, + { + "id": "comp/gr_history", + "name": "GR History", + "kind": { + "History": { + "of": "GainReduction", + "len": 128 + } + }, + "rate_hz": 60.0 + } + ], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/lit-delay.catalog.json b/crates/frame/examples/surfaces/lit-delay.catalog.json new file mode 100644 index 00000000..6461927c --- /dev/null +++ b/crates/frame/examples/surfaces/lit-delay.catalog.json @@ -0,0 +1,188 @@ +{ + "id": "viz.delay_taps", + "version": 1, + "params": [ + { + "addr": "delay/time", + "name": "Time", + "short_name": "Time", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 2000.0, + "scale": "Logarithmic", + "unit": "Milliseconds", + "steps": null + }, + "default": 0.6710334273172837, + "bipolar": false, + "featured": true + }, + { + "addr": "delay/feedback", + "name": "Feedback", + "short_name": "Feedback", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 0.98, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5612244897959184, + "bipolar": false, + "featured": true + }, + { + "addr": "delay/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": false + }, + { + "addr": "delay/tap_div_l", + "name": "Left", + "short_name": "Left", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "1/4", + "1/8.", + "1/8", + "1/8T", + "1/16", + "Golden", + "Silver", + "Free", + "1/4.", + "1/2", + "1/4T" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "delay/tap_div_r", + "name": "Right", + "short_name": "Right", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "1/4", + "1/8.", + "1/8", + "1/8T", + "1/16", + "Golden", + "Silver", + "Free", + "1/4.", + "1/2", + "1/4T" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.1, + "bipolar": false, + "featured": false + }, + { + "addr": "delay/family", + "name": "Machine", + "short_name": "Machine", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "Digital", + "Tape", + "Analog", + "Pitch", + "Rhythmic", + "Special" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "delay/bpm", + "name": "Tempo", + "short_name": "Tempo", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 40.0, + "max": 240.0, + "scale": "Linear", + "unit": "Bpm", + "steps": null + }, + "default": 0.4, + "bipolar": false, + "featured": false + }, + { + "addr": "delay/on", + "name": "On", + "short_name": "On", + "group": "Delay", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/lit-eq-glow.catalog.json b/crates/frame/examples/surfaces/lit-eq-glow.catalog.json new file mode 100644 index 00000000..0da4e4c8 --- /dev/null +++ b/crates/frame/examples/surfaces/lit-eq-glow.catalog.json @@ -0,0 +1,1391 @@ +{ + "id": "viz.eq_glow", + "version": 1, + "params": [ + { + "addr": "eq/b1_used", + "name": "Band 1 Used", + "short_name": "Used", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_on", + "name": "Band 1 On", + "short_name": "On", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_freq", + "name": "Band 1 Freq", + "short_name": "Freq", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.17314897791118378, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_gain", + "name": "Band 1 Gain", + "short_name": "Gain", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b1_q", + "name": "Band 1 Q", + "short_name": "Q", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.45357800204546916, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_shape", + "name": "Band 1 Shape", + "short_name": "Shape", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.3333333333333333, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_slope", + "name": "Band 1 Slope", + "short_name": "Slope", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_name", + "name": "Band 1 Name", + "short_name": "Name", + "group": "Band 1", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_placement", + "name": "Band 1 Placement", + "short_name": "Placement", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_focus", + "name": "Band 1 Focus", + "short_name": "Focus", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_dyn_range", + "name": "Band 1 Dyn Range", + "short_name": "Dyn", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b2_used", + "name": "Band 2 Used", + "short_name": "Used", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_on", + "name": "Band 2 On", + "short_name": "On", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_freq", + "name": "Band 2 Freq", + "short_name": "Freq", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.3103662964248948, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_gain", + "name": "Band 2 Gain", + "short_name": "Gain", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.55, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b2_q", + "name": "Band 2 Q", + "short_name": "Q", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.45357800204546916, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_shape", + "name": "Band 2 Shape", + "short_name": "Shape", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.1111111111111111, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_slope", + "name": "Band 2 Slope", + "short_name": "Slope", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_name", + "name": "Band 2 Name", + "short_name": "Name", + "group": "Band 2", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_placement", + "name": "Band 2 Placement", + "short_name": "Placement", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_focus", + "name": "Band 2 Focus", + "short_name": "Focus", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_dyn_range", + "name": "Band 2 Dyn Range", + "short_name": "Dyn", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b3_used", + "name": "Band 3 Used", + "short_name": "Used", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_on", + "name": "Band 3 On", + "short_name": "On", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_freq", + "name": "Band 3 Freq", + "short_name": "Freq", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.46683712516340553, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_gain", + "name": "Band 3 Gain", + "short_name": "Gain", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.43333333333333335, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b3_q", + "name": "Band 3 Q", + "short_name": "Q", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.5456062932940204, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_shape", + "name": "Band 3 Shape", + "short_name": "Shape", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_slope", + "name": "Band 3 Slope", + "short_name": "Slope", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_name", + "name": "Band 3 Name", + "short_name": "Name", + "group": "Band 3", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_placement", + "name": "Band 3 Placement", + "short_name": "Placement", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_focus", + "name": "Band 3 Focus", + "short_name": "Focus", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_dyn_range", + "name": "Band 3 Dyn Range", + "short_name": "Dyn", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b4_used", + "name": "Band 4 Used", + "short_name": "Used", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_on", + "name": "Band 4 On", + "short_name": "On", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_freq", + "name": "Band 4 Freq", + "short_name": "Freq", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.6845350125425831, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_gain", + "name": "Band 4 Gain", + "short_name": "Gain", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5416666666666666, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b4_q", + "name": "Band 4 Q", + "short_name": "Q", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_shape", + "name": "Band 4 Shape", + "short_name": "Shape", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_slope", + "name": "Band 4 Slope", + "short_name": "Slope", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_name", + "name": "Band 4 Name", + "short_name": "Name", + "group": "Band 4", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_placement", + "name": "Band 4 Placement", + "short_name": "Placement", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_focus", + "name": "Band 4 Focus", + "short_name": "Focus", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_dyn_range", + "name": "Band 4 Dyn Range", + "short_name": "Dyn", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b5_used", + "name": "Band 5 Used", + "short_name": "Used", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_on", + "name": "Band 5 On", + "short_name": "On", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_freq", + "name": "Band 5 Freq", + "short_name": "Freq", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.8496230913516148, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_gain", + "name": "Band 5 Gain", + "short_name": "Gain", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5416666666666666, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b5_q", + "name": "Band 5 Q", + "short_name": "Q", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.45357800204546916, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_shape", + "name": "Band 5 Shape", + "short_name": "Shape", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.2222222222222222, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_slope", + "name": "Band 5 Slope", + "short_name": "Slope", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_name", + "name": "Band 5 Name", + "short_name": "Name", + "group": "Band 5", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_placement", + "name": "Band 5 Placement", + "short_name": "Placement", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_focus", + "name": "Band 5 Focus", + "short_name": "Focus", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_dyn_range", + "name": "Band 5 Dyn Range", + "short_name": "Dyn", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b6_used", + "name": "Band 6 Used", + "short_name": "Used", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_on", + "name": "Band 6 On", + "short_name": "On", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_freq", + "name": "Band 6 Freq", + "short_name": "Freq", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_gain", + "name": "Band 6 Gain", + "short_name": "Gain", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b6_q", + "name": "Band 6 Q", + "short_name": "Q", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_shape", + "name": "Band 6 Shape", + "short_name": "Shape", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_slope", + "name": "Band 6 Slope", + "short_name": "Slope", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_name", + "name": "Band 6 Name", + "short_name": "Name", + "group": "Band 6", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_placement", + "name": "Band 6 Placement", + "short_name": "Placement", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_focus", + "name": "Band 6 Focus", + "short_name": "Focus", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_dyn_range", + "name": "Band 6 Dyn Range", + "short_name": "Dyn", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + } + ], + "actions": [], + "streams": [ + { + "id": "eq/spectrum", + "name": "Spectrum", + "kind": { + "Spectrum": { + "bins": 128, + "f_min": 20.0, + "f_max": 20000.0, + "floor_db": -90.0 + } + }, + "rate_hz": 30.0 + } + ], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/lit-mod.catalog.json b/crates/frame/examples/surfaces/lit-mod.catalog.json new file mode 100644 index 00000000..7f1e1367 --- /dev/null +++ b/crates/frame/examples/surfaces/lit-mod.catalog.json @@ -0,0 +1,105 @@ +{ + "id": "viz.mod_lfo", + "version": 1, + "params": [ + { + "addr": "mod/rate", + "name": "Rate", + "short_name": "Rate", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.05, + "max": 10.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5455263544885197, + "bipolar": false, + "featured": true + }, + { + "addr": "mod/depth", + "name": "Depth", + "short_name": "Depth", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.75, + "bipolar": false, + "featured": true + }, + { + "addr": "mod/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": false + }, + { + "addr": "mod/engine", + "name": "Engine", + "short_name": "Engine", + "group": "Modulation", + "kind": { + "Choice": { + "options": [ + "Chorus", + "Flanger", + "Phaser", + "Tremolo", + "Vibrato", + "Rotary" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "mod/on", + "name": "On", + "short_name": "On", + "group": "Modulation", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/lit-reverb.catalog.json b/crates/frame/examples/surfaces/lit-reverb.catalog.json new file mode 100644 index 00000000..c5176213 --- /dev/null +++ b/crates/frame/examples/surfaces/lit-reverb.catalog.json @@ -0,0 +1,158 @@ +{ + "id": "viz.reverb_space", + "version": 1, + "params": [ + { + "addr": "reverb/decay", + "name": "Decay", + "short_name": "Decay", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.1, + "max": 20.0, + "scale": "Logarithmic", + "unit": "Seconds", + "steps": null + }, + "default": 0.6149304227398895, + "bipolar": false, + "featured": true + }, + { + "addr": "reverb/predelay", + "name": "Pre-delay", + "short_name": "Pre-delay", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 250.0, + "scale": "Linear", + "unit": "Milliseconds", + "steps": null + }, + "default": 0.14, + "bipolar": false, + "featured": false + }, + { + "addr": "reverb/density", + "name": "Density", + "short_name": "Density", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.55, + "bipolar": false, + "featured": false + }, + { + "addr": "reverb/damp", + "name": "Damping", + "short_name": "Damping", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.35, + "bipolar": false, + "featured": false + }, + { + "addr": "reverb/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": false + }, + { + "addr": "reverb/family", + "name": "Space", + "short_name": "Space", + "group": "Reverb", + "kind": { + "Choice": { + "options": [ + "Room", + "Hall", + "Plate", + "Spring", + "Ambient", + "Random", + "Special", + "Convolution" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 8 + }, + "default": 0.14285714285714285, + "bipolar": false, + "featured": false + }, + { + "addr": "reverb/bpm", + "name": "Tempo", + "short_name": "Tempo", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 40.0, + "max": 240.0, + "scale": "Linear", + "unit": "Bpm", + "steps": null + }, + "default": 0.4, + "bipolar": false, + "featured": false + }, + { + "addr": "reverb/on", + "name": "On", + "short_name": "On", + "group": "Reverb", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/make_1176.py b/crates/frame/examples/surfaces/make_1176.py new file mode 100644 index 00000000..5d4528ff --- /dev/null +++ b/crates/frame/examples/surfaces/make_1176.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""A FET limiting amplifier face (after the 1176) in frame markup. + +Everything is plain frame nodes: the knobs, dot scales, push-button ratio bank, +jewel lamp and VU meter are drawn, then turned into controls by attaching +`control` data whose slots point at the needle / lamp / readout nodes. Change +any colour or shape by hand (or with a model) and it keeps working. + +Writes `07-fet-1176.frame` (the surface) and `fet.catalog.json` (the parameters +it binds to — the compressor profile's macro slots: input, output, attack, +release, ratio, meter — so the mock engine can play it). + + python3 examples/surfaces/make_1176.py +""" +import json, math, pathlib, uuid + +OUT = pathlib.Path(__file__).resolve().parent + +INK = "#e2dccd" # panel legends +DIM = "#9a9384" # secondary legends / readouts +PANEL = "#151518" +BLUE = "#1b5f79" + + +def nid(): + return str(uuid.uuid4()) + + +def num(v): + return f"{v:.2f}".rstrip("0").rstrip(".") + + +def text(name, s, x, y, w, h, size, fill, align="center", id=None): + i = f' id="{id}"' if id else "" + return f'{s}' + + +def radial(stops): + return "{" + json.dumps([{"kind": {"RadialGradient": {"stops": [{"position": p, "color": c} for p, c in stops]}}}]) + "}" + + +def linear(stops): + return "{" + json.dumps([{"kind": {"LinearGradient": {"stops": [{"position": p, "color": c} for p, c in stops]}}}]) + "}" + + +def control(kind, source, slots): + c = {"kind": kind, "binding": {"source": source, "curve": "Linear", "min": 0.0, "max": 1.0}, "slots": [{"role": r, "node": n} for r, n in slots]} + return "{" + json.dumps(c) + "}" + + +def param(addr): + return {"Param": {"addr": addr}} + + +def knob(name, addr, cx, cy, d, scale, legend_y=None): + """A silver-top knob: black skirt, knurled chrome cap, white index; a + printed dot scale with numerals around it; legend and live readout.""" + needle, value = nid(), nid() + out = [] + # Printed scale (outside the control, so it doesn't take clicks). + r_dot, r_num = d / 2 + 7, d / 2 + 19 + for k, label in enumerate(scale): + a = math.radians(-135 + 270 * k / (len(scale) - 1)) + dx, dy = math.sin(a), -math.cos(a) + out.append(f'') + if label: + out.append(text("scale", label, cx + r_num * dx - 14, cy + r_num * dy - 7, 28, 14, 9.5, DIM)) + # The control: a square dial whose centre is the needle's pivot. + x, y = cx - d / 2, cy - d / 2 + cap = d * 0.66 + ctl = control({"Knob": {"sweep_degrees": 270.0}}, param(addr), [("Needle", needle), ("Value", value)]) + out.append(f''' + + + + +''') + ly = legend_y if legend_y is not None else cy + d / 2 + 26 + out.append(text(f"{name} legend", name.upper(), cx - 50, ly, 100, 16, 11, INK)) + out.append(text(f"{name} value", "", cx - 50, ly + 16, 100, 14, 10, DIM, id=value)) + return out + + +def select_bank(name, addr, x, y, labels, order): + """A column of push-in selectors. `labels` top to bottom; `order` gives + each label's option index in the catalog.""" + out = [text(f"{name} legend", name.upper(), x - 10, y - 24, 66, 14, 10, INK)] + for row, label in enumerate(labels): + lamp = nid() + ctl = control({"Button": {"mode": {"Select": {"index": order[label], "count": len(labels)}}}}, param(addr), [("Led", lamp)]) + by = y + row * 40 + out.append(f''' + + {text("label", label, 0, 9, 46, 14, 11, "#111113")} +''') + return out + + +def vu(x, y, w, h): + """An ivory VU window. The needle's parent is a square frame centred on + the pivot (below the window), so the needle rotates about the pivot.""" + needle, value = nid(), nid() + px, py, r = w / 2, h + 4, 88 + out = [] + marks = [(-20, "20"), (-10, "10"), (-7, "7"), (-5, "5"), (-3, "3"), (-2, ""), (-1, ""), (0, "0"), (1, ""), (2, ""), (3, "3")] + for db, label in marks: + p = (db + 20) / 23 + a = math.radians((p - 0.5) * 96) + tx, ty = px + r * math.sin(a), py - r * math.cos(a) + red = db >= 0 + c, s = math.cos(a), math.sin(a) + # a 1.6 × 10 tick, rotated about its own centre + tw, th = 1.6, 10.0 if label else 6.0 + ox, oy = tx - (c * tw / 2 - s * th / 2), ty - (s * tw / 2 + c * th / 2) + tr = json.dumps({"a": c, "b": -s, "tx": ox, "c": s, "d": c, "ty": oy}) + out.append(f'') + if label: + out.append(text("mark", label, px + (r + 13) * math.sin(a) - 12, py - (r + 13) * math.cos(a) - 7, 24, 12, 9, "#b3261e" if red else "#1c1c1c")) + # The red zone, 0..+3, as a thin ring sector. + a0, a1 = math.radians((20 / 23 - 0.5) * 96), math.radians(0.5 * 96) + ro, ri = r + 3, r - 1 + pt = lambda rr, aa: (px + rr * math.sin(aa), py - rr * math.cos(aa)) + (x0, y0), (x1, y1), (x2, y2), (x3, y3) = pt(ro, a0), pt(ro, a1), pt(ri, a1), pt(ri, a0) + zone = f"M {x0:.2f} {y0:.2f} A {ro} {ro} 0 0 1 {x1:.2f} {y1:.2f} L {x2:.2f} {y2:.2f} A {ri} {ri} 0 0 0 {x3:.2f} {y3:.2f} Z" + out.append(f'') + out.append(text("vu", "VU", w / 2 - 20, h * 0.52, 40, 18, 15, "#1c1c1c")) + out.append(text("gr", "", w / 2 - 40, h * 0.52 + 18, 80, 12, 8.5, "#6b6252", id=value)) + out.append(f''' + +''') + ctl = control({"Gauge": {"sweep_degrees": 96.0, "floor_db": -20.0, "ceiling_db": 3.0}}, {"Stream": {"id": "1176/gr"}}, [("Needle", needle), ("Value", value)]) + inner = "\n ".join(out) + return f''' + {inner} +''' + + +W, H = 1060, 330 +nodes = [] +# Brushed anodising: faint horizontal grain. +for k in range(0, H, 3): + nodes.append(f'') +# Rack ears with slotted holes. +for ex in (0, W - 40): + nodes.append(f'' + + "".join(f'' for yy in (40, H - 52)) + "") +# Wordmark. +nodes.append(text("model", "FET LIMITING AMPLIFIER", 60, 18, 300, 16, 11, DIM, align="left")) +nodes.append(text("maker", "frame · 1176-style", 60, H - 30, 300, 14, 9.5, "#6d6a62", align="left")) +# Input / Output. +atten = ["∞", "48", "36", "30", "24", "18", "12", "6", "0"] +nodes += knob("Input", "1176/input", 170, 158, 112, atten) +nodes += knob("Output", "1176/output", 380, 158, 112, atten) +# Attack over Release. +seven = [str(k) for k in range(1, 8)] +nodes += knob("Attack", "1176/attack", 548, 98, 58, seven, legend_y=140) +nodes += knob("Release", "1176/release", 548, 232, 58, seven, legend_y=274) +# Ratio bank: printed 20 at the top, 4 at the bottom, "All" above them. +nodes += select_bank("Ratio", "1176/ratio", 636, 70, ["All", "20", "12", "8", "4"], {"4": 0, "8": 1, "12": 2, "20": 3, "All": 4}) +# The meter's painted band. +nodes.append(f'') +jewel = nid() +jewel_ctl = control({"Button": {"mode": "Toggle"}}, param("1176/on"), [("Led", jewel)]) +nodes.append(f''' + + +''') +nodes.append(vu(737, 54, 176, 122)) +nodes.append(text("band title", "LIMITING AMPLIFIER", 712, 200, 226, 18, 13, "#f0f5f8")) +nodes.append(text("band sub", "PEAK LIMITER", 712, 222, 226, 14, 9.5, "#bcd8e4")) +# Meter source. +nodes += select_bank("Meter", "1176/meter", 960, 70, ["GR", "+8", "+4", "Off"], {"GR": 0, "+8": 1, "+4": 2, "Off": 3}) + +body = "\n ".join("\n ".join(n.splitlines()) for n in nodes) +markup = f''' + + + {body} + + + +''' +(OUT / "07-fet-1176.frame").write_text(markup) + + +def p(addr, name, kind, rng, default, featured=True, short=None): + return {"addr": addr, "name": name, "short_name": short, "group": "FET", "kind": kind, "range": rng, "default": default, "bipolar": False, "featured": featured} + + +def rng(lo, hi, unit="None", steps=None): + return {"min": lo, "max": hi, "scale": "Linear", "unit": unit, "steps": steps} + + +catalog = { + "id": "comp.fet_1176", + "version": 1, + "params": [ + p("1176/input", "Input", "Continuous", rng(-48.0, 0.0, "Db"), 0.62), + p("1176/output", "Output", "Continuous", rng(-48.0, 0.0, "Db"), 0.75), + p("1176/attack", "Attack", "Continuous", rng(1.0, 7.0), 0.5), + p("1176/release", "Release", "Continuous", rng(1.0, 7.0), 0.5), + p("1176/ratio", "Ratio", {"Choice": {"options": ["4", "8", "12", "20", "All"]}}, rng(0.0, 1.0, steps=5), 0.0), + p("1176/meter", "Meter", {"Choice": {"options": ["GR", "+8", "+4", "Off"]}}, rng(0.0, 1.0, steps=4), 0.0), + p("1176/on", "Power", "Toggle", rng(0.0, 1.0, steps=2), 1.0), + ], + "actions": [], + "streams": [ + {"id": "1176/gr", "name": "Gain Reduction", "kind": "GainReduction", "rate_hz": 30.0}, + {"id": "1176/out", "name": "Output", "kind": {"Level": {"channels": 2}}, "rate_hz": 30.0}, + ], + "collections": [], +} +(OUT / "fet.catalog.json").write_text(json.dumps(catalog, indent=2)) +print("wrote 07-fet-1176.frame and fet.catalog.json") diff --git a/crates/frame/examples/surfaces/rig-panels.catalog.json b/crates/frame/examples/surfaces/rig-panels.catalog.json new file mode 100644 index 00000000..563fddad --- /dev/null +++ b/crates/frame/examples/surfaces/rig-panels.catalog.json @@ -0,0 +1,6571 @@ +{ + "id": "rig-panels", + "version": 1, + "params": [ + { + "addr": "comp/threshold", + "name": "Threshold", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": -60.0, + "max": 0.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": true + }, + { + "addr": "comp/ratio", + "name": "Ratio", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 1.0, + "max": 20.0, + "scale": "Logarithmic", + "unit": "Ratio", + "steps": null + }, + "default": 0.46275642631951835, + "bipolar": false, + "featured": true + }, + { + "addr": "comp/knee", + "name": "Knee", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 24.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.25, + "bipolar": false, + "featured": false + }, + { + "addr": "comp/makeup", + "name": "Makeup", + "short_name": null, + "group": "Compressor", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 24.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "comp/on", + "name": "On", + "short_name": "On", + "group": "Compressor", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_used", + "name": "Band 1 Used", + "short_name": "Used", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_on", + "name": "Band 1 On", + "short_name": "On", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_freq", + "name": "Band 1 Freq", + "short_name": "Freq", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.25972346686677567, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_gain", + "name": "Band 1 Gain", + "short_name": "Gain", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b1_q", + "name": "Band 1 Q", + "short_name": "Q", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_shape", + "name": "Band 1 Shape", + "short_name": "Shape", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.3333333333333333, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_slope", + "name": "Band 1 Slope", + "short_name": "Slope", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_name", + "name": "Band 1 Name", + "short_name": "Name", + "group": "Band 1", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_placement", + "name": "Band 1 Placement", + "short_name": "Placement", + "group": "Band 1", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_focus", + "name": "Band 1 Focus", + "short_name": "Focus", + "group": "Band 1", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b1_dyn_range", + "name": "Band 1 Dyn Range", + "short_name": "Dyn", + "group": "Band 1", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b2_used", + "name": "Band 2 Used", + "short_name": "Used", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_on", + "name": "Band 2 On", + "short_name": "On", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_freq", + "name": "Band 2 Freq", + "short_name": "Freq", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.38144653688117797, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_gain", + "name": "Band 2 Gain", + "short_name": "Gain", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b2_q", + "name": "Band 2 Q", + "short_name": "Q", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_shape", + "name": "Band 2 Shape", + "short_name": "Shape", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_slope", + "name": "Band 2 Slope", + "short_name": "Slope", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_name", + "name": "Band 2 Name", + "short_name": "Name", + "group": "Band 2", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_placement", + "name": "Band 2 Placement", + "short_name": "Placement", + "group": "Band 2", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_focus", + "name": "Band 2 Focus", + "short_name": "Focus", + "group": "Band 2", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b2_dyn_range", + "name": "Band 2 Dyn Range", + "short_name": "Dyn", + "group": "Band 2", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b3_used", + "name": "Band 3 Used", + "short_name": "Used", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_on", + "name": "Band 3 On", + "short_name": "On", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_freq", + "name": "Band 3 Freq", + "short_name": "Freq", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.46686388670746043, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_gain", + "name": "Band 3 Gain", + "short_name": "Gain", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5270416666666666, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b3_q", + "name": "Band 3 Q", + "short_name": "Q", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_shape", + "name": "Band 3 Shape", + "short_name": "Shape", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_slope", + "name": "Band 3 Slope", + "short_name": "Slope", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_name", + "name": "Band 3 Name", + "short_name": "Name", + "group": "Band 3", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_placement", + "name": "Band 3 Placement", + "short_name": "Placement", + "group": "Band 3", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_focus", + "name": "Band 3 Focus", + "short_name": "Focus", + "group": "Band 3", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b3_dyn_range", + "name": "Band 3 Dyn Range", + "short_name": "Dyn", + "group": "Band 3", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b4_used", + "name": "Band 4 Used", + "short_name": "Used", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_on", + "name": "Band 4 On", + "short_name": "On", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_freq", + "name": "Band 4 Freq", + "short_name": "Freq", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.6172140338117907, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_gain", + "name": "Band 4 Gain", + "short_name": "Gain", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b4_q", + "name": "Band 4 Q", + "short_name": "Q", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_shape", + "name": "Band 4 Shape", + "short_name": "Shape", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_slope", + "name": "Band 4 Slope", + "short_name": "Slope", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_name", + "name": "Band 4 Name", + "short_name": "Name", + "group": "Band 4", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_placement", + "name": "Band 4 Placement", + "short_name": "Placement", + "group": "Band 4", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_focus", + "name": "Band 4 Focus", + "short_name": "Focus", + "group": "Band 4", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b4_dyn_range", + "name": "Band 4 Dyn Range", + "short_name": "Dyn", + "group": "Band 4", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b5_used", + "name": "Band 5 Used", + "short_name": "Used", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_on", + "name": "Band 5 On", + "short_name": "On", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_freq", + "name": "Band 5 Freq", + "short_name": "Freq", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.7881124898289408, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_gain", + "name": "Band 5 Gain", + "short_name": "Gain", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b5_q", + "name": "Band 5 Q", + "short_name": "Q", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_shape", + "name": "Band 5 Shape", + "short_name": "Shape", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_slope", + "name": "Band 5 Slope", + "short_name": "Slope", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_name", + "name": "Band 5 Name", + "short_name": "Name", + "group": "Band 5", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_placement", + "name": "Band 5 Placement", + "short_name": "Placement", + "group": "Band 5", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_focus", + "name": "Band 5 Focus", + "short_name": "Focus", + "group": "Band 5", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b5_dyn_range", + "name": "Band 5 Dyn Range", + "short_name": "Dyn", + "group": "Band 5", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b6_used", + "name": "Band 6 Used", + "short_name": "Used", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_on", + "name": "Band 6 On", + "short_name": "On", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_freq", + "name": "Band 6 Freq", + "short_name": "Freq", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_gain", + "name": "Band 6 Gain", + "short_name": "Gain", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b6_q", + "name": "Band 6 Q", + "short_name": "Q", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_shape", + "name": "Band 6 Shape", + "short_name": "Shape", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_slope", + "name": "Band 6 Slope", + "short_name": "Slope", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_name", + "name": "Band 6 Name", + "short_name": "Name", + "group": "Band 6", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_placement", + "name": "Band 6 Placement", + "short_name": "Placement", + "group": "Band 6", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_focus", + "name": "Band 6 Focus", + "short_name": "Focus", + "group": "Band 6", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b6_dyn_range", + "name": "Band 6 Dyn Range", + "short_name": "Dyn", + "group": "Band 6", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b7_used", + "name": "Band 7 Used", + "short_name": "Used", + "group": "Band 7", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_on", + "name": "Band 7 On", + "short_name": "On", + "group": "Band 7", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_freq", + "name": "Band 7 Freq", + "short_name": "Freq", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_gain", + "name": "Band 7 Gain", + "short_name": "Gain", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b7_q", + "name": "Band 7 Q", + "short_name": "Q", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_shape", + "name": "Band 7 Shape", + "short_name": "Shape", + "group": "Band 7", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_slope", + "name": "Band 7 Slope", + "short_name": "Slope", + "group": "Band 7", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_name", + "name": "Band 7 Name", + "short_name": "Name", + "group": "Band 7", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_placement", + "name": "Band 7 Placement", + "short_name": "Placement", + "group": "Band 7", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_focus", + "name": "Band 7 Focus", + "short_name": "Focus", + "group": "Band 7", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b7_dyn_range", + "name": "Band 7 Dyn Range", + "short_name": "Dyn", + "group": "Band 7", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b8_used", + "name": "Band 8 Used", + "short_name": "Used", + "group": "Band 8", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_on", + "name": "Band 8 On", + "short_name": "On", + "group": "Band 8", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_freq", + "name": "Band 8 Freq", + "short_name": "Freq", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_gain", + "name": "Band 8 Gain", + "short_name": "Gain", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b8_q", + "name": "Band 8 Q", + "short_name": "Q", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_shape", + "name": "Band 8 Shape", + "short_name": "Shape", + "group": "Band 8", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_slope", + "name": "Band 8 Slope", + "short_name": "Slope", + "group": "Band 8", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_name", + "name": "Band 8 Name", + "short_name": "Name", + "group": "Band 8", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_placement", + "name": "Band 8 Placement", + "short_name": "Placement", + "group": "Band 8", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_focus", + "name": "Band 8 Focus", + "short_name": "Focus", + "group": "Band 8", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b8_dyn_range", + "name": "Band 8 Dyn Range", + "short_name": "Dyn", + "group": "Band 8", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b9_used", + "name": "Band 9 Used", + "short_name": "Used", + "group": "Band 9", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_on", + "name": "Band 9 On", + "short_name": "On", + "group": "Band 9", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_freq", + "name": "Band 9 Freq", + "short_name": "Freq", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_gain", + "name": "Band 9 Gain", + "short_name": "Gain", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b9_q", + "name": "Band 9 Q", + "short_name": "Q", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_shape", + "name": "Band 9 Shape", + "short_name": "Shape", + "group": "Band 9", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_slope", + "name": "Band 9 Slope", + "short_name": "Slope", + "group": "Band 9", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_name", + "name": "Band 9 Name", + "short_name": "Name", + "group": "Band 9", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_placement", + "name": "Band 9 Placement", + "short_name": "Placement", + "group": "Band 9", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_focus", + "name": "Band 9 Focus", + "short_name": "Focus", + "group": "Band 9", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b9_dyn_range", + "name": "Band 9 Dyn Range", + "short_name": "Dyn", + "group": "Band 9", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b10_used", + "name": "Band 10 Used", + "short_name": "Used", + "group": "Band 10", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_on", + "name": "Band 10 On", + "short_name": "On", + "group": "Band 10", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_freq", + "name": "Band 10 Freq", + "short_name": "Freq", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_gain", + "name": "Band 10 Gain", + "short_name": "Gain", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b10_q", + "name": "Band 10 Q", + "short_name": "Q", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_shape", + "name": "Band 10 Shape", + "short_name": "Shape", + "group": "Band 10", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_slope", + "name": "Band 10 Slope", + "short_name": "Slope", + "group": "Band 10", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_name", + "name": "Band 10 Name", + "short_name": "Name", + "group": "Band 10", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_placement", + "name": "Band 10 Placement", + "short_name": "Placement", + "group": "Band 10", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_focus", + "name": "Band 10 Focus", + "short_name": "Focus", + "group": "Band 10", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b10_dyn_range", + "name": "Band 10 Dyn Range", + "short_name": "Dyn", + "group": "Band 10", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b11_used", + "name": "Band 11 Used", + "short_name": "Used", + "group": "Band 11", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_on", + "name": "Band 11 On", + "short_name": "On", + "group": "Band 11", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_freq", + "name": "Band 11 Freq", + "short_name": "Freq", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_gain", + "name": "Band 11 Gain", + "short_name": "Gain", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b11_q", + "name": "Band 11 Q", + "short_name": "Q", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_shape", + "name": "Band 11 Shape", + "short_name": "Shape", + "group": "Band 11", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_slope", + "name": "Band 11 Slope", + "short_name": "Slope", + "group": "Band 11", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_name", + "name": "Band 11 Name", + "short_name": "Name", + "group": "Band 11", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_placement", + "name": "Band 11 Placement", + "short_name": "Placement", + "group": "Band 11", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_focus", + "name": "Band 11 Focus", + "short_name": "Focus", + "group": "Band 11", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b11_dyn_range", + "name": "Band 11 Dyn Range", + "short_name": "Dyn", + "group": "Band 11", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b12_used", + "name": "Band 12 Used", + "short_name": "Used", + "group": "Band 12", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_on", + "name": "Band 12 On", + "short_name": "On", + "group": "Band 12", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_freq", + "name": "Band 12 Freq", + "short_name": "Freq", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_gain", + "name": "Band 12 Gain", + "short_name": "Gain", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b12_q", + "name": "Band 12 Q", + "short_name": "Q", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_shape", + "name": "Band 12 Shape", + "short_name": "Shape", + "group": "Band 12", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_slope", + "name": "Band 12 Slope", + "short_name": "Slope", + "group": "Band 12", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_name", + "name": "Band 12 Name", + "short_name": "Name", + "group": "Band 12", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_placement", + "name": "Band 12 Placement", + "short_name": "Placement", + "group": "Band 12", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_focus", + "name": "Band 12 Focus", + "short_name": "Focus", + "group": "Band 12", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b12_dyn_range", + "name": "Band 12 Dyn Range", + "short_name": "Dyn", + "group": "Band 12", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b13_used", + "name": "Band 13 Used", + "short_name": "Used", + "group": "Band 13", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_on", + "name": "Band 13 On", + "short_name": "On", + "group": "Band 13", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_freq", + "name": "Band 13 Freq", + "short_name": "Freq", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_gain", + "name": "Band 13 Gain", + "short_name": "Gain", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b13_q", + "name": "Band 13 Q", + "short_name": "Q", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_shape", + "name": "Band 13 Shape", + "short_name": "Shape", + "group": "Band 13", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_slope", + "name": "Band 13 Slope", + "short_name": "Slope", + "group": "Band 13", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_name", + "name": "Band 13 Name", + "short_name": "Name", + "group": "Band 13", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_placement", + "name": "Band 13 Placement", + "short_name": "Placement", + "group": "Band 13", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_focus", + "name": "Band 13 Focus", + "short_name": "Focus", + "group": "Band 13", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b13_dyn_range", + "name": "Band 13 Dyn Range", + "short_name": "Dyn", + "group": "Band 13", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b14_used", + "name": "Band 14 Used", + "short_name": "Used", + "group": "Band 14", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_on", + "name": "Band 14 On", + "short_name": "On", + "group": "Band 14", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_freq", + "name": "Band 14 Freq", + "short_name": "Freq", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_gain", + "name": "Band 14 Gain", + "short_name": "Gain", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b14_q", + "name": "Band 14 Q", + "short_name": "Q", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_shape", + "name": "Band 14 Shape", + "short_name": "Shape", + "group": "Band 14", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_slope", + "name": "Band 14 Slope", + "short_name": "Slope", + "group": "Band 14", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_name", + "name": "Band 14 Name", + "short_name": "Name", + "group": "Band 14", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_placement", + "name": "Band 14 Placement", + "short_name": "Placement", + "group": "Band 14", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_focus", + "name": "Band 14 Focus", + "short_name": "Focus", + "group": "Band 14", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b14_dyn_range", + "name": "Band 14 Dyn Range", + "short_name": "Dyn", + "group": "Band 14", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b15_used", + "name": "Band 15 Used", + "short_name": "Used", + "group": "Band 15", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_on", + "name": "Band 15 On", + "short_name": "On", + "group": "Band 15", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_freq", + "name": "Band 15 Freq", + "short_name": "Freq", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_gain", + "name": "Band 15 Gain", + "short_name": "Gain", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b15_q", + "name": "Band 15 Q", + "short_name": "Q", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_shape", + "name": "Band 15 Shape", + "short_name": "Shape", + "group": "Band 15", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_slope", + "name": "Band 15 Slope", + "short_name": "Slope", + "group": "Band 15", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_name", + "name": "Band 15 Name", + "short_name": "Name", + "group": "Band 15", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_placement", + "name": "Band 15 Placement", + "short_name": "Placement", + "group": "Band 15", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_focus", + "name": "Band 15 Focus", + "short_name": "Focus", + "group": "Band 15", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b15_dyn_range", + "name": "Band 15 Dyn Range", + "short_name": "Dyn", + "group": "Band 15", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b16_used", + "name": "Band 16 Used", + "short_name": "Used", + "group": "Band 16", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_on", + "name": "Band 16 On", + "short_name": "On", + "group": "Band 16", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_freq", + "name": "Band 16 Freq", + "short_name": "Freq", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_gain", + "name": "Band 16 Gain", + "short_name": "Gain", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b16_q", + "name": "Band 16 Q", + "short_name": "Q", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_shape", + "name": "Band 16 Shape", + "short_name": "Shape", + "group": "Band 16", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_slope", + "name": "Band 16 Slope", + "short_name": "Slope", + "group": "Band 16", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_name", + "name": "Band 16 Name", + "short_name": "Name", + "group": "Band 16", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_placement", + "name": "Band 16 Placement", + "short_name": "Placement", + "group": "Band 16", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_focus", + "name": "Band 16 Focus", + "short_name": "Focus", + "group": "Band 16", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b16_dyn_range", + "name": "Band 16 Dyn Range", + "short_name": "Dyn", + "group": "Band 16", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b17_used", + "name": "Band 17 Used", + "short_name": "Used", + "group": "Band 17", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_on", + "name": "Band 17 On", + "short_name": "On", + "group": "Band 17", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_freq", + "name": "Band 17 Freq", + "short_name": "Freq", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_gain", + "name": "Band 17 Gain", + "short_name": "Gain", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b17_q", + "name": "Band 17 Q", + "short_name": "Q", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_shape", + "name": "Band 17 Shape", + "short_name": "Shape", + "group": "Band 17", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_slope", + "name": "Band 17 Slope", + "short_name": "Slope", + "group": "Band 17", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_name", + "name": "Band 17 Name", + "short_name": "Name", + "group": "Band 17", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_placement", + "name": "Band 17 Placement", + "short_name": "Placement", + "group": "Band 17", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_focus", + "name": "Band 17 Focus", + "short_name": "Focus", + "group": "Band 17", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b17_dyn_range", + "name": "Band 17 Dyn Range", + "short_name": "Dyn", + "group": "Band 17", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b18_used", + "name": "Band 18 Used", + "short_name": "Used", + "group": "Band 18", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_on", + "name": "Band 18 On", + "short_name": "On", + "group": "Band 18", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_freq", + "name": "Band 18 Freq", + "short_name": "Freq", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_gain", + "name": "Band 18 Gain", + "short_name": "Gain", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b18_q", + "name": "Band 18 Q", + "short_name": "Q", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_shape", + "name": "Band 18 Shape", + "short_name": "Shape", + "group": "Band 18", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_slope", + "name": "Band 18 Slope", + "short_name": "Slope", + "group": "Band 18", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_name", + "name": "Band 18 Name", + "short_name": "Name", + "group": "Band 18", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_placement", + "name": "Band 18 Placement", + "short_name": "Placement", + "group": "Band 18", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_focus", + "name": "Band 18 Focus", + "short_name": "Focus", + "group": "Band 18", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b18_dyn_range", + "name": "Band 18 Dyn Range", + "short_name": "Dyn", + "group": "Band 18", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b19_used", + "name": "Band 19 Used", + "short_name": "Used", + "group": "Band 19", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_on", + "name": "Band 19 On", + "short_name": "On", + "group": "Band 19", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_freq", + "name": "Band 19 Freq", + "short_name": "Freq", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_gain", + "name": "Band 19 Gain", + "short_name": "Gain", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b19_q", + "name": "Band 19 Q", + "short_name": "Q", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_shape", + "name": "Band 19 Shape", + "short_name": "Shape", + "group": "Band 19", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_slope", + "name": "Band 19 Slope", + "short_name": "Slope", + "group": "Band 19", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_name", + "name": "Band 19 Name", + "short_name": "Name", + "group": "Band 19", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_placement", + "name": "Band 19 Placement", + "short_name": "Placement", + "group": "Band 19", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_focus", + "name": "Band 19 Focus", + "short_name": "Focus", + "group": "Band 19", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b19_dyn_range", + "name": "Band 19 Dyn Range", + "short_name": "Dyn", + "group": "Band 19", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b20_used", + "name": "Band 20 Used", + "short_name": "Used", + "group": "Band 20", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_on", + "name": "Band 20 On", + "short_name": "On", + "group": "Band 20", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_freq", + "name": "Band 20 Freq", + "short_name": "Freq", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_gain", + "name": "Band 20 Gain", + "short_name": "Gain", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b20_q", + "name": "Band 20 Q", + "short_name": "Q", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_shape", + "name": "Band 20 Shape", + "short_name": "Shape", + "group": "Band 20", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_slope", + "name": "Band 20 Slope", + "short_name": "Slope", + "group": "Band 20", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_name", + "name": "Band 20 Name", + "short_name": "Name", + "group": "Band 20", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_placement", + "name": "Band 20 Placement", + "short_name": "Placement", + "group": "Band 20", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_focus", + "name": "Band 20 Focus", + "short_name": "Focus", + "group": "Band 20", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b20_dyn_range", + "name": "Band 20 Dyn Range", + "short_name": "Dyn", + "group": "Band 20", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b21_used", + "name": "Band 21 Used", + "short_name": "Used", + "group": "Band 21", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_on", + "name": "Band 21 On", + "short_name": "On", + "group": "Band 21", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_freq", + "name": "Band 21 Freq", + "short_name": "Freq", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_gain", + "name": "Band 21 Gain", + "short_name": "Gain", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b21_q", + "name": "Band 21 Q", + "short_name": "Q", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_shape", + "name": "Band 21 Shape", + "short_name": "Shape", + "group": "Band 21", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_slope", + "name": "Band 21 Slope", + "short_name": "Slope", + "group": "Band 21", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_name", + "name": "Band 21 Name", + "short_name": "Name", + "group": "Band 21", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_placement", + "name": "Band 21 Placement", + "short_name": "Placement", + "group": "Band 21", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_focus", + "name": "Band 21 Focus", + "short_name": "Focus", + "group": "Band 21", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b21_dyn_range", + "name": "Band 21 Dyn Range", + "short_name": "Dyn", + "group": "Band 21", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b22_used", + "name": "Band 22 Used", + "short_name": "Used", + "group": "Band 22", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_on", + "name": "Band 22 On", + "short_name": "On", + "group": "Band 22", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_freq", + "name": "Band 22 Freq", + "short_name": "Freq", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_gain", + "name": "Band 22 Gain", + "short_name": "Gain", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b22_q", + "name": "Band 22 Q", + "short_name": "Q", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_shape", + "name": "Band 22 Shape", + "short_name": "Shape", + "group": "Band 22", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_slope", + "name": "Band 22 Slope", + "short_name": "Slope", + "group": "Band 22", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_name", + "name": "Band 22 Name", + "short_name": "Name", + "group": "Band 22", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_placement", + "name": "Band 22 Placement", + "short_name": "Placement", + "group": "Band 22", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_focus", + "name": "Band 22 Focus", + "short_name": "Focus", + "group": "Band 22", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b22_dyn_range", + "name": "Band 22 Dyn Range", + "short_name": "Dyn", + "group": "Band 22", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b23_used", + "name": "Band 23 Used", + "short_name": "Used", + "group": "Band 23", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_on", + "name": "Band 23 On", + "short_name": "On", + "group": "Band 23", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_freq", + "name": "Band 23 Freq", + "short_name": "Freq", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_gain", + "name": "Band 23 Gain", + "short_name": "Gain", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b23_q", + "name": "Band 23 Q", + "short_name": "Q", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_shape", + "name": "Band 23 Shape", + "short_name": "Shape", + "group": "Band 23", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_slope", + "name": "Band 23 Slope", + "short_name": "Slope", + "group": "Band 23", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_name", + "name": "Band 23 Name", + "short_name": "Name", + "group": "Band 23", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_placement", + "name": "Band 23 Placement", + "short_name": "Placement", + "group": "Band 23", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_focus", + "name": "Band 23 Focus", + "short_name": "Focus", + "group": "Band 23", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b23_dyn_range", + "name": "Band 23 Dyn Range", + "short_name": "Dyn", + "group": "Band 23", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b24_used", + "name": "Band 24 Used", + "short_name": "Used", + "group": "Band 24", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_on", + "name": "Band 24 On", + "short_name": "On", + "group": "Band 24", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_freq", + "name": "Band 24 Freq", + "short_name": "Freq", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 30000.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5751884543241927, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_gain", + "name": "Band 24 Gain", + "short_name": "Gain", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/b24_q", + "name": "Band 24 Q", + "short_name": "Q", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": 0.025, + "max": 40.0, + "scale": "Logarithmic", + "unit": "None", + "steps": null + }, + "default": 0.4530040738117794, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_shape", + "name": "Band 24 Shape", + "short_name": "Shape", + "group": "Band 24", + "kind": { + "Choice": { + "options": [ + "Bell", + "Low Shelf", + "High Shelf", + "Low Cut", + "High Cut", + "Notch", + "Band Pass", + "Tilt Shelf", + "Flat Tilt", + "All Pass" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 10 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_slope", + "name": "Band 24 Slope", + "short_name": "Slope", + "group": "Band 24", + "kind": { + "Choice": { + "options": [ + "0 dB/oct", + "6 dB/oct", + "12 dB/oct", + "18 dB/oct", + "24 dB/oct", + "30 dB/oct", + "36 dB/oct", + "48 dB/oct", + "72 dB/oct", + "96 dB/oct", + "Brickwall" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_name", + "name": "Band 24 Name", + "short_name": "Name", + "group": "Band 24", + "kind": "Text", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_placement", + "name": "Band 24 Placement", + "short_name": "Placement", + "group": "Band 24", + "kind": { + "Choice": { + "options": [ + "ST", + "L", + "R", + "M", + "S" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 5 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_focus", + "name": "Band 24 Focus", + "short_name": "Focus", + "group": "Band 24", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/b24_dyn_range", + "name": "Band 24 Dyn Range", + "short_name": "Dyn", + "group": "Band 24", + "kind": "Continuous", + "range": { + "min": -30.0, + "max": 30.0, + "scale": "Linear", + "unit": "Db", + "steps": null + }, + "default": 0.5, + "bipolar": true, + "featured": false + }, + { + "addr": "eq/range", + "name": "Display Range", + "short_name": "Range", + "group": "View", + "kind": { + "Choice": { + "options": [ + "±3 dB", + "±6 dB", + "±12 dB", + "±18 dB", + "±24 dB", + "±30 dB" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.4, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/show_pre", + "name": "Pre-EQ Analyser", + "short_name": "PRE", + "group": "View", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/show_post", + "name": "Post-EQ Analyser", + "short_name": "POST", + "group": "View", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "eq/show_ext", + "name": "External Analyser", + "short_name": "EXT", + "group": "View", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "mod/rate", + "name": "Rate", + "short_name": "Rate", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.05, + "max": 10.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.5654120103239064, + "bipolar": false, + "featured": true + }, + { + "addr": "mod/depth", + "name": "Depth", + "short_name": "Depth", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": true + }, + { + "addr": "mod/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "mod/engine", + "name": "Engine", + "short_name": "Engine", + "group": "Modulation", + "kind": { + "Choice": { + "options": [ + "Chorus", + "Flanger", + "Phaser", + "Tremolo", + "Vibrato", + "Rotary" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "mod/on", + "name": "On", + "short_name": "On", + "group": "Modulation", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "motion/rate", + "name": "Rate", + "short_name": "Rate", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.05, + "max": 10.0, + "scale": "Logarithmic", + "unit": "Hz", + "steps": null + }, + "default": 0.8270600516195319, + "bipolar": false, + "featured": true + }, + { + "addr": "motion/depth", + "name": "Depth", + "short_name": "Depth", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.5, + "bipolar": false, + "featured": true + }, + { + "addr": "motion/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Modulation", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "motion/engine", + "name": "Engine", + "short_name": "Engine", + "group": "Modulation", + "kind": { + "Choice": { + "options": [ + "Chorus", + "Flanger", + "Phaser", + "Tremolo", + "Vibrato", + "Rotary" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.6, + "bipolar": false, + "featured": false + }, + { + "addr": "motion/on", + "name": "On", + "short_name": "On", + "group": "Modulation", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "dly1/time", + "name": "Time", + "short_name": "Time", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 2000.0, + "scale": "Logarithmic", + "unit": "Milliseconds", + "steps": null + }, + "default": 0.586464236345161, + "bipolar": false, + "featured": true + }, + { + "addr": "dly1/feedback", + "name": "Feedback", + "short_name": "Feedback", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 0.98, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.28571428571428575, + "bipolar": false, + "featured": true + }, + { + "addr": "dly1/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.3500000851436285, + "bipolar": false, + "featured": false + }, + { + "addr": "dly1/tap_div_l", + "name": "Left", + "short_name": "Left", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "1/4", + "1/8.", + "1/8", + "1/8T", + "1/16", + "Golden", + "Silver", + "Free", + "1/4.", + "1/2", + "1/4T" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "dly1/tap_div_r", + "name": "Right", + "short_name": "Right", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "1/4", + "1/8.", + "1/8", + "1/8T", + "1/16", + "Golden", + "Silver", + "Free", + "1/4.", + "1/2", + "1/4T" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "dly1/family", + "name": "Machine", + "short_name": "Machine", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "Digital", + "Tape", + "Analog", + "Pitch", + "Rhythmic", + "Special" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.2, + "bipolar": false, + "featured": false + }, + { + "addr": "dly1/bpm", + "name": "Tempo", + "short_name": "Tempo", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 40.0, + "max": 240.0, + "scale": "Linear", + "unit": "Bpm", + "steps": null + }, + "default": 0.545, + "bipolar": false, + "featured": false + }, + { + "addr": "dly1/on", + "name": "On", + "short_name": "On", + "group": "Delay", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "dly2/time", + "name": "Time", + "short_name": "Time", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 10.0, + "max": 2000.0, + "scale": "Logarithmic", + "unit": "Milliseconds", + "steps": null + }, + "default": 0.7727631772442599, + "bipolar": false, + "featured": true + }, + { + "addr": "dly2/feedback", + "name": "Feedback", + "short_name": "Feedback", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 0.98, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.6326530612244898, + "bipolar": false, + "featured": true + }, + { + "addr": "dly2/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.1, + "bipolar": false, + "featured": false + }, + { + "addr": "dly2/tap_div_l", + "name": "Left", + "short_name": "Left", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "1/4", + "1/8.", + "1/8", + "1/8T", + "1/16", + "Golden", + "Silver", + "Free", + "1/4.", + "1/2", + "1/4T" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.1, + "bipolar": false, + "featured": false + }, + { + "addr": "dly2/tap_div_r", + "name": "Right", + "short_name": "Right", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "1/4", + "1/8.", + "1/8", + "1/8T", + "1/16", + "Golden", + "Silver", + "Free", + "1/4.", + "1/2", + "1/4T" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 11 + }, + "default": 0.1, + "bipolar": false, + "featured": false + }, + { + "addr": "dly2/family", + "name": "Machine", + "short_name": "Machine", + "group": "Delay", + "kind": { + "Choice": { + "options": [ + "Digital", + "Tape", + "Analog", + "Pitch", + "Rhythmic", + "Special" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 6 + }, + "default": 0.6, + "bipolar": false, + "featured": false + }, + { + "addr": "dly2/bpm", + "name": "Tempo", + "short_name": "Tempo", + "group": "Delay", + "kind": "Continuous", + "range": { + "min": 40.0, + "max": 240.0, + "scale": "Linear", + "unit": "Bpm", + "steps": null + }, + "default": 0.545, + "bipolar": false, + "featured": false + }, + { + "addr": "dly2/on", + "name": "On", + "short_name": "On", + "group": "Delay", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "verb1/decay", + "name": "Decay", + "short_name": "Decay", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.1, + "max": 20.0, + "scale": "Logarithmic", + "unit": "Seconds", + "steps": null + }, + "default": 0.6038972653859299, + "bipolar": false, + "featured": true + }, + { + "addr": "verb1/predelay", + "name": "Pre-delay", + "short_name": "Pre-delay", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 250.0, + "scale": "Linear", + "unit": "Milliseconds", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "verb1/density", + "name": "Density", + "short_name": "Density", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.45, + "bipolar": false, + "featured": false + }, + { + "addr": "verb1/damp", + "name": "Damping", + "short_name": "Damping", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "verb1/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.15, + "bipolar": false, + "featured": false + }, + { + "addr": "verb1/family", + "name": "Space", + "short_name": "Space", + "group": "Reverb", + "kind": { + "Choice": { + "options": [ + "Room", + "Hall", + "Plate", + "Spring", + "Ambient", + "Random", + "Special", + "Convolution" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 8 + }, + "default": 0.14285714285714285, + "bipolar": false, + "featured": false + }, + { + "addr": "verb1/bpm", + "name": "Tempo", + "short_name": "Tempo", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 40.0, + "max": 240.0, + "scale": "Linear", + "unit": "Bpm", + "steps": null + }, + "default": 0.545, + "bipolar": false, + "featured": false + }, + { + "addr": "verb1/on", + "name": "On", + "short_name": "On", + "group": "Reverb", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + }, + { + "addr": "verb2/decay", + "name": "Decay", + "short_name": "Decay", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.1, + "max": 20.0, + "scale": "Logarithmic", + "unit": "Seconds", + "steps": null + }, + "default": 0.9542952805260035, + "bipolar": false, + "featured": true + }, + { + "addr": "verb2/predelay", + "name": "Pre-delay", + "short_name": "Pre-delay", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 250.0, + "scale": "Linear", + "unit": "Milliseconds", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "verb2/density", + "name": "Density", + "short_name": "Density", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.92, + "bipolar": false, + "featured": false + }, + { + "addr": "verb2/damp", + "name": "Damping", + "short_name": "Damping", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.0, + "bipolar": false, + "featured": false + }, + { + "addr": "verb2/mix", + "name": "Mix", + "short_name": "Mix", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": null + }, + "default": 0.15, + "bipolar": false, + "featured": false + }, + { + "addr": "verb2/family", + "name": "Space", + "short_name": "Space", + "group": "Reverb", + "kind": { + "Choice": { + "options": [ + "Room", + "Hall", + "Plate", + "Spring", + "Ambient", + "Random", + "Special", + "Convolution" + ] + } + }, + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 8 + }, + "default": 0.14285714285714285, + "bipolar": false, + "featured": false + }, + { + "addr": "verb2/bpm", + "name": "Tempo", + "short_name": "Tempo", + "group": "Reverb", + "kind": "Continuous", + "range": { + "min": 40.0, + "max": 240.0, + "scale": "Linear", + "unit": "Bpm", + "steps": null + }, + "default": 0.545, + "bipolar": false, + "featured": false + }, + { + "addr": "verb2/on", + "name": "On", + "short_name": "On", + "group": "Reverb", + "kind": "Toggle", + "range": { + "min": 0.0, + "max": 1.0, + "scale": "Linear", + "unit": "None", + "steps": 2 + }, + "default": 1.0, + "bipolar": false, + "featured": false + } + ], + "actions": [], + "streams": [ + { + "id": "comp/in", + "name": "Input", + "kind": { + "Level": { + "channels": 1 + } + }, + "rate_hz": 30.0 + }, + { + "id": "comp/gr", + "name": "Gain Reduction", + "kind": "GainReduction", + "rate_hz": 30.0 + }, + { + "id": "comp/in_history", + "name": "Input History", + "kind": { + "History": { + "of": "Level", + "len": 120 + } + }, + "rate_hz": 60.0 + }, + { + "id": "comp/gr_history", + "name": "GR History", + "kind": { + "History": { + "of": "GainReduction", + "len": 120 + } + }, + "rate_hz": 60.0 + }, + { + "id": "eq/spectrum", + "name": "Spectrum", + "kind": { + "Spectrum": { + "bins": 96, + "f_min": 20.0, + "f_max": 20000.0, + "floor_db": -90.0 + } + }, + "rate_hz": 30.0 + }, + { + "id": "eq/spectrum_pre", + "name": "Spectrum (pre)", + "kind": { + "Spectrum": { + "bins": 96, + "f_min": 20.0, + "f_max": 20000.0, + "floor_db": -90.0 + } + }, + "rate_hz": 30.0 + }, + { + "id": "eq/sidechain", + "name": "Sidechain", + "kind": { + "Spectrum": { + "bins": 96, + "f_min": 20.0, + "f_max": 20000.0, + "floor_db": -90.0 + } + }, + "rate_hz": 30.0 + } + ], + "collections": [] +} diff --git a/crates/frame/examples/surfaces/rig-panels.feeds b/crates/frame/examples/surfaces/rig-panels.feeds new file mode 100644 index 00000000..2df4b5f2 --- /dev/null +++ b/crates/frame/examples/surfaces/rig-panels.feeds @@ -0,0 +1,6 @@ +comp/in=-16.707 +comp/gr=0.000 +comp/in_history=-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-27.959,-15.612,-7.635,-5.438,-5.581,-5.737,-5.905,-6.084,-6.276,-6.478,-6.691,-6.915,-7.150,-7.394,-7.648,-7.912,-8.184,-8.466,-8.756,-9.054,-9.361,-9.675,-9.997,-10.326,-10.662,-11.004,-11.353,-11.707,-12.068,-12.434,-12.804,-13.180,-13.559,-13.943,-14.330,-14.720,-15.113,-15.509,-15.906,-16.304,-16.606 +comp/gr_history=-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-0.000,-2.975,-8.896,-11.776,-11.642,-11.496,-11.339,-11.170,-10.991,-10.801,-10.600,-10.390,-10.171,-9.941,-9.703,-9.456,-9.200,-8.936,-8.664,-8.384,-8.097,-7.802,-7.500,-7.192,-6.877,-6.556,-6.229,-5.896,-5.558,-5.215,-4.867,-4.515,-4.159,-3.799,-3.436,-3.070,-2.702,-2.332,-1.959,-1.586,-1.305 +eq/spectrum=-28.214,-26.965,-25.919,-26.259,-27.186,-27.016,-25.719,-24.966,-25.608,-26.390,-25.846,-24.511,-24.114,-24.968,-25.498,-24.619,-23.369,-23.149,-21.358,-16.921,-16.158,-19.874,-22.583,-23.586,-23.450,-22.315,-21.871,-22.442,-21.423,-19.591,-20.455,-22.552,-24.110,-24.253,-22.178,-21.136,-23.032,-24.684,-24.133,-23.308,-23.388,-23.817,-24.896,-25.368,-24.401,-24.566,-26.328,-27.661,-27.497,-26.542,-26.524,-27.756,-28.665,-28.165,-27.296,-27.656,-28.987,-29.567,-28.807,-28.139,-28.858,-30.164,-30.373,-29.455,-29.081,-30.102,-31.262,-31.097,-30.142,-30.124,-31.356,-32.265,-31.791,-31.162,-32.115,-34.205,-35.542,-35.540,-35.630,-37.107,-39.171,-40.137,-39.977,-40.362,-42.140,-44.059,-44.652,-44.454,-45.194,-47.184,-48.851,-49.109,-48.998,-50.115,-52.205,-53.687 +eq/spectrum_pre=-28.214,-26.965,-25.919,-26.259,-27.186,-27.016,-25.719,-24.966,-25.608,-26.390,-25.846,-24.511,-24.114,-24.968,-25.498,-24.619,-23.369,-23.149,-21.358,-16.921,-16.158,-19.874,-22.583,-23.586,-23.450,-22.315,-21.871,-22.442,-21.423,-19.591,-20.455,-22.552,-24.110,-24.253,-22.178,-21.136,-23.032,-24.684,-24.133,-23.308,-23.388,-23.817,-24.896,-25.368,-24.401,-24.566,-26.328,-27.661,-27.497,-26.542,-26.524,-27.756,-28.665,-28.165,-27.296,-27.656,-28.987,-29.567,-28.807,-28.139,-28.858,-30.164,-30.373,-29.455,-29.081,-30.102,-31.262,-31.097,-30.142,-30.124,-31.356,-32.265,-31.791,-31.162,-32.115,-34.205,-35.542,-35.540,-35.630,-37.107,-39.171,-40.137,-39.977,-40.362,-42.140,-44.059,-44.652,-44.454,-45.194,-47.184,-48.851,-49.109,-48.998,-50.115,-52.205,-53.687 diff --git a/packages/fts-figma-import/.gitignore b/crates/frame/figma/plugins/fts-figma-import/.gitignore similarity index 100% rename from packages/fts-figma-import/.gitignore rename to crates/frame/figma/plugins/fts-figma-import/.gitignore diff --git a/packages/fts-figma-import/README.md b/crates/frame/figma/plugins/fts-figma-import/README.md similarity index 98% rename from packages/fts-figma-import/README.md rename to crates/frame/figma/plugins/fts-figma-import/README.md index a1b18e28..6bc48360 100644 --- a/packages/fts-figma-import/README.md +++ b/crates/frame/figma/plugins/fts-figma-import/README.md @@ -84,7 +84,7 @@ Bridge writes per scenario: Default parity root on bridge host: -- `/Users/codywright/Documents/Development/FastTrackStudio/frame/tests/frame/parity/scenarios` +- `/Users/codywright/Documents/Development/FastTrackStudio/frame/features/render/tests/parity/scenarios` ## Live sync mode diff --git a/packages/fts-figma-import/code.ts b/crates/frame/figma/plugins/fts-figma-import/code.ts similarity index 100% rename from packages/fts-figma-import/code.ts rename to crates/frame/figma/plugins/fts-figma-import/code.ts diff --git a/packages/fts-figma-import/manifest.json b/crates/frame/figma/plugins/fts-figma-import/manifest.json similarity index 100% rename from packages/fts-figma-import/manifest.json rename to crates/frame/figma/plugins/fts-figma-import/manifest.json diff --git a/packages/fts-figma-import/package.json b/crates/frame/figma/plugins/fts-figma-import/package.json similarity index 100% rename from packages/fts-figma-import/package.json rename to crates/frame/figma/plugins/fts-figma-import/package.json diff --git a/packages/fts-figma-import/pnpm-lock.yaml b/crates/frame/figma/plugins/fts-figma-import/pnpm-lock.yaml similarity index 100% rename from packages/fts-figma-import/pnpm-lock.yaml rename to crates/frame/figma/plugins/fts-figma-import/pnpm-lock.yaml diff --git a/packages/fts-figma-import/scripts/local-bridge.mjs b/crates/frame/figma/plugins/fts-figma-import/scripts/local-bridge.mjs similarity index 99% rename from packages/fts-figma-import/scripts/local-bridge.mjs rename to crates/frame/figma/plugins/fts-figma-import/scripts/local-bridge.mjs index 051e056c..ce646095 100644 --- a/packages/fts-figma-import/scripts/local-bridge.mjs +++ b/crates/frame/figma/plugins/fts-figma-import/scripts/local-bridge.mjs @@ -6,7 +6,7 @@ const PORT = Number(process.env.FTS_FIGMA_BRIDGE_PORT || 43123); const HOST = process.env.FTS_FIGMA_BRIDGE_HOST || ''; const OUT_PATH = process.env.FTS_FIGMA_OUT_PATH || '/tmp/fts-figma-export.json'; const PARITY_ROOT = process.env.FTS_PARITY_ROOT - || '/Users/codywright/Documents/Development/FastTrackStudio/frame/tests/frame/parity/scenarios'; + || '/Users/codywright/Documents/Development/FastTrackStudio/frame/features/render/tests/parity/scenarios'; const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', diff --git a/packages/fts-figma-import/tsconfig.json b/crates/frame/figma/plugins/fts-figma-import/tsconfig.json similarity index 100% rename from packages/fts-figma-import/tsconfig.json rename to crates/frame/figma/plugins/fts-figma-import/tsconfig.json diff --git a/packages/fts-figma-import/ui.html b/crates/frame/figma/plugins/fts-figma-import/ui.html similarity index 99% rename from packages/fts-figma-import/ui.html rename to crates/frame/figma/plugins/fts-figma-import/ui.html index 99f7633d..a84d4a66 100644 --- a/packages/fts-figma-import/ui.html +++ b/crates/frame/figma/plugins/fts-figma-import/ui.html @@ -151,7 +151,7 @@
- +
diff --git a/packages/fts-figma-parity-plugin/.gitignore b/crates/frame/figma/plugins/fts-figma-parity-plugin/.gitignore similarity index 100% rename from packages/fts-figma-parity-plugin/.gitignore rename to crates/frame/figma/plugins/fts-figma-parity-plugin/.gitignore diff --git a/packages/fts-figma-parity-plugin/README.md b/crates/frame/figma/plugins/fts-figma-parity-plugin/README.md similarity index 100% rename from packages/fts-figma-parity-plugin/README.md rename to crates/frame/figma/plugins/fts-figma-parity-plugin/README.md diff --git a/packages/fts-figma-parity-plugin/manifest.json b/crates/frame/figma/plugins/fts-figma-parity-plugin/manifest.json similarity index 100% rename from packages/fts-figma-parity-plugin/manifest.json rename to crates/frame/figma/plugins/fts-figma-parity-plugin/manifest.json diff --git a/packages/fts-figma-parity-plugin/package.json b/crates/frame/figma/plugins/fts-figma-parity-plugin/package.json similarity index 100% rename from packages/fts-figma-parity-plugin/package.json rename to crates/frame/figma/plugins/fts-figma-parity-plugin/package.json diff --git a/packages/fts-figma-parity-plugin/pnpm-lock.yaml b/crates/frame/figma/plugins/fts-figma-parity-plugin/pnpm-lock.yaml similarity index 100% rename from packages/fts-figma-parity-plugin/pnpm-lock.yaml rename to crates/frame/figma/plugins/fts-figma-parity-plugin/pnpm-lock.yaml diff --git a/packages/fts-figma-parity-plugin/src/main/main.ts b/crates/frame/figma/plugins/fts-figma-parity-plugin/src/main/main.ts similarity index 99% rename from packages/fts-figma-parity-plugin/src/main/main.ts rename to crates/frame/figma/plugins/fts-figma-parity-plugin/src/main/main.ts index 1a727eea..f188f903 100644 --- a/packages/fts-figma-parity-plugin/src/main/main.ts +++ b/crates/frame/figma/plugins/fts-figma-parity-plugin/src/main/main.ts @@ -41,7 +41,7 @@ const RUNTIME_LABEL = `FTS parity generator ${GENERATOR_VERSION}`; const DEFAULT_BRIDGE_EXPORT_URL = 'http://localhost:43123/figma-parity-pack'; const DEFAULT_BRIDGE_HEALTH_URL = 'http://localhost:43123/health?source=fts-figma-parity-plugin'; const DEFAULT_PARITY_ROOT_DIR = - '/Users/codywright/Documents/Development/FastTrackStudio/frame/tests/frame/parity/scenarios'; + '/Users/codywright/Documents/Development/FastTrackStudio/frame/features/render/tests/parity/scenarios'; export default async function () { const page = figma.currentPage; diff --git a/packages/fts-figma-parity-plugin/src/main/tsconfig.json b/crates/frame/figma/plugins/fts-figma-parity-plugin/src/main/tsconfig.json similarity index 100% rename from packages/fts-figma-parity-plugin/src/main/tsconfig.json rename to crates/frame/figma/plugins/fts-figma-parity-plugin/src/main/tsconfig.json diff --git a/packages/fts-figma-parity-plugin/src/main/tsconfig.tsbuildinfo b/crates/frame/figma/plugins/fts-figma-parity-plugin/src/main/tsconfig.tsbuildinfo similarity index 100% rename from packages/fts-figma-parity-plugin/src/main/tsconfig.tsbuildinfo rename to crates/frame/figma/plugins/fts-figma-parity-plugin/src/main/tsconfig.tsbuildinfo diff --git a/packages/fts-figma-parity-plugin/src/vite-env.d.ts b/crates/frame/figma/plugins/fts-figma-parity-plugin/src/vite-env.d.ts similarity index 100% rename from packages/fts-figma-parity-plugin/src/vite-env.d.ts rename to crates/frame/figma/plugins/fts-figma-parity-plugin/src/vite-env.d.ts diff --git a/packages/fts-figma-parity-plugin/tsconfig.json b/crates/frame/figma/plugins/fts-figma-parity-plugin/tsconfig.json similarity index 100% rename from packages/fts-figma-parity-plugin/tsconfig.json rename to crates/frame/figma/plugins/fts-figma-parity-plugin/tsconfig.json diff --git a/packages/fts-figma-parity-plugin/vite.config.ts b/crates/frame/figma/plugins/fts-figma-parity-plugin/vite.config.ts similarity index 100% rename from packages/fts-figma-parity-plugin/vite.config.ts rename to crates/frame/figma/plugins/fts-figma-parity-plugin/vite.config.ts diff --git a/tests/frame/parity/README.md b/crates/frame/render/tests/parity/README.md similarity index 100% rename from tests/frame/parity/README.md rename to crates/frame/render/tests/parity/README.md diff --git a/crates/frame/render/tests/parity/golden/effects.background-blur-stress.primitives.txt b/crates/frame/render/tests/parity/golden/effects.background-blur-stress.primitives.txt new file mode 100644 index 00000000..6e62f5fa --- /dev/null +++ b/crates/frame/render/tests/parity/golden/effects.background-blur-stress.primitives.txt @@ -0,0 +1,20 @@ +Rect { node_id: NodeId("8:968"), x: 1980.0, y: 9120.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1442"), x: 2004.0, y: 9144.0, width: 357.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1443"), x: 2004.0, y: 9192.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1444"), x: 2004.0, y: 9504.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1446"), x: 2140.0, y: 9250.0, width: 135.532947779, height: 135.532947779, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.352941185236, b: 0.352941185236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.139626353359, authored: false } +Path { node_id: NodeId("15:1447"), x: 2280.0, y: 9250.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.352941185236, g: 0.549019634724, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1448"), x: 2420.0, y: 9250.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.352941185236, b: 0.352941185236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1449"), x: 2560.0, y: 9250.0, width: 135.532947779, height: 135.532947779, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.352941185236, g: 0.549019634724, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.139626353359, authored: false } +Path { node_id: NodeId("15:1450"), x: 2700.0, y: 9250.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.352941185236, b: 0.352941185236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1451"), x: 2840.0, y: 9250.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.352941185236, g: 0.549019634724, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1452"), x: 2980.0, y: 9250.0, width: 135.532947779, height: 135.532947779, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.352941185236, b: 0.352941185236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.139626353359, authored: false } +Path { node_id: NodeId("15:1453"), x: 2140.0, y: 9390.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.352941185236, g: 0.549019634724, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1454"), x: 2280.0, y: 9390.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.352941185236, b: 0.352941185236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1455"), x: 2420.0, y: 9390.0, width: 135.532947779, height: 135.532947779, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.352941185236, g: 0.549019634724, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.139626353359, authored: false } +Path { node_id: NodeId("15:1456"), x: 2560.0, y: 9390.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.352941185236, b: 0.352941185236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1457"), x: 2700.0, y: 9390.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.352941185236, g: 0.549019634724, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1458"), x: 2840.0, y: 9390.0, width: 135.532947779, height: 135.532947779, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.352941185236, b: 0.352941185236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.139626353359, authored: false } +Path { node_id: NodeId("15:1459"), x: 2980.0, y: 9390.0, width: 131.886045635, height: 131.886045635, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120 0 L120 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.352941185236, g: 0.549019634724, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.104719754075, authored: false } +Path { node_id: NodeId("15:1460"), x: 2220.0, y: 9540.0, width: 340.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 24 C0 10.7452 10.7452 0 24 0 L316 0 C329.255 0 340 10.7452 340 24 L340 196 C340 209.255 329.255 220 316 220 L24 220 C10.7452 220 0 209.255 0 196 L0 24 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.180000007153 }), Normal)], stroke: None, effects: [NodeEffect { kind: BackgroundBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0450000017881 }, offset_x: 0.0, offset_y: 0.0, radius: 12.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1461"), x: 2600.0, y: 9540.0, width: 340.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 24 C0 10.7452 10.7452 0 24 0 L316 0 C329.255 0 340 10.7452 340 24 L340 196 C340 209.255 329.255 220 316 220 L24 220 C10.7452 220 0 209.255 0 196 L0 24 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.180000007153 }), Normal)], stroke: None, effects: [NodeEffect { kind: BackgroundBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0450000017881 }, offset_x: 0.0, offset_y: 0.0, radius: 24.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/effects.drop-shadow-matrix.primitives.txt b/crates/frame/render/tests/parity/golden/effects.drop-shadow-matrix.primitives.txt new file mode 100644 index 00000000..9e8052a1 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/effects.drop-shadow-matrix.primitives.txt @@ -0,0 +1,8 @@ +Rect { node_id: NodeId("8:944"), x: 0.0, y: 7980.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1421"), x: 24.0, y: 8004.0, width: 328.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1422"), x: 24.0, y: 8052.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1423"), x: 24.0, y: 8364.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1425"), x: 200.0, y: 8120.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 16 C0 7.16344 7.16344 0 16 0 L204 0 C212.837 0 220 7.16344 220 16 L220 124 C220 132.837 212.837 140 204 140 L16 140 C7.16344 140 0 132.837 0 124 L0 16 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0941176488996, g: 0.0941176488996, b: 0.0941176488996, a: 0.219999998808 }, offset_x: 2.0, offset_y: 2.0, radius: 4.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1426"), x: 480.0, y: 8120.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 16 C0 7.16344 7.16344 0 16 0 L204 0 C212.837 0 220 7.16344 220 16 L220 124 C220 132.837 212.837 140 204 140 L16 140 C7.16344 140 0 132.837 0 124 L0 16 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0941176488996, g: 0.0941176488996, b: 0.0941176488996, a: 0.319999992847 }, offset_x: 8.0, offset_y: 8.0, radius: 12.0, spread: 2.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1427"), x: 760.0, y: 8120.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 16 C0 7.16344 7.16344 0 16 0 L204 0 C212.837 0 220 7.16344 220 16 L220 124 C220 132.837 212.837 140 204 140 L16 140 C7.16344 140 0 132.837 0 124 L0 16 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0941176488996, g: 0.0941176488996, b: 0.0941176488996, a: 0.419999986887 }, offset_x: 16.0, offset_y: 16.0, radius: 28.0, spread: 4.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1428"), x: 280.0, y: 8450.0, width: 520.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 24 C0 10.7452 10.7452 0 24 0 L496 0 C509.255 0 520 10.7452 520 24 L520 196 C520 209.255 509.255 220 496 220 L24 220 C10.7452 220 0 209.255 0 196 L0 24 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.219999998808 }, offset_x: 0.0, offset_y: 2.0, radius: 4.0, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.20000000298 }, offset_x: 0.0, offset_y: 16.0, radius: 24.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/effects.effect-ordering.primitives.txt b/crates/frame/render/tests/parity/golden/effects.effect-ordering.primitives.txt new file mode 100644 index 00000000..c1208f4f --- /dev/null +++ b/crates/frame/render/tests/parity/golden/effects.effect-ordering.primitives.txt @@ -0,0 +1,5 @@ +Rect { node_id: NodeId("8:961"), x: 0.0, y: 9120.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1436"), x: 24.0, y: 9144.0, width: 277.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1437"), x: 24.0, y: 9192.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1440"), x: 220.0, y: 9270.0, width: 260.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L260 0 L260 180 L0 180 L0 0 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.298039227724, g: 0.517647087574, b: 0.960784316063, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.0784313753247, g: 0.156862750649, b: 0.313725501299, a: 1.0 }, gradient: None, width: 12.0, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.239999994636 }, offset_x: 0.0, offset_y: 12.0, radius: 20.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1441"), x: 560.0, y: 9270.0, width: 260.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L260 0 L260 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.517647087574, b: 0.298039227724, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 10.0, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.239999994636 }, offset_x: 0.0, offset_y: 10.0, radius: 16.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/effects.inner-shadow-matrix.primitives.txt b/crates/frame/render/tests/parity/golden/effects.inner-shadow-matrix.primitives.txt new file mode 100644 index 00000000..40133a20 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/effects.inner-shadow-matrix.primitives.txt @@ -0,0 +1,6 @@ +Rect { node_id: NodeId("8:953"), x: 1980.0, y: 7980.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1429"), x: 2004.0, y: 8004.0, width: 331.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1430"), x: 2004.0, y: 8052.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1433"), x: 2180.0, y: 8120.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 16 C0 7.16344 7.16344 0 16 0 L204 0 C212.837 0 220 7.16344 220 16 L220 124 C220 132.837 212.837 140 204 140 L16 140 C7.16344 140 0 132.837 0 124 L0 16 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.941176474094, g: 0.956862747669, b: 0.988235294819, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0980392172933, g: 0.0980392172933, b: 0.117647059262, a: 0.20000000298 }, offset_x: 0.0, offset_y: 1.0, radius: 4.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1434"), x: 2460.0, y: 8120.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 16 C0 7.16344 7.16344 0 16 0 L204 0 C212.837 0 220 7.16344 220 16 L220 124 C220 132.837 212.837 140 204 140 L16 140 C7.16344 140 0 132.837 0 124 L0 16 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.941176474094, g: 0.956862747669, b: 0.988235294819, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0980392172933, g: 0.0980392172933, b: 0.117647059262, a: 0.319999992847 }, offset_x: 2.0, offset_y: 3.0, radius: 9.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1435"), x: 2740.0, y: 8120.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 16 C0 7.16344 7.16344 0 16 0 L204 0 C212.837 0 220 7.16344 220 16 L220 124 C220 132.837 212.837 140 204 140 L16 140 C7.16344 140 0 132.837 0 124 L0 16 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.941176474094, g: 0.956862747669, b: 0.988235294819, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0980392172933, g: 0.0980392172933, b: 0.117647059262, a: 0.439999997616 }, offset_x: 4.0, offset_y: 5.0, radius: 14.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/effects.layer-background-blur.primitives.txt b/crates/frame/render/tests/parity/golden/effects.layer-background-blur.primitives.txt new file mode 100644 index 00000000..705d2e5b --- /dev/null +++ b/crates/frame/render/tests/parity/golden/effects.layer-background-blur.primitives.txt @@ -0,0 +1,9 @@ +Rect { node_id: NodeId("8:830"), x: 1980.0, y: 1140.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1318"), x: 2004.0, y: 1164.0, width: 345.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1319"), x: 2004.0, y: 1212.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1320"), x: 2004.0, y: 1524.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1322"), x: 2160.0, y: 1250.0, width: 920.0, height: 800.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L920 0 L920 800 L0 800 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.878431379795, g: 0.901960790157, b: 0.929411768913, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1323"), x: 2220.0, y: 1300.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L220 0 L220 140 L0 140 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.219607844949, g: 0.470588237047, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 2.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1324"), x: 2490.0, y: 1300.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L220 0 L220 140 L0 140 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.219607844949, g: 0.470588237047, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 8.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1325"), x: 2760.0, y: 1300.0, width: 220.0, height: 140.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L220 0 L220 140 L0 140 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.219607844949, g: 0.470588237047, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 24.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1326"), x: 2300.0, y: 1570.0, width: 640.0, height: 360.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 24 C0 10.7452 10.7452 0 24 0 L616 0 C629.255 0 640 10.7452 640 24 L640 336 C640 349.255 629.255 360 616 360 L24 360 C10.7452 360 0 349.255 0 336 L0 24 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.20000000298 }), Normal)], stroke: None, effects: [NodeEffect { kind: BackgroundBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0500000007451 }, offset_x: 0.0, offset_y: 0.0, radius: 18.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/effects.shadow-matrix.primitives.txt b/crates/frame/render/tests/parity/golden/effects.shadow-matrix.primitives.txt new file mode 100644 index 00000000..11f2690f --- /dev/null +++ b/crates/frame/render/tests/parity/golden/effects.shadow-matrix.primitives.txt @@ -0,0 +1,5 @@ +Rect { node_id: NodeId("8:937"), x: 1980.0, y: 6840.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1415"), x: 2004.0, y: 6864.0, width: 275.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1416"), x: 2004.0, y: 6912.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1419"), x: 2180.0, y: 6980.0, width: 240.0, height: 150.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L240 0 L240 150 L0 150 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 0.25 }, offset_x: 0.0, offset_y: 6.0, radius: 8.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1420"), x: 2500.0, y: 6980.0, width: 240.0, height: 150.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L240 0 L240 150 L0 150 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 0.34999999404 }, offset_x: 0.0, offset_y: 2.0, radius: 6.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/images.fill-mode-matrix.primitives.txt b/crates/frame/render/tests/parity/golden/images.fill-mode-matrix.primitives.txt new file mode 100644 index 00000000..0d0a3bac --- /dev/null +++ b/crates/frame/render/tests/parity/golden/images.fill-mode-matrix.primitives.txt @@ -0,0 +1,6 @@ +Rect { node_id: NodeId("8:860"), x: 0.0, y: 3420.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1345"), x: 24.0, y: 3444.0, width: 287.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1346"), x: 24.0, y: 3492.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1349"), x: 180.0, y: 3530.0, width: 280.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("9993ed1d2781fdafd876038e6be0a1162d377be1"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Fill, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1350"), x: 500.0, y: 3530.0, width: 280.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("9993ed1d2781fdafd876038e6be0a1162d377be1"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Fit, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1351"), x: 820.0, y: 3530.0, width: 280.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("9993ed1d2781fdafd876038e6be0a1162d377be1"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Crop, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/images.image-sampling.primitives.txt b/crates/frame/render/tests/parity/golden/images.image-sampling.primitives.txt new file mode 100644 index 00000000..72a7cd97 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/images.image-sampling.primitives.txt @@ -0,0 +1,6 @@ +Rect { node_id: NodeId("8:1024"), x: 1980.0, y: 11400.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1494"), x: 2004.0, y: 11424.0, width: 286.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1495"), x: 2004.0, y: 11472.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1498"), x: 2160.0, y: 11540.0, width: 220.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L220 0 L220 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("b6f8e2adf979577a15f43ba46ba26289a0ef23f3"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4MDD4DwAE/gH/5pEq9AAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Fill, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1499"), x: 2430.0, y: 11540.0, width: 500.0, height: 120.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L500 0 L500 120 L0 120 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("b6f8e2adf979577a15f43ba46ba26289a0ef23f3"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4MDD4DwAE/gH/5pEq9AAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Fit, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1500"), x: 2980.0, y: 11540.0, width: 180.0, height: 260.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L180 0 L180 260 L0 260 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("b6f8e2adf979577a15f43ba46ba26289a0ef23f3"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4MDD4DwAE/gH/5pEq9AAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Crop, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/layout.auto-layout-hug-fill.primitives.txt b/crates/frame/render/tests/parity/golden/layout.auto-layout-hug-fill.primitives.txt new file mode 100644 index 00000000..9ac086c3 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/layout.auto-layout-hug-fill.primitives.txt @@ -0,0 +1,10 @@ +Rect { node_id: NodeId("8:877"), x: 0.0, y: 4560.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1360"), x: 24.0, y: 4584.0, width: 308.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1361"), x: 24.0, y: 4632.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("15:1364"), x: 180.0, y: 4680.0, width: 420.0, height: 256.0, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +ClipStart { node_id: NodeId("15:1365"), x: 204.0, y: 4704.0, width: 372.0, height: 64.0, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 } } +Rect { node_id: NodeId("15:1365"), x: 204.0, y: 4704.0, width: 372.0, height: 64.0, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1366"), x: 204.0, y: 4704.0, width: 140.0, height: 64.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L140 0 L140 64 L0 64 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.219607844949, g: 0.470588237047, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1367"), x: 356.0, y: 4704.0, width: 220.0, height: 64.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L220 0 L220 64 L0 64 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.470588237047, b: 0.219607844949, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +ClipEnd { node_id: NodeId("15:1365") } +Rect { node_id: NodeId("15:1368"), x: 204.0, y: 4792.0, width: 372.0, height: 120.0, fills: [(Solid(Rgba { r: 0.929411768913, g: 0.960784316063, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/masks.clip-content-nesting.primitives.txt b/crates/frame/render/tests/parity/golden/masks.clip-content-nesting.primitives.txt new file mode 100644 index 00000000..5d62ea55 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/masks.clip-content-nesting.primitives.txt @@ -0,0 +1,8 @@ +Rect { node_id: NodeId("8:1041"), x: 1980.0, y: 12540.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1509"), x: 2004.0, y: 12564.0, width: 321.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("15:1513"), x: 2160.0, y: 12680.0, width: 700.0, height: 420.0, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +ClipStart { node_id: NodeId("15:1514"), x: 2240.0, y: 12740.0, width: 500.0, height: 280.0, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 } } +Rect { node_id: NodeId("15:1514"), x: 2240.0, y: 12740.0, width: 500.0, height: 280.0, fills: [(Solid(Rgba { r: 0.949019610882, g: 0.960784316063, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1515"), x: 2120.0, y: 12780.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.392156869173, b: 0.392156869173, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1516"), x: 2400.0, y: 12660.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.392156869173, g: 0.627451002598, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +ClipEnd { node_id: NodeId("15:1514") } diff --git a/crates/frame/render/tests/parity/golden/masks.mask-clip-interactions.primitives.txt b/crates/frame/render/tests/parity/golden/masks.mask-clip-interactions.primitives.txt new file mode 100644 index 00000000..020f9b7a --- /dev/null +++ b/crates/frame/render/tests/parity/golden/masks.mask-clip-interactions.primitives.txt @@ -0,0 +1,15 @@ +Rect { node_id: NodeId("8:1006"), x: 1980.0, y: 10260.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1478"), x: 2004.0, y: 10284.0, width: 340.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1479"), x: 2004.0, y: 10332.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("15:1482"), x: 2160.0, y: 10400.0, width: 700.0, height: 420.0, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +ClipStart { node_id: NodeId("15:1483"), x: 2240.0, y: 10460.0, width: 500.0, height: 280.0, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 } } +Rect { node_id: NodeId("15:1483"), x: 2240.0, y: 10460.0, width: 500.0, height: 280.0, fills: [(Solid(Rgba { r: 0.949019610882, g: 0.960784316063, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1484"), x: 2120.0, y: 10500.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.392156869173, b: 0.392156869173, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1485"), x: 2400.0, y: 10380.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.392156869173, g: 0.627451002598, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +ClipEnd { node_id: NodeId("15:1483") } +MaskDefStart { node_id: NodeId("15:1486"), x: 2960.0, y: 10480.0, width: 220.0, height: 220.0, luminance: false } +Path { node_id: NodeId("15:1486"), x: 2960.0, y: 10480.0, width: 220.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M220 110 C220 170.751 170.751 220 110 220 C49.2487 220 0 170.751 0 110 C0 49.2487 49.2487 0 110 0 C170.751 0 220 49.2487 220 110 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +MaskDefEnd { node_id: NodeId("15:1486") } +MaskApplyStart { node_id: NodeId("15:1486"), x: 2960.0, y: 10480.0, width: 220.0, height: 220.0 } +Path { node_id: NodeId("15:1487"), x: 2900.0, y: 10440.0, width: 360.0, height: 280.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L360 0 L360 280 L0 280 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.368627458811, g: 0.670588254929, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 10.0, radius: 14.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +MaskApplyEnd { node_id: NodeId("15:1486") } diff --git a/crates/frame/render/tests/parity/golden/masks.vector-text-image-mask.primitives.txt b/crates/frame/render/tests/parity/golden/masks.vector-text-image-mask.primitives.txt new file mode 100644 index 00000000..29c08523 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/masks.vector-text-image-mask.primitives.txt @@ -0,0 +1,16 @@ +Rect { node_id: NodeId("8:850"), x: 1980.0, y: 2280.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1336"), x: 2004.0, y: 2304.0, width: 358.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1337"), x: 2004.0, y: 2352.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1340"), x: 2160.0, y: 2390.0, width: 920.0, height: 800.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 16 C0 7.16343 7.16344 0 16 0 L904 0 C912.837 0 920 7.16344 920 16 L920 784 C920 792.837 912.837 800 904 800 L16 800 C7.16346 800 0 792.837 0 784 L0 16 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.949019610882, g: 0.949019610882, b: 0.949019610882, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +MaskDefStart { node_id: NodeId("15:1341"), x: 2200.0, y: 2460.0, width: 280.0, height: 280.0, luminance: false } +Path { node_id: NodeId("15:1341"), x: 2200.0, y: 2460.0, width: 280.0, height: 280.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M280 140 C280 217.32 217.32 280 140 280 C62.6801 280 0 217.32 0 140 C0 62.6801 62.6801 0 140 0 C217.32 0 280 62.6801 280 140 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +MaskDefEnd { node_id: NodeId("15:1341") } +MaskApplyStart { node_id: NodeId("15:1341"), x: 2200.0, y: 2460.0, width: 280.0, height: 280.0 } +Path { node_id: NodeId("15:1342"), x: 2160.0, y: 2420.0, width: 420.0, height: 360.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L420 0 L420 360 L0 360 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.250980407, g: 0.600000023842, b: 0.949019610882, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +MaskApplyEnd { node_id: NodeId("15:1341") } +MaskDefStart { node_id: NodeId("15:1343"), x: 2630.0, y: 2520.0, width: 275.0, height: 116.0, luminance: false } +Path { node_id: NodeId("15:1343"), x: 2630.0, y: 2520.0, width: 275.0, height: 116.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +MaskDefEnd { node_id: NodeId("15:1343") } +MaskApplyStart { node_id: NodeId("15:1343"), x: 2630.0, y: 2520.0, width: 275.0, height: 116.0 } +Path { node_id: NodeId("15:1344"), x: 2600.0, y: 2460.0, width: 400.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L400 0 L400 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.40000000596, b: 0.40000000596, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +MaskApplyEnd { node_id: NodeId("15:1343") } diff --git a/crates/frame/render/tests/parity/golden/paint.blend-mode-combos.primitives.txt b/crates/frame/render/tests/parity/golden/paint.blend-mode-combos.primitives.txt new file mode 100644 index 00000000..d9d4c759 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/paint.blend-mode-combos.primitives.txt @@ -0,0 +1,23 @@ +Rect { node_id: NodeId("8:989"), x: 0.0, y: 10260.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1462"), x: 24.0, y: 10284.0, width: 315.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1463"), x: 24.0, y: 10332.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1466"), x: 180.0, y: 10400.0, width: 210.0, height: 210.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L210 0 L210 210 L0 210 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.933333337307, g: 0.933333337307, b: 0.964705884457, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1467"), x: 204.0, y: 10424.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.313725501299, b: 0.313725501299, a: 0.75 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("15:1468"), x: 256.0, y: 10476.0, width: 110.0, height: 110.0, blend: Multiply, opacity: 0.75 } +Path { node_id: NodeId("15:1468"), x: 256.0, y: 10476.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.313725501299, g: 0.588235318661, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1468") } +Path { node_id: NodeId("15:1469"), x: 440.0, y: 10400.0, width: 210.0, height: 210.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L210 0 L210 210 L0 210 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.933333337307, g: 0.933333337307, b: 0.964705884457, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1470"), x: 464.0, y: 10424.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.313725501299, b: 0.313725501299, a: 0.75 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("15:1471"), x: 516.0, y: 10476.0, width: 110.0, height: 110.0, blend: Screen, opacity: 0.75 } +Path { node_id: NodeId("15:1471"), x: 516.0, y: 10476.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.313725501299, g: 0.588235318661, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1471") } +Path { node_id: NodeId("15:1472"), x: 700.0, y: 10400.0, width: 210.0, height: 210.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L210 0 L210 210 L0 210 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.933333337307, g: 0.933333337307, b: 0.964705884457, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1473"), x: 724.0, y: 10424.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.313725501299, b: 0.313725501299, a: 0.75 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("15:1474"), x: 776.0, y: 10476.0, width: 110.0, height: 110.0, blend: Overlay, opacity: 0.75 } +Path { node_id: NodeId("15:1474"), x: 776.0, y: 10476.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.313725501299, g: 0.588235318661, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1474") } +Path { node_id: NodeId("15:1475"), x: 960.0, y: 10400.0, width: 210.0, height: 210.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L210 0 L210 210 L0 210 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.933333337307, g: 0.933333337307, b: 0.964705884457, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1476"), x: 984.0, y: 10424.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.313725501299, b: 0.313725501299, a: 0.75 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("15:1477"), x: 1036.0, y: 10476.0, width: 110.0, height: 110.0, blend: SoftLight, opacity: 0.75 } +Path { node_id: NodeId("15:1477"), x: 1036.0, y: 10476.0, width: 110.0, height: 110.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L110 0 L110 110 L0 110 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.313725501299, g: 0.588235318661, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1477") } diff --git a/crates/frame/render/tests/parity/golden/paint.blend-mode-stack.primitives.txt b/crates/frame/render/tests/parity/golden/paint.blend-mode-stack.primitives.txt new file mode 100644 index 00000000..62256d0e --- /dev/null +++ b/crates/frame/render/tests/parity/golden/paint.blend-mode-stack.primitives.txt @@ -0,0 +1,12 @@ +Rect { node_id: NodeId("8:821"), x: 0.0, y: 1140.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1310"), x: 24.0, y: 1164.0, width: 291.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1311"), x: 24.0, y: 1212.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1312"), x: 24.0, y: 1524.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1314"), x: 180.0, y: 1250.0, width: 920.0, height: 800.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 20 C0 8.95429 8.95431 0 20 0 L900 0 C911.046 0 920 8.95431 920 20 L920 780 C920 791.046 911.046 800 900 800 L20 800 C8.9543 800 0 791.046 0 780 L0 20 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.949019610882, g: 0.949019610882, b: 0.980392158031, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1315"), x: 280.0, y: 1320.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 0.235294118524, b: 0.235294118524, a: 0.75 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("15:1316"), x: 420.0, y: 1390.0, width: 300.0, height: 220.0, blend: Multiply, opacity: 0.75 } +Path { node_id: NodeId("15:1316"), x: 420.0, y: 1390.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.235294118524, g: 0.600000023842, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1316") } +LayerStart { node_id: NodeId("15:1317"), x: 560.0, y: 1460.0, width: 300.0, height: 220.0, blend: Screen, opacity: 0.75 } +Path { node_id: NodeId("15:1317"), x: 560.0, y: 1460.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.301960796118, g: 1.0, b: 0.40000000596, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1317") } diff --git a/crates/frame/render/tests/parity/golden/paint.gradient-matrix.primitives.txt b/crates/frame/render/tests/parity/golden/paint.gradient-matrix.primitives.txt new file mode 100644 index 00000000..6dab072e --- /dev/null +++ b/crates/frame/render/tests/parity/golden/paint.gradient-matrix.primitives.txt @@ -0,0 +1,6 @@ +Rect { node_id: NodeId("8:813"), x: 1980.0, y: 0.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1303"), x: 2004.0, y: 24.0, width: 261.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1304"), x: 2004.0, y: 72.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1307"), x: 2160.0, y: 110.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 1.0, g: 0.235294118524, b: 0.235294118524, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.235294118524, g: 0.235294118524, b: 1.0, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1308"), x: 2480.0, y: 110.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientRadial { transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 1.0, g: 0.960784316063, b: 0.313725501299, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.156862750649, g: 0.156862750649, b: 0.156862750649, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1309"), x: 2800.0, y: 110.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientAngular { transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.235294118524, g: 0.784313738346, b: 0.470588237047, a: 1.0 } }, GradientStop { offset: 0.5, color: Rgba { r: 0.901960790157, g: 0.372549027205, b: 0.823529422283, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.235294118524, g: 0.784313738346, b: 0.470588237047, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/primitives.fractional-geometry.primitives.txt b/crates/frame/render/tests/parity/golden/primitives.fractional-geometry.primitives.txt new file mode 100644 index 00000000..3c6c3d0c --- /dev/null +++ b/crates/frame/render/tests/parity/golden/primitives.fractional-geometry.primitives.txt @@ -0,0 +1,10 @@ +Rect { node_id: NodeId("8:802"), x: 0.0, y: 0.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1293"), x: 24.0, y: 24.0, width: 348.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1294"), x: 24.0, y: 72.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1295"), x: 24.0, y: 384.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1297"), x: 180.5, y: 120.5, width: 160.5, height: 80.5, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L160.5 0 L160.5 80.5 L0 80.5 L0 0 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.219607844949, g: 0.470588237047, b: 0.960784316063, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.0823529437184, g: 0.160784319043, b: 0.321568638086, a: 1.0 }, gradient: None, width: 1.0, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1298"), x: 390.5, y: 110.5, width: 120.5, height: 120.5, normalize_to_origin: false, text_outline: false, fill_paths: ["M120.5 60.25 C120.5 93.5252 93.5252 120.5 60.25 120.5 C26.9748 120.5 0 93.5252 0 60.25 C0 26.9748 26.9748 0 60.25 0 C93.5252 0 120.5 26.9748 120.5 60.25 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.450980395079, b: 0.219607844949, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1299"), x: 560.5, y: 165.5, width: 220.5, height: 0.0, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: ["M0 -0.5 L0 0 L220.5 0 L220.5 -0.5 L220.5 -1 L0 -1 L0 -0.5 Z"], svg_base64: None, fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.109803922474, g: 0.109803922474, b: 0.109803922474, a: 1.0 }, gradient: None, width: 1.0, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1300"), x: 180.5, y: 430.25, width: 120.25, height: 120.25, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120.25 0 L120.25 120.25 L0 120.25 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.168627455831, g: 0.729411780834, b: 0.549019634724, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1301"), x: 315.75, y: 430.75, width: 120.25, height: 120.25, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120.25 0 L120.25 120.25 L0 120.25 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.929411768913, g: 0.78823530674, b: 0.270588248968, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1302"), x: 451.5, y: 431.100006104, width: 120.25, height: 120.25, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L120.25 0 L120.25 120.25 L0 120.25 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.909803926945, g: 0.309803932905, b: 0.309803932905, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/primitives.single-pixel-anchor.primitives.txt b/crates/frame/render/tests/parity/golden/primitives.single-pixel-anchor.primitives.txt new file mode 100644 index 00000000..1389dbd1 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/primitives.single-pixel-anchor.primitives.txt @@ -0,0 +1,2 @@ +Rect { node_id: NodeId("8:912"), x: 0.0, y: 5700.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1397"), x: 137.0, y: 5911.0, width: 1.0, height: 1.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L1 0 L1 1 L0 1 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/regression.kitchen-sink.primitives.txt b/crates/frame/render/tests/parity/golden/regression.kitchen-sink.primitives.txt new file mode 100644 index 00000000..a4f4f63f --- /dev/null +++ b/crates/frame/render/tests/parity/golden/regression.kitchen-sink.primitives.txt @@ -0,0 +1,32 @@ +Rect { node_id: NodeId("8:887"), x: 1980.0, y: 4560.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1369"), x: 2004.0, y: 4584.0, width: 283.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1370"), x: 2004.0, y: 4632.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1371"), x: 2004.0, y: 4944.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1372"), x: 2004.0, y: 5256.0, width: 78.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1373"), x: 2160.0, y: 4670.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 1.0, g: 0.235294118524, b: 0.235294118524, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.235294118524, g: 0.235294118524, b: 1.0, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1374"), x: 2480.0, y: 4670.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientRadial { transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 1.0, g: 0.960784316063, b: 0.313725501299, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.156862750649, g: 0.156862750649, b: 0.156862750649, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1375"), x: 2800.0, y: 4670.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientAngular { transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.235294118524, g: 0.784313738346, b: 0.470588237047, a: 1.0 } }, GradientStop { offset: 0.5, color: Rgba { r: 0.901960790157, g: 0.372549027205, b: 0.823529422283, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.235294118524, g: 0.784313738346, b: 0.470588237047, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1376"), x: 2160.0, y: 4670.0, width: 920.0, height: 800.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 20 C0 8.95429 8.95431 0 20 0 L900 0 C911.046 0 920 8.95431 920 20 L920 780 C920 791.046 911.046 800 900 800 L20 800 C8.9543 800 0 791.046 0 780 L0 20 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.949019610882, g: 0.949019610882, b: 0.980392158031, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1377"), x: 2260.0, y: 4740.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 0.235294118524, b: 0.235294118524, a: 0.75 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("15:1378"), x: 2400.0, y: 4810.0, width: 300.0, height: 220.0, blend: Multiply, opacity: 0.75 } +Path { node_id: NodeId("15:1378"), x: 2400.0, y: 4810.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.235294118524, g: 0.600000023842, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1378") } +LayerStart { node_id: NodeId("15:1379"), x: 2540.0, y: 4880.0, width: 300.0, height: 220.0, blend: Screen, opacity: 0.75 } +Path { node_id: NodeId("15:1379"), x: 2540.0, y: 4880.0, width: 300.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L300 0 L300 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.301960796118, g: 1.0, b: 0.40000000596, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("15:1379") } +Path { node_id: NodeId("15:1380"), x: 2160.0, y: 4670.0, width: 280.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("9993ed1d2781fdafd876038e6be0a1162d377be1"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Fill, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1381"), x: 2480.0, y: 4670.0, width: 280.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("9993ed1d2781fdafd876038e6be0a1162d377be1"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Fit, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1382"), x: 2800.0, y: 4670.0, width: 280.0, height: 220.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 220 L0 220 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Image { image_hash: Some("9993ed1d2781fdafd876038e6be0a1162d377be1"), data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", alpha: 1.0, scale_mode: Crop, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1384"), x: 2180.0, y: 4740.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.109803922474, g: 0.109803922474, b: 0.109803922474, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1384"), x: 2180.0, y: 4740.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1384"), x: 2180.0, y: 4740.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.313725501299, b: 0.313725501299, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1384"), x: 2180.0, y: 4740.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.235294118524, g: 0.450980395079, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1385"), x: 2180.0, y: 5030.0, width: 1120.0, height: 120.0, normalize_to_origin: true, text_outline: true, fill_paths: ["", ""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal), (Solid(Rgba { r: 0.274509817362, g: 0.568627476692, b: 0.960784316063, a: 1.0 }), Normal), (Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1386"), x: 2180.0, y: 5340.0, width: 780.0, height: 170.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1386"), x: 2180.0, y: 5340.0, width: 780.0, height: 170.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.470588237047, b: 0.313725501299, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1387"), x: 2180.0, y: 4700.0, width: 240.0, height: 150.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L240 0 L240 150 L0 150 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 0.25 }, offset_x: 0.0, offset_y: 6.0, radius: 8.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1388"), x: 2500.0, y: 4700.0, width: 240.0, height: 150.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L240 0 L240 150 L0 150 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 0.34999999404 }, offset_x: 0.0, offset_y: 2.0, radius: 6.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("15:1389"), x: 2200.0, y: 4720.0, width: 560.0, height: 420.0, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1390"), x: 2320.0, y: 4800.0, width: 291.74248606, height: 230.123610795, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L260 0 L260 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.313725501299, g: 0.509803950787, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.209439499418, authored: false } +Path { node_id: NodeId("15:1391"), x: 2380.0, y: 4840.0, width: 249.404649138, height: 191.62109077, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L220 0 L220 130 L0 130 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.509803950787, b: 0.313725501299, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.314159265836, authored: false } +Path { node_id: NodeId("15:1392"), x: 2440.0, y: 4880.0, width: 163.520333469, height: 135.891469121, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L140 0 L140 90 L0 90 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.392156869173, g: 0.86274510622, b: 0.588235318661, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.383972418105, authored: false } diff --git a/crates/frame/render/tests/parity/golden/regression.tough-1176-blue-face.primitives.txt b/crates/frame/render/tests/parity/golden/regression.tough-1176-blue-face.primitives.txt new file mode 100644 index 00000000..aea288ea --- /dev/null +++ b/crates/frame/render/tests/parity/golden/regression.tough-1176-blue-face.primitives.txt @@ -0,0 +1,338 @@ +Rect { node_id: NodeId("1:228"), x: 16.0, y: -186.0, width: 841.200012207, height: 156.0, fills: [(GradientRadial { transform: Some(AffineTransform2D { a: -9.23610071232e-17, b: -1.67435898485, tx: 1.33717954233, c: 1.67435898485, d: -1.17069950263e-14, ty: -0.791025633221 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.164705887437, g: 0.164705887437, b: 0.172549024224, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.0874999985099, g: 0.0842187479138, b: 0.0874999985099, a: 1.0 } }] }, Normal), (Image { image_hash: Some("2100cf81935675556d2ec435206bf323403a7aa9"), data_base64: "", alpha: 1.0, scale_mode: Crop, image_transform: Some(AffineTransform2D { a: 0.936748325825, b: 0.0, tx: 0.0316258333623, c: 0.0, d: 0.795918345451, ty: 0.102040819824 }) }, SoftLight), (Solid(Rgba { r: 0.0614930540323, g: 0.0722665712237, b: 0.0958333313465, a: 0.5 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 3.20000004768, top_right: 3.20000004768, bottom_right: 3.20000004768, bottom_left: 3.20000004768 }, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.473958343267, g: 0.527390122414, b: 0.649999976158, a: 0.25 }, offset_x: 0.800000011921, offset_y: 0.800000011921, radius: 0.0, spread: 0.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.40000000596 }, offset_x: -0.800000011921, offset_y: -0.800000011921, radius: 0.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:229;2847:3540"), x: 23.5999755859, y: -178.0, width: 20.3999996185, height: 13.1999998093, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 6.2 C0 2.77583 2.77584 0 6.2 0 L14.2 0 C17.6242 0 20.4 2.77584 20.4 6.2 L20.4 7 C20.4 10.4242 17.6242 13.2 14.2 13.2 L6.2 13.2 C2.77583 13.2 0 10.4242 0 7 L0 6.2 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -0.622448960789, b: 0.967741881758, tx: 0.271231061598, c: -0.967741881758, d: -1.55515076776, ty: 1.8743494815 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.254901975393, g: 0.254901975393, b: 0.262745112181, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:229;2847:3539"), x: 24.399974823, y: -177.199996948, width: 18.8000011444, height: 11.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 5.8 C0 2.59675 2.59675 0 5.8 0 L13 0 C16.2032 0 18.8 2.59675 18.8 5.8 L18.8 5.8 C18.8 9.00325 16.2033 11.6 13 11.6 L5.8 11.6 C2.59675 11.6 0 9.00325 0 5.8 L0 5.8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:230;2847:3540"), x: 23.5999755859, y: -50.3999938965, width: 20.3999996185, height: 13.1999998093, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 6.2 C0 2.77583 2.77584 0 6.2 0 L14.2 0 C17.6242 0 20.4 2.77584 20.4 6.2 L20.4 7 C20.4 10.4242 17.6242 13.2 14.2 13.2 L6.2 13.2 C2.77583 13.2 0 10.4242 0 7 L0 6.2 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -0.622448960789, b: 0.967741881758, tx: 0.271231061598, c: -0.967741881758, d: -1.55515076776, ty: 1.8743494815 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.254901975393, g: 0.254901975393, b: 0.262745112181, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:230;2847:3539"), x: 24.399974823, y: -49.5999946594, width: 18.8000011444, height: 11.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 5.8 C0 2.59675 2.59675 0 5.8 0 L13 0 C16.2032 0 18.8 2.59675 18.8 5.8 L18.8 5.8 C18.8 9.00325 16.2033 11.6 13 11.6 L5.8 11.6 C2.59675 11.6 0 9.00325 0 5.8 L0 5.8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:231;2847:3540"), x: 831.599975586, y: -178.0, width: 20.3999996185, height: 13.1999998093, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 6.2 C0 2.77583 2.77584 0 6.2 0 L14.2 0 C17.6242 0 20.4 2.77584 20.4 6.2 L20.4 7 C20.4 10.4242 17.6242 13.2 14.2 13.2 L6.2 13.2 C2.77583 13.2 0 10.4242 0 7 L0 6.2 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -0.622448960789, b: 0.967741881758, tx: 0.271231061598, c: -0.967741881758, d: -1.55515076776, ty: 1.8743494815 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.254901975393, g: 0.254901975393, b: 0.262745112181, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:231;2847:3539"), x: 832.399963379, y: -177.199996948, width: 18.8000011444, height: 11.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 5.8 C0 2.59675 2.59675 0 5.8 0 L13 0 C16.2032 0 18.8 2.59675 18.8 5.8 L18.8 5.8 C18.8 9.00325 16.2033 11.6 13 11.6 L5.8 11.6 C2.59675 11.6 0 9.00325 0 5.8 L0 5.8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:232;2847:3540"), x: 831.599975586, y: -50.3999938965, width: 20.3999996185, height: 13.1999998093, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 6.2 C0 2.77583 2.77584 0 6.2 0 L14.2 0 C17.6242 0 20.4 2.77584 20.4 6.2 L20.4 7 C20.4 10.4242 17.6242 13.2 14.2 13.2 L6.2 13.2 C2.77583 13.2 0 10.4242 0 7 L0 6.2 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -0.622448960789, b: 0.967741881758, tx: 0.271231061598, c: -0.967741881758, d: -1.55515076776, ty: 1.8743494815 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.254901975393, g: 0.254901975393, b: 0.262745112181, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:232;2847:3539"), x: 832.399963379, y: -49.5999946594, width: 18.8000011444, height: 11.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 5.8 C0 2.59675 2.59675 0 5.8 0 L13 0 C16.2032 0 18.8 2.59675 18.8 5.8 L18.8 5.8 C18.8 9.00325 16.2033 11.6 13 11.6 L5.8 11.6 C2.59675 11.6 0 9.00325 0 5.8 L0 5.8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.00392156885937, g: 0.00392156885937, b: 0.00392156885937, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:233;2845:3288"), x: 64.7999801636, y: -154.800003052, width: 14.4000005722, height: 14.4000005722, normalize_to_origin: false, text_outline: false, fill_paths: ["M14.4 7.2 C14.4 11.1765 11.1765 14.4 7.2 14.4 C3.22355 14.4 0 11.1765 0 7.2 C0 3.22355 3.22355 0 7.2 0 C11.1765 0 14.4 3.22355 14.4 7.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.126985684037, g: 0.124869249761, b: 0.124869249761, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.483333319426, g: 0.483333319426, b: 0.483333319426, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: -0.800000011921, radius: 1.60000002384, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:233;2845:3299"), x: 65.9999771118, y: -153.600006104, width: 13.6000003815, height: 13.6000003815, blend: Normal, opacity: 0.800000011921 } +Path { node_id: NodeId("I1:233;2845:3299"), x: 65.9999771118, y: -153.600006104, width: 13.6000003815, height: 13.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M11.6083 1.99167 C10.6573 1.04068 9.44568 0.393039 8.12661 0.13066 C6.80754 -0.131719 5.44029 0.00294347 4.19775 0.51762 C2.95521 1.0323 1.8932 1.90387 1.14601 3.02212 C0.398813 4.14038 -2.03076e-07 5.45509 7.77156e-14 6.8 L6.8 6.8 L11.6083 1.99167 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.168627455831, g: 0.168627455831, b: 0.168627455831, a: 1.0 }), Normal), (GradientLinear { transform: Some(AffineTransform2D { a: 0.356725140803, b: -0.502923983861, tx: 0.47368421067, c: 0.502923983861, d: 0.356725140803, ty: -0.0438596472485 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.570833325386, g: 0.570833325386, b: 0.570833325386, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:233;2845:3299") } +Path { node_id: NodeId("I1:233;2845:3296"), x: 68.0430450439, y: -151.556915283, width: 8.80000019073, height: 8.80000019073, normalize_to_origin: false, text_outline: false, fill_paths: ["M0.799999 5.2 C2.3464 5.2 3.6 6.4536 3.6 8 L3.6 8.8 L5.2 8.8 L5.2 8 C5.2 6.4536 6.4536 5.2 8 5.2 L8.8 5.2 L8.8 3.6 L8 3.6 C6.4536 3.6 5.2 2.3464 5.2 0.799999 L5.2 0 L3.6 0 L3.6 0.799999 C3.6 2.3464 2.3464 3.6 0.799999 3.6 L0 3.6 L0 5.2 L0.799999 5.2 Z"], stroke_paths: [""], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0156862754375, g: 0.0156862754375, b: 0.0156862754375, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.287204861641, g: 0.290080964565, b: 0.295833319426, a: 1.0 }, gradient: None, width: 0.40000000596, cap: None, join: Miter, align: Outside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } +Path { node_id: NodeId("I1:233;2845:3298"), x: 71.1679229736, y: -148.632064819, width: 4.40000009537, height: 4.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -1.47743647709e-15, b: -0.5, tx: 0.749999880791, c: 0.5, d: -5.26352033248e-15, ty: 0.374999970198 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.1254902035, g: 0.133333340287, b: 0.129411771894, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.254901975393, g: 0.258823543787, b: 0.266666680574, a: 1.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } +Path { node_id: NodeId("I1:234;2845:3288"), x: 64.7999801636, y: -78.0, width: 14.4000005722, height: 14.4000005722, normalize_to_origin: false, text_outline: false, fill_paths: ["M14.4 7.2 C14.4 11.1765 11.1765 14.4 7.2 14.4 C3.22355 14.4 0 11.1765 0 7.2 C0 3.22355 3.22355 0 7.2 0 C11.1765 0 14.4 3.22355 14.4 7.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.126985684037, g: 0.124869249761, b: 0.124869249761, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.483333319426, g: 0.483333319426, b: 0.483333319426, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: -0.800000011921, radius: 1.60000002384, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:234;2845:3299"), x: 65.9999771118, y: -76.8000030518, width: 13.6000003815, height: 13.6000003815, blend: Normal, opacity: 0.800000011921 } +Path { node_id: NodeId("I1:234;2845:3299"), x: 65.9999771118, y: -76.8000030518, width: 13.6000003815, height: 13.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M11.6083 1.99167 C10.6573 1.04068 9.44568 0.393039 8.12661 0.13066 C6.80754 -0.131719 5.44029 0.00294347 4.19775 0.51762 C2.95521 1.0323 1.8932 1.90387 1.14601 3.02212 C0.398813 4.14038 -2.03076e-07 5.45509 7.77156e-14 6.8 L6.8 6.8 L11.6083 1.99167 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.168627455831, g: 0.168627455831, b: 0.168627455831, a: 1.0 }), Normal), (GradientLinear { transform: Some(AffineTransform2D { a: 0.356725140803, b: -0.502923983861, tx: 0.47368421067, c: 0.502923983861, d: 0.356725140803, ty: -0.0438596472485 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.570833325386, g: 0.570833325386, b: 0.570833325386, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:234;2845:3299") } +Path { node_id: NodeId("I1:234;2845:3296"), x: 68.0430450439, y: -74.7569274902, width: 8.80000019073, height: 8.80000019073, normalize_to_origin: false, text_outline: false, fill_paths: ["M0.799999 5.2 C2.3464 5.2 3.6 6.4536 3.6 8 L3.6 8.8 L5.2 8.8 L5.2 8 C5.2 6.4536 6.4536 5.2 8 5.2 L8.8 5.2 L8.8 3.6 L8 3.6 C6.4536 3.6 5.2 2.3464 5.2 0.799999 L5.2 0 L3.6 0 L3.6 0.799999 C3.6 2.3464 2.3464 3.6 0.799999 3.6 L0 3.6 L0 5.2 L0.799999 5.2 Z"], stroke_paths: [""], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0156862754375, g: 0.0156862754375, b: 0.0156862754375, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.287204861641, g: 0.290080964565, b: 0.295833319426, a: 1.0 }, gradient: None, width: 0.40000000596, cap: None, join: Miter, align: Outside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } +Path { node_id: NodeId("I1:234;2845:3298"), x: 71.1679229736, y: -71.832069397, width: 4.40000009537, height: 4.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -1.47743647709e-15, b: -0.5, tx: 0.749999880791, c: 0.5, d: -5.26352033248e-15, ty: 0.374999970198 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.1254902035, g: 0.133333340287, b: 0.129411771894, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.254901975393, g: 0.258823543787, b: 0.266666680574, a: 1.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } +Rect { node_id: NodeId("I1:235;2847:3412"), x: 99.4615020752, y: -152.400009155, width: 70.8000030518, height: 70.8000030518, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:235;2847:3411"), x: 99.4615020752, y: -152.400009155, width: 70.8000030518, height: 70.8000030518, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3222"), x: 99.4615020752, y: -152.400009155, width: 70.8000030518, height: 70.8000030518, normalize_to_origin: false, text_outline: false, fill_paths: ["M70.8 35.4 C70.8 54.9509 54.9509 70.8 35.4 70.8 C15.8491 70.8 0 54.9509 0 35.4 C0 15.8491 15.8491 0 35.4 0 C54.9509 0 70.8 15.8491 70.8 35.4 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientRadial { transform: Some(AffineTransform2D { a: -1.46327687617, b: 1.25988697961, tx: 1.08192093389, c: -1.25988697961, d: -1.46327687617, ty: 2.27966110601 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.295833319426, g: 0.295833319426, b: 0.295833319426, a: 1.0 } }, GradientStop { offset: 0.533748030663, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 1.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.25, g: 0.248958334327, b: 0.248958334327, a: 1.0 }, offset_x: 0.0, offset_y: -2.0, radius: 0.800000011921, spread: -0.800000011921 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3223"), x: 101.061424255, y: -150.800048828, width: 69.2000045776, height: 69.2000045776, normalize_to_origin: false, text_outline: false, fill_paths: ["M59.0659 10.1341 C53.1303 4.19848 45.2392 0.62309 36.8629 0.074081 C28.4867 -0.474928 20.1965 2.03988 13.5369 7.14998 C6.87725 12.2601 2.30246 19.6169 0.664829 27.8499 C-0.972807 36.0828 0.438399 44.6304 4.63552 51.9 L34.6 34.6 L59.0659 10.1341 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.356725140803, b: -0.502923983861, tx: 0.47368421067, c: 0.502923983861, d: 0.356725140803, ty: -0.0438596472485 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.570833325386, g: 0.570833325386, b: 0.570833325386, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.40000000596 }, offset_x: 0.800000011921, offset_y: 0.800000011921, radius: 0.40000000596, spread: -0.40000000596 }, NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3224"), x: 104.045761108, y: -130.146759033, width: 14.8000001907, height: 1.37439584732, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.687198 C0 0.307669 0.307669 0 0.687198 0 L14.1128 0 C14.4923 0 14.8 0.307669 14.8 0.687198 L14.8 0.687198 C14.8 1.06673 14.4923 1.3744 14.1128 1.3744 L0.687198 1.3744 C0.307669 1.3744 0 1.06673 0 0.687198 L0 0.687198 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.933333337307, g: 0.929444432259, b: 0.929444432259, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.10000000149 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }], blend: Normal, rotation: 0.366519153098, authored: false } +Rect { node_id: NodeId("I1:235;2845:3225"), x: 114.661499023, y: -137.400009155, width: 55.1991157532, height: 49.9996757507, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:235;2847:3410"), x: 114.661499023, y: -137.400009155, width: 55.1991157532, height: 49.9996757507, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:235;2845:3226"), x: 117.608299255, y: -137.400009155, width: 51.4522399902, height: 49.3996734619, blend: Normal, opacity: 0.699999988079 } +Path { node_id: NodeId("I1:235;2845:3226"), x: 117.608299255, y: -137.400009155, width: 51.4522399902, height: 49.3996734619, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0823529437184, g: 0.0823529437184, b: 0.0823529437184, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:235;2845:3226") } +Rect { node_id: NodeId("I1:235;2847:3409"), x: 114.661499023, y: -137.200012207, width: 43.2000007629, height: 43.2000007629, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3227"), x: 114.661499023, y: -137.200012207, width: 43.2000007629, height: 43.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0583333335817, g: 0.0583333335817, b: 0.0583333335817, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 1.0 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:235;2845:3228"), x: 115.461494446, y: -136.400009155, width: 40.7999992371, height: 40.7999992371, blend: Normal, opacity: 0.600000023842 } +Path { node_id: NodeId("I1:235;2845:3228"), x: 115.461494446, y: -136.400009155, width: 40.7999992371, height: 40.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.274301992268, b: -0.819270221178, tx: 0.635333093905, c: 0.514026735962, d: 0.437190197409, ty: -0.232621821637 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 1.0 } }, GradientStop { offset: 0.933754086494, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.695833325386, g: 0.695833325386, b: 0.695833325386, a: 1.0 }, offset_x: 0.40000000596, offset_y: 0.40000000596, radius: 0.0, spread: 0.0 }, NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:235;2845:3228") } +Path { node_id: NodeId("I1:235;2845:3229"), x: 117.06149292, y: -134.800018311, width: 36.7999992371, height: 36.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: ["M36.8 18.4 C36.8 28.562 28.562 36.8 18.4 36.8 C8.23796 36.8 0 28.562 0 18.4 C0 8.23796 8.23796 0 18.4 0 C28.562 0 36.8 8.23796 36.8 18.4 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientAngular { transform: Some(AffineTransform2D { a: -0.707106793288, b: -0.707106793288, tx: 1.20710683544, c: 0.707106793288, d: -0.707106793288, ty: 0.500000885084 }), stops: [GradientStop { offset: 0.0139440111816, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }, GradientStop { offset: 0.168265238404, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.349274724722, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.492538094521, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.518536567688, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.656246423721, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.84331792593, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.982868492603, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }] }, Normal), (Image { image_hash: Some("6c705c704de71c825c5fd0b874c2925852350627"), data_base64: "", alpha: 0.300000011921, scale_mode: Fill, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Darken)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: 2.79999995232, offset_y: 4.80000019073, radius: 0.40000000596, spread: -4.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: -2.0, offset_y: -2.79999995232, radius: 0.40000000596, spread: -2.40000009537 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:235;2845:3287"), x: 83.9999771118, y: -165.600006104, width: 100.999992371, height: 96.3999938965, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:235;2845:3277"), x: 96.3999786377, y: -156.0, width: 79.6519699097, height: 80.1934127808, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3234"), x: 135.044952393, y: -155.503311157, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ2LjE3MzQgMS4yIDEuMikiIGZpbGw9IiNERURFREUiLz4KPC9zdmc+Cg=="), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.805877148002, authored: false } +Path { node_id: NodeId("I1:235;2845:3249"), x: 168.044189453, y: -136.339050293, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.231149922021, authored: false } +Path { node_id: NodeId("I1:235;2845:3247"), x: 158.90272522, y: -146.827194214, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.135108994832, authored: false } +Path { node_id: NodeId("I1:235;2845:3245"), x: 148.052993774, y: -152.861312866, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.461547460386, authored: false } +Path { node_id: NodeId("I1:235;2845:3267"), x: 102.007209778, y: -136.269165039, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 2.91044273157, authored: false } +Path { node_id: NodeId("I1:235;2845:3266"), x: 172.936904907, y: -123.664543152, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -2.54690339776, authored: false } +Path { node_id: NodeId("I1:235;2845:3262"), x: 173.178512573, y: -109.853637695, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -2.18883475071, authored: false } +Path { node_id: NodeId("I1:235;2845:3260"), x: 168.385009766, y: -97.9526672363, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.85307472258, authored: false } +Path { node_id: NodeId("I1:235;2845:3258"), x: 158.993545532, y: -87.0411987305, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.47593555523, authored: false } +Path { node_id: NodeId("I1:235;2845:3256"), x: 147.827072144, y: -80.947052002, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.14318485983, authored: false } +Path { node_id: NodeId("I1:235;2845:3268"), x: 111.148925781, y: -146.757324219, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -3.00648365876, authored: false } +Path { node_id: NodeId("I1:235;2845:3269"), x: 121.998374939, y: -152.791442871, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -2.6800451932, authored: false } +Path { node_id: NodeId("I1:235;2845:3235"), x: 135.158233643, y: -78.7033004761, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ2LjE3MzQgMS4yIDEuMikiIGZpbGw9IiNERURFREUiLz4KPC9zdmc+Cg=="), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.805877148002, authored: false } +Path { node_id: NodeId("I1:235;2845:3270"), x: 97.1147918701, y: -123.594665527, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.59468925583, authored: false } +Path { node_id: NodeId("I1:235;2845:3271"), x: 96.8732299805, y: -109.783760071, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.952757902875, authored: false } +Path { node_id: NodeId("I1:235;2845:3272"), x: 101.666267395, y: -97.8827972412, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.28851793101, authored: false } +Path { node_id: NodeId("I1:235;2845:3273"), x: 111.05821991, y: -86.9713287354, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.66565709836, authored: false } +Path { node_id: NodeId("I1:235;2845:3274"), x: 122.224243164, y: -80.877166748, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.99840779376, authored: false } +Rect { node_id: NodeId("I1:235;2845:3286"), x: 83.9999771118, y: -165.600006104, width: 100.999992371, height: 96.3999938965, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3275"), x: 84.5312271118, y: -126.697731018, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3282"), x: 103.331222534, y: -153.897720337, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3284"), x: 162.860183716, y: -153.897720337, width: 9.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3283"), x: 131.846130371, y: -163.49772644, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3281"), x: 176.460189819, y: -126.697731018, width: 9.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3278"), x: 89.6374740601, y: -95.8977355957, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3285"), x: 172.868713379, y: -96.2977371216, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3279"), x: 149.651687622, y: -77.0977401733, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:235;2845:3280"), x: 118.780662537, y: -75.3051300049, width: 8.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:236;2847:3417"), x: 275.461486816, y: -152.400009155, width: 70.8000030518, height: 70.8000030518, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:236;2847:3418"), x: 275.461486816, y: -152.400009155, width: 70.8000030518, height: 70.8000030518, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3419"), x: 275.461486816, y: -152.400009155, width: 70.8000030518, height: 70.8000030518, normalize_to_origin: false, text_outline: false, fill_paths: ["M70.8 35.4 C70.8 54.9509 54.9509 70.8 35.4 70.8 C15.8491 70.8 0 54.9509 0 35.4 C0 15.8491 15.8491 0 35.4 0 C54.9509 0 70.8 15.8491 70.8 35.4 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientRadial { transform: Some(AffineTransform2D { a: -1.46327687617, b: 1.25988697961, tx: 1.08192093389, c: -1.25988697961, d: -1.46327687617, ty: 2.27966110601 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.295833319426, g: 0.295833319426, b: 0.295833319426, a: 1.0 } }, GradientStop { offset: 0.533748030663, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 1.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.25, g: 0.248958334327, b: 0.248958334327, a: 1.0 }, offset_x: 0.0, offset_y: -2.0, radius: 0.800000011921, spread: -0.800000011921 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3420"), x: 277.061401367, y: -150.800048828, width: 69.2000045776, height: 69.2000045776, normalize_to_origin: false, text_outline: false, fill_paths: ["M59.0659 10.1341 C53.1303 4.19848 45.2392 0.62309 36.8629 0.074081 C28.4867 -0.474928 20.1965 2.03988 13.5369 7.14998 C6.87725 12.2601 2.30246 19.6169 0.664829 27.8499 C-0.972807 36.0828 0.438399 44.6304 4.63552 51.9 L34.6 34.6 L59.0659 10.1341 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.356725140803, b: -0.502923983861, tx: 0.47368421067, c: 0.502923983861, d: 0.356725140803, ty: -0.0438596472485 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.570833325386, g: 0.570833325386, b: 0.570833325386, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.40000000596 }, offset_x: 0.800000011921, offset_y: 0.800000011921, radius: 0.40000000596, spread: -0.40000000596 }, NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3421"), x: 329.343200684, y: -130.037017822, width: 14.8000001907, height: 1.37439584732, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.687198 C0 0.307669 0.307669 0 0.687198 0 L14.1128 0 C14.4923 0 14.8 0.307669 14.8 0.687198 L14.8 0.687198 C14.8 1.06673 14.4923 1.3744 14.1128 1.3744 L0.687198 1.3744 C0.307669 1.3744 0 1.06673 0 0.687198 L0 0.687198 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.933333337307, g: 0.929444432259, b: 0.929444432259, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.10000000149 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }], blend: Normal, rotation: 2.79252681969, authored: false } +Rect { node_id: NodeId("I1:236;2847:3422"), x: 290.661499023, y: -137.400009155, width: 55.1991157532, height: 49.9996757507, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:236;2847:3423"), x: 290.661499023, y: -137.400009155, width: 55.1991157532, height: 49.9996757507, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:236;2847:3424"), x: 293.608337402, y: -137.400009155, width: 51.4522399902, height: 49.3996734619, blend: Normal, opacity: 0.699999988079 } +Path { node_id: NodeId("I1:236;2847:3424"), x: 293.608337402, y: -137.400009155, width: 51.4522399902, height: 49.3996734619, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0823529437184, g: 0.0823529437184, b: 0.0823529437184, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:236;2847:3424") } +Rect { node_id: NodeId("I1:236;2847:3425"), x: 290.661499023, y: -137.200012207, width: 43.2000007629, height: 43.2000007629, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3426"), x: 290.661499023, y: -137.200012207, width: 43.2000007629, height: 43.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0583333335817, g: 0.0583333335817, b: 0.0583333335817, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 1.0 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:236;2847:3427"), x: 291.461517334, y: -136.400009155, width: 40.7999992371, height: 40.7999992371, blend: Normal, opacity: 0.600000023842 } +Path { node_id: NodeId("I1:236;2847:3427"), x: 291.461517334, y: -136.400009155, width: 40.7999992371, height: 40.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.274301992268, b: -0.819270221178, tx: 0.635333093905, c: 0.514026735962, d: 0.437190197409, ty: -0.232621821637 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 1.0 } }, GradientStop { offset: 0.933754086494, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.695833325386, g: 0.695833325386, b: 0.695833325386, a: 1.0 }, offset_x: 0.40000000596, offset_y: 0.40000000596, radius: 0.0, spread: 0.0 }, NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:236;2847:3427") } +Path { node_id: NodeId("I1:236;2847:3428"), x: 293.061523438, y: -134.800018311, width: 36.7999992371, height: 36.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: ["M36.8 18.4 C36.8 28.562 28.562 36.8 18.4 36.8 C8.23796 36.8 0 28.562 0 18.4 C0 8.23796 8.23796 0 18.4 0 C28.562 0 36.8 8.23796 36.8 18.4 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientAngular { transform: Some(AffineTransform2D { a: -0.707106793288, b: -0.707106793288, tx: 1.20710683544, c: 0.707106793288, d: -0.707106793288, ty: 0.500000885084 }), stops: [GradientStop { offset: 0.0139440111816, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }, GradientStop { offset: 0.168265238404, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.349274724722, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.492538094521, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.518536567688, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.656246423721, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.84331792593, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.982868492603, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }] }, Normal), (Image { image_hash: Some("6c705c704de71c825c5fd0b874c2925852350627"), data_base64: "", alpha: 0.300000011921, scale_mode: Fill, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Darken)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: 2.79999995232, offset_y: 4.80000019073, radius: 0.40000000596, spread: -4.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: -2.0, offset_y: -2.79999995232, radius: 0.40000000596, spread: -2.40000009537 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:236;2847:3429"), x: 259.999969482, y: -165.600006104, width: 101.0, height: 96.3999938965, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:236;2847:3430"), x: 272.399963379, y: -156.0, width: 79.6519699097, height: 80.1934127808, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3431"), x: 311.044952393, y: -155.503311157, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ2LjE3MzQgMS4yIDEuMikiIGZpbGw9IiNERURFREUiLz4KPC9zdmc+Cg=="), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.805877148002, authored: false } +Path { node_id: NodeId("I1:236;2847:3432"), x: 344.044158936, y: -136.339050293, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.231149922021, authored: false } +Path { node_id: NodeId("I1:236;2847:3433"), x: 334.902709961, y: -146.827194214, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.135108994832, authored: false } +Path { node_id: NodeId("I1:236;2847:3434"), x: 324.052978516, y: -152.861312866, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.461547460386, authored: false } +Path { node_id: NodeId("I1:236;2847:3435"), x: 278.007202148, y: -136.269165039, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 2.91044273157, authored: false } +Path { node_id: NodeId("I1:236;2847:3436"), x: 348.936859131, y: -123.664543152, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -2.54690339776, authored: false } +Path { node_id: NodeId("I1:236;2847:3437"), x: 349.178497314, y: -109.853637695, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -2.18883475071, authored: false } +Path { node_id: NodeId("I1:236;2847:3438"), x: 344.384979248, y: -97.9526672363, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.85307472258, authored: false } +Path { node_id: NodeId("I1:236;2847:3439"), x: 334.993530273, y: -87.0411987305, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.47593555523, authored: false } +Path { node_id: NodeId("I1:236;2847:3440"), x: 323.827056885, y: -80.947052002, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.14318485983, authored: false } +Path { node_id: NodeId("I1:236;2847:3441"), x: 287.148895264, y: -146.757324219, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -3.00648365876, authored: false } +Path { node_id: NodeId("I1:236;2847:3442"), x: 297.998352051, y: -152.791442871, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -2.6800451932, authored: false } +Path { node_id: NodeId("I1:236;2847:3443"), x: 311.158233643, y: -78.7033004761, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ2LjE3MzQgMS4yIDEuMikiIGZpbGw9IiNERURFREUiLz4KPC9zdmc+Cg=="), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.805877148002, authored: false } +Path { node_id: NodeId("I1:236;2847:3444"), x: 273.114776611, y: -123.594665527, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.59468925583, authored: false } +Path { node_id: NodeId("I1:236;2847:3445"), x: 272.87322998, y: -109.783760071, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.952757902875, authored: false } +Path { node_id: NodeId("I1:236;2847:3446"), x: 277.666259766, y: -97.8827972412, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.28851793101, authored: false } +Path { node_id: NodeId("I1:236;2847:3447"), x: 287.058197021, y: -86.9713287354, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.66565709836, authored: false } +Path { node_id: NodeId("I1:236;2847:3448"), x: 298.224212646, y: -80.877166748, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -1.99840779376, authored: false } +Rect { node_id: NodeId("I1:236;2847:3449"), x: 259.999969482, y: -165.600006104, width: 101.0, height: 96.3999938965, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3450"), x: 260.531219482, y: -126.697731018, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3451"), x: 279.331207275, y: -153.897720337, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3452"), x: 338.860198975, y: -153.897720337, width: 9.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3453"), x: 307.846130371, y: -163.49772644, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3454"), x: 352.460205078, y: -126.697731018, width: 9.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3455"), x: 265.637481689, y: -95.8977355957, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3456"), x: 348.868713379, y: -96.2977371216, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3457"), x: 325.651641846, y: -77.0977401733, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:236;2847:3458"), x: 294.780639648, y: -75.3051300049, width: 8.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:237"), x: 131.846130371, y: -163.49772644, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:238"), x: 124.959495544, y: -60.2181854248, width: 25.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:239"), x: 296.709472656, y: -60.2977294922, width: 34.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:240"), x: 424.590301514, y: -179.918182373, width: 25.0, height: 8.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("1:241"), x: 207.999969482, y: -165.600006104, width: 29.2000007629, height: 23.6000003815, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:242"), x: 217.092300415, y: -163.418182373, width: 12.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("1:243"), x: 207.999969482, y: -155.199996948, width: 29.2000007629, height: 12.8000001907, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:244"), x: 231.94430542, y: -149.410217285, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M2.28125 7.63068 L2.28125 3.78977 L3.04545 3.78977 L3.04545 7.63068 L2.28125 7.63068 Z M0.744318 6.09091 L0.744318 5.3267 L4.58523 5.3267 L4.58523 6.09091 L0.744318 6.09091 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:245"), x: 208.859344482, y: -148.026702881, width: 4.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M3.43892 5.1733 L3.43892 5.90909 L0.859375 5.90909 L0.859375 5.1733 L3.43892 5.1733 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:246;2857:3648"), x: 214.799987793, y: -155.199996948, width: 12.8000001907, height: 12.8000001907, normalize_to_origin: false, text_outline: false, fill_paths: ["M12.8 6.4 C12.8 9.93462 9.93462 12.8 6.4 12.8 C2.86538 12.8 0 9.93462 0 6.4 C0 2.86538 2.86538 0 6.4 0 C9.93462 0 12.8 2.86538 12.8 6.4 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -0.671875002503, b: 0.671875002503, tx: 0.507812463708, c: -0.671875002503, d: -0.671875002503, ty: 1.3359374612 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.487500011921, g: 0.487500011921, b: 0.487500011921, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.34999999404, g: 0.34999999404, b: 0.33979165554, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:246;2857:3646"), x: 214.799987793, y: -155.199996948, width: 9.60000038147, height: 9.60000038147, normalize_to_origin: false, text_outline: false, fill_paths: ["M9.6 4.8 C9.6 7.45097 7.45097 9.6 4.8 9.6 C2.14903 9.6 0 7.45097 0 4.8 C0 2.14903 2.14903 0 4.8 0 C7.45097 0 9.6 2.14903 9.6 4.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.440690100193, g: 0.440690100193, b: 0.426172584295, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.800000011921 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 1.20000004768 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:246;2857:3647"), x: 216.799987793, y: -149.199996948, width: 9.60000038147, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L9.6 0 L9.6 1.6 L0 1.6 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.270588248968, g: 0.270588248968, b: 0.262745112181, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.10000000149 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:246;2857:3649"), x: 219.999984741, y: -152.800003052, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.204166665673, g: 0.204166665673, b: 0.20246528089, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.800000011921 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.800000011921, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("1:247"), x: 207.999969482, y: -98.0, width: 29.2000007629, height: 23.6000003815, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:248"), x: 215.20715332, y: -95.8181838989, width: 15.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("1:249"), x: 207.999969482, y: -87.5999984741, width: 29.2000007629, height: 12.8000001907, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:250"), x: 231.94430542, y: -81.8102264404, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M2.28125 7.63068 L2.28125 3.78977 L3.04545 3.78977 L3.04545 7.63068 L2.28125 7.63068 Z M0.744318 6.09091 L0.744318 5.3267 L4.58523 5.3267 L4.58523 6.09091 L0.744318 6.09091 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:251"), x: 208.859344482, y: -80.4267044067, width: 4.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M3.43892 5.1733 L3.43892 5.90909 L0.859375 5.90909 L0.859375 5.1733 L3.43892 5.1733 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:252;2859:3744"), x: 214.799987793, y: -87.5999984741, width: 12.8000001907, height: 12.8000001907, normalize_to_origin: false, text_outline: false, fill_paths: ["M12.8 6.4 C12.8 9.93462 9.93462 12.8 6.4 12.8 C2.86538 12.8 0 9.93462 0 6.4 C0 2.86538 2.86538 0 6.4 0 C9.93462 0 12.8 2.86538 12.8 6.4 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -0.671875002503, b: 0.671875002503, tx: 0.507812463708, c: -0.671875002503, d: -0.671875002503, ty: 1.3359374612 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.487500011921, g: 0.487500011921, b: 0.487500011921, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.34999999404, g: 0.34999999404, b: 0.33979165554, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:252;2859:3745"), x: 214.799987793, y: -87.5999984741, width: 9.60000038147, height: 9.60000038147, normalize_to_origin: false, text_outline: false, fill_paths: ["M9.6 4.8 C9.6 7.45097 7.45097 9.6 4.8 9.6 C2.14903 9.6 0 7.45097 0 4.8 C0 2.14903 2.14903 0 4.8 0 C7.45097 0 9.6 2.14903 9.6 4.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.440690100193, g: 0.440690100193, b: 0.426172584295, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.800000011921 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 1.20000004768 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:252;2859:3746"), x: 217.640182495, y: -84.7597961426, width: 9.60000038147, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L9.6 0 L9.6 1.6 L0 1.6 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.270588248968, g: 0.270588248968, b: 0.262745112181, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.10000000149 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.0, spread: 0.0 }], blend: Normal, rotation: 2.35619449019, authored: false } +Path { node_id: NodeId("I1:252;2859:3747"), x: 222.097061157, y: -79.9029464722, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.204166665673, g: 0.204166665673, b: 0.20246528089, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.800000011921 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.800000011921, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 2.35619449019, authored: false } +Rect { node_id: NodeId("I1:253;2847:3353"), x: 417.999969482, y: -165.600006104, width: 39.799987793, height: 53.5999984741, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:253;2847:3346"), x: 419.199981689, y: -164.400009155, width: 36.4000015259, height: 38.4000015259, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3339"), x: 445.599975586, y: -128.400009155, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3340"), x: 427.199981689, y: -128.400009155, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3341"), x: 453.199981689, y: -141.600006104, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3343"), x: 447.999969482, y: -158.800003052, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3342"), x: 419.199981689, y: -141.600006104, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3344"), x: 424.799987793, y: -158.800003052, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3345"), x: 436.399993896, y: -164.400009155, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:253;2847:3352"), x: 417.999969482, y: -165.600006104, width: 39.799987793, height: 53.5999984741, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3348"), x: 419.660217285, y: -127.418190002, width: 4.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M2.76705 2.18182 L2.76705 8 L1.88636 8 L1.88636 3.0625 L1.85227 3.0625 L0.460227 3.97159 L0.460227 3.13068 L1.91193 2.18182 L2.76705 2.18182 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3354"), x: 429.216094971, y: -119.897735596, width: 16.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3350"), x: 418.531219482, y: -163.49772644, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3351"), x: 453.373809814, y: -163.418182373, width: 5.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3349"), x: 451.157897949, y: -127.418190002, width: 5.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M0.755682 8 L3.29545 2.97727 L3.29545 2.93466 L0.357955 2.93466 L0.357955 2.18182 L4.20455 2.18182 L4.20455 2.96023 L1.6733 8 L0.755682 8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:253;2847:3358"), x: 415.999969482, y: -165.600006104, width: 45.7254676819, height: 43.2000007629, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:253;2847:3338"), x: 424.799987793, y: -159.200012207, width: 36.1254692078, height: 34.5162162781, blend: Normal, opacity: 0.560000002384 } +Path { node_id: NodeId("I1:253;2847:3338"), x: 424.799987793, y: -159.200012207, width: 36.1254692078, height: 34.5162162781, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0823529437184, g: 0.0823529437184, b: 0.0823529437184, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:253;2847:3338") } +Rect { node_id: NodeId("I1:253;2847:3357"), x: 415.999969482, y: -165.600006104, width: 43.2000007629, height: 43.2000007629, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3333"), x: 421.675811768, y: -159.949493408, width: 43.2000007629, height: 43.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 1.0 }), Normal), (Solid(Rgba { r: 0.0583333335817, g: 0.0583333335817, b: 0.0583333335817, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 1.0 }, offset_x: 0.0, offset_y: 1.60000002384, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:253;2847:3335"), x: 422.160522461, y: -159.463409424, width: 40.7999992371, height: 40.7999992371, blend: Normal, opacity: 0.699999988079 } +Path { node_id: NodeId("I1:253;2847:3335"), x: 422.160522461, y: -159.463409424, width: 40.7999992371, height: 40.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.274301992268, b: -0.819270221178, tx: 0.635333093905, c: 0.514026735962, d: 0.437190197409, ty: -0.232621821637 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 1.0 } }, GradientStop { offset: 0.933754086494, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.695833325386, g: 0.695833325386, b: 0.695833325386, a: 1.0 }, offset_x: 0.40000000596, offset_y: 0.40000000596, radius: 0.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:253;2847:3335") } +Path { node_id: NodeId("I1:253;2847:3337"), x: 423.199981689, y: -157.600006104, width: 27.2000007629, height: 27.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M27.2 13.6 C27.2 21.1111 21.1111 27.2 13.6 27.2 C6.08893 27.2 0 21.1111 0 13.6 C0 6.08893 6.08893 0 13.6 0 C21.1111 0 27.2 6.08893 27.2 13.6 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientAngular { transform: Some(AffineTransform2D { a: -0.707106793288, b: -0.707106793288, tx: 1.20710683544, c: 0.707106793288, d: -0.707106793288, ty: 0.500000885084 }), stops: [GradientStop { offset: 0.0139440111816, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }, GradientStop { offset: 0.168265238404, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.349274724722, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.492538094521, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.518536567688, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.656246423721, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.84331792593, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.982868492603, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }] }, Normal), (Image { image_hash: Some("6c705c704de71c825c5fd0b874c2925852350627"), data_base64: "", alpha: 0.300000011921, scale_mode: Fill, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Darken)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: 2.79999995232, offset_y: 4.80000019073, radius: 0.40000000596, spread: -4.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: -2.0, offset_y: -2.79999995232, radius: 0.40000000596, spread: -2.40000009537 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:253;2847:3347"), x: 435.599975586, y: -135.600006104, width: 4.0, height: 4.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M4 2 C4 3.10457 3.10457 4 2 4 C0.895431 4 0 3.10457 0 2 C0 0.895431 0.895431 0 2 0 C3.10457 0 4 0.895431 4 2 Z M0.8 2 C0.8 2.66274 1.33726 3.2 2 3.2 C2.66274 3.2 3.2 2.66274 3.2 2 C3.2 1.33726 2.66274 0.8 2 0.8 C1.33726 0.8 0.8 1.33726 0.8 2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.117647059262, g: 0.117647059262, b: 0.117647059262, a: 0.699999988079 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:254;2847:3386"), x: 417.999969482, y: -96.8000030518, width: 39.799987793, height: 59.5999984741, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:254;2847:3387"), x: 419.199981689, y: -95.6000061035, width: 36.4000015259, height: 38.4000015259, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3388"), x: 445.599975586, y: -59.6000099182, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3389"), x: 427.199981689, y: -59.6000099182, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3390"), x: 453.199981689, y: -72.8000030518, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3391"), x: 447.999969482, y: -90.0000076294, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3392"), x: 419.199981689, y: -72.8000030518, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3393"), x: 424.799987793, y: -90.0000076294, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3394"), x: 436.399993896, y: -95.6000061035, width: 2.40000009537, height: 2.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.4 1.2 C2.4 1.86274 1.86274 2.4 1.2 2.4 C0.537258 2.4 0 1.86274 0 1.2 C0 0.537258 0.537258 0 1.2 0 C1.86274 0 2.4 0.537258 2.4 1.2 Z"], stroke_paths: [], svg_base64: Some("PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIzIiB2aWV3Qm94PSIwIDAgMyAzIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSIxLjIiIGN5PSIxLjIiIHI9IjEuMiIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K"), fills: [(Solid(Rgba { r: 0.850980401039, g: 0.850980401039, b: 0.850980401039, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:254;2847:3395"), x: 417.999969482, y: -96.8000030518, width: 39.799987793, height: 59.5999984741, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3396"), x: 419.660217285, y: -58.6181869507, width: 4.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M2.76705 2.18182 L2.76705 8 L1.88636 8 L1.88636 3.0625 L1.85227 3.0625 L0.460227 3.97159 L0.460227 3.13068 L1.91193 2.18182 L2.76705 2.18182 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3397"), x: 420.32119751, y: -45.0977325439, width: 35.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3408"), x: 431.261688232, y: -53.3753395081, width: 11.4000005722, height: 4.2000002861, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: ["M0 4.2 L0.138282 4.57534 L11.5383 0.375337 L11.4 0 L11.2617 -0.375337 L-0.138282 3.82466 L0 4.2 Z"], svg_base64: Some("PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iNSIgdmlld0JveD0iMCAwIDEyIDUiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0wLjEzODI3NSA0LjU3NTM0TDExLjUzODMgMC4zNzUzMzYiIHN0cm9rZT0iI0RFREVERSIgc3Ryb2tlLXdpZHRoPSIwLjgiLz4KPC9zdmc+Cg=="), fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }, gradient: None, width: 0.800000011921, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3398"), x: 418.531219482, y: -94.6977310181, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3399"), x: 453.373809814, y: -94.6181869507, width: 5.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3400"), x: 451.157897949, y: -58.6181869507, width: 5.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: ["M0.755682 8 L3.29545 2.97727 L3.29545 2.93466 L0.357955 2.93466 L0.357955 2.18182 L4.20455 2.18182 L4.20455 2.96023 L1.6733 8 L0.755682 8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:254;2847:3401"), x: 415.999969482, y: -96.8000030518, width: 45.7254676819, height: 43.2000007629, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:254;2847:3402"), x: 424.799987793, y: -90.4000091553, width: 36.1254692078, height: 34.5162162781, blend: Normal, opacity: 0.560000002384 } +Path { node_id: NodeId("I1:254;2847:3402"), x: 424.799987793, y: -90.4000091553, width: 36.1254692078, height: 34.5162162781, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0823529437184, g: 0.0823529437184, b: 0.0823529437184, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:254;2847:3402") } +Rect { node_id: NodeId("I1:254;2847:3403"), x: 415.999969482, y: -96.8000030518, width: 43.2000007629, height: 43.2000007629, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3404"), x: 421.675811768, y: -91.1494827271, width: 43.2000007629, height: 43.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 1.0 }), Normal), (Solid(Rgba { r: 0.0583333335817, g: 0.0583333335817, b: 0.0583333335817, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0784313753247, g: 0.0784313753247, b: 0.0784313753247, a: 1.0 }, offset_x: 0.0, offset_y: 1.60000002384, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:254;2847:3405"), x: 422.160522461, y: -90.6634063721, width: 40.7999992371, height: 40.7999992371, blend: Normal, opacity: 0.699999988079 } +Path { node_id: NodeId("I1:254;2847:3405"), x: 422.160522461, y: -90.6634063721, width: 40.7999992371, height: 40.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.274301992268, b: -0.819270221178, tx: 0.635333093905, c: 0.514026735962, d: 0.437190197409, ty: -0.232621821637 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 1.0 } }, GradientStop { offset: 0.933754086494, color: Rgba { r: 0.498039215803, g: 0.498039215803, b: 0.498039215803, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.695833325386, g: 0.695833325386, b: 0.695833325386, a: 1.0 }, offset_x: 0.40000000596, offset_y: 0.40000000596, radius: 0.0, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:254;2847:3405") } +Path { node_id: NodeId("I1:254;2847:3406"), x: 423.199981689, y: -88.8000030518, width: 27.2000007629, height: 27.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M27.2 13.6 C27.2 21.1111 21.1111 27.2 13.6 27.2 C6.08893 27.2 0 21.1111 0 13.6 C0 6.08893 6.08893 0 13.6 0 C21.1111 0 27.2 6.08893 27.2 13.6 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(GradientAngular { transform: Some(AffineTransform2D { a: -0.707106793288, b: -0.707106793288, tx: 1.20710683544, c: 0.707106793288, d: -0.707106793288, ty: 0.500000885084 }), stops: [GradientStop { offset: 0.0139440111816, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }, GradientStop { offset: 0.168265238404, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.349274724722, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.492538094521, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.518536567688, color: Rgba { r: 0.360784322023, g: 0.360784322023, b: 0.352941185236, a: 1.0 } }, GradientStop { offset: 0.656246423721, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.84331792593, color: Rgba { r: 0.952941179276, g: 0.952941179276, b: 0.952941179276, a: 1.0 } }, GradientStop { offset: 0.982868492603, color: Rgba { r: 0.376470595598, g: 0.376470595598, b: 0.368627458811, a: 1.0 } }] }, Normal), (Image { image_hash: Some("6c705c704de71c825c5fd0b874c2925852350627"), data_base64: "", alpha: 0.300000011921, scale_mode: Fill, image_transform: Some(AffineTransform2D { a: 1.0, b: 0.0, tx: 0.0, c: 0.0, d: 1.0, ty: 0.0 }) }, Darken)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: 2.79999995232, offset_y: 4.80000019073, radius: 0.40000000596, spread: -4.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, offset_x: -2.0, offset_y: -2.79999995232, radius: 0.40000000596, spread: -2.40000009537 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.40000000596 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:254;2847:3407"), x: 441.599975586, y: -83.6000061035, width: 4.0, height: 4.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M4 2 C4 3.10457 3.10457 4 2 4 C0.895431 4 0 3.10457 0 2 C0 0.895431 0.895431 0 2 0 C3.10457 0 4 0.895431 4 2 Z M0.8 2 C0.8 2.66274 1.33726 3.2 2 3.2 C2.66274 3.2 3.2 2.66274 3.2 2 C3.2 1.33726 2.66274 0.8 2 0.8 C1.33726 0.8 0.8 1.33726 0.8 2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.117647059262, g: 0.117647059262, b: 0.117647059262, a: 0.699999988079 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.5 }, offset_x: 0.40000000596, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("1:255"), x: 519.199951172, y: -177.600006104, width: 36.7999992371, height: 120.200004578, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:256"), x: 532.639160156, y: -175.49772644, width: 24.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:257"), x: 520.68359375, y: -155.897720337, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:258"), x: 525.79699707, y: -128.697723389, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:259"), x: 525.203063965, y: -103.018180847, width: 6.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:260"), x: 525.047668457, y: -75.0977249146, width: 5.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("1:261"), x: 535.999938965, y: -165.599990845, width: 17.7999973297, height: 107.800003052, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("1:262"), x: 535.999938965, y: -165.599990845, width: 15.1999998093, height: 105.199996948, blend: Normal, opacity: 0.800000011921 } +Path { node_id: NodeId("1:262"), x: 535.999938965, y: -165.599990845, width: 15.1999998093, height: 105.199996948, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.800004 C0 0.358176 0.358172 0 0.8 0 L14.4 0 C14.8418 0 15.2 0.358172 15.2 0.8 L15.2 104.4 C15.2 104.842 14.8418 105.2 14.4 105.2 L0.8 105.2 C0.358172 105.2 0 104.842 0 104.4 L0 0.800004 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.00784313771874, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("1:262") } +LayerStart { node_id: NodeId("I1:263;2847:3461"), x: 539.799926758, y: -163.199996948, width: 15.8000001907, height: 28.2000007629, blend: Normal, opacity: 0.40000000596 } +Path { node_id: NodeId("I1:263;2847:3461"), x: 539.799926758, y: -163.199996948, width: 15.8000001907, height: 28.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M15.8 5.8 L9.8 0 L0 0 L0 26.4 L2.6 28.2 L11.6 28.2 L14.2 28.2 C15.0837 28.2 15.8 27.4837 15.8 26.6 L15.8 5.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:263;2847:3461") } +Path { node_id: NodeId("I1:263;2847:3459"), x: 536.799926758, y: -164.800003052, width: 12.0, height: 25.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799998 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.4 C12 24.8418 11.6418 25.2 11.2 25.2 L0.8 25.2 C0.358172 25.2 0 24.8418 0 24.4 L0 0.799998 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:263;2847:3484"), x: 537.599914551, y: -164.0, width: 12.0, height: 23.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799999 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 22.4 C12 22.8418 11.6418 23.2 11.2 23.2 L0.8 23.2 C0.358172 23.2 0 22.8418 0 22.4 L0 0.799999 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 }, gradient: Some(GradientRadial { transform: Some(AffineTransform2D { a: 1.86666660395, b: -2.24476368891, tx: 0.255715127422, c: 2.26785717858, d: 0.314840664264, ty: -1.26456321183 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.20000000298, g: 0.192499995232, b: 0.192499995232, a: 0.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:263;2847:3483"), x: 537.599914551, y: -140.0, width: 12.0, height: 1.60000002384, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:263;2847:3483"), x: 537.599914551, y: -140.0, width: 12.0, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L0 1.6 L12 1.6 L10 0 L0 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:263;2847:3483") } +LayerStart { node_id: NodeId("I1:264;2847:3461"), x: 539.799926758, y: -137.799987793, width: 15.8000001907, height: 28.2000007629, blend: Normal, opacity: 0.40000000596 } +Path { node_id: NodeId("I1:264;2847:3461"), x: 539.799926758, y: -137.799987793, width: 15.8000001907, height: 28.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M15.8 5.8 L9.8 0 L0 0 L0 26.4 L2.6 28.2 L11.6 28.2 L14.2 28.2 C15.0837 28.2 15.8 27.4837 15.8 26.6 L15.8 5.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:264;2847:3461") } +Path { node_id: NodeId("I1:264;2847:3459"), x: 536.799926758, y: -139.399993896, width: 12.0, height: 25.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799998 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.4 C12 24.8418 11.6418 25.2 11.2 25.2 L0.8 25.2 C0.358172 25.2 0 24.8418 0 24.4 L0 0.799998 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:264;2847:3484"), x: 537.599914551, y: -138.599990845, width: 12.0, height: 23.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799999 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 22.4 C12 22.8418 11.6418 23.2 11.2 23.2 L0.8 23.2 C0.358172 23.2 0 22.8418 0 22.4 L0 0.799999 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 }, gradient: Some(GradientRadial { transform: Some(AffineTransform2D { a: 1.86666660395, b: -2.24476368891, tx: 0.255715127422, c: 2.26785717858, d: 0.314840664264, ty: -1.26456321183 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.20000000298, g: 0.192499995232, b: 0.192499995232, a: 0.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:264;2847:3483"), x: 537.599914551, y: -114.599998474, width: 12.0, height: 1.60000002384, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:264;2847:3483"), x: 537.599914551, y: -114.599998474, width: 12.0, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L0 1.6 L12 1.6 L10 0 L0 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:264;2847:3483") } +LayerStart { node_id: NodeId("I1:265;2847:3461"), x: 539.799926758, y: -112.400001526, width: 15.8000001907, height: 28.2000007629, blend: Normal, opacity: 0.40000000596 } +Path { node_id: NodeId("I1:265;2847:3461"), x: 539.799926758, y: -112.400001526, width: 15.8000001907, height: 28.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M15.8 5.8 L9.8 0 L0 0 L0 26.4 L2.6 28.2 L11.6 28.2 L14.2 28.2 C15.0837 28.2 15.8 27.4837 15.8 26.6 L15.8 5.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:265;2847:3461") } +Path { node_id: NodeId("I1:265;2847:3459"), x: 536.799926758, y: -114.0, width: 12.0, height: 25.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799998 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.4 C12 24.8418 11.6418 25.2 11.2 25.2 L0.8 25.2 C0.358172 25.2 0 24.8418 0 24.4 L0 0.799998 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:265;2847:3484"), x: 537.599914551, y: -113.200004578, width: 12.0, height: 23.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799999 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 22.4 C12 22.8418 11.6418 23.2 11.2 23.2 L0.8 23.2 C0.358172 23.2 0 22.8418 0 22.4 L0 0.799999 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 }, gradient: Some(GradientRadial { transform: Some(AffineTransform2D { a: 1.86666660395, b: -2.24476368891, tx: 0.255715127422, c: 2.26785717858, d: 0.314840664264, ty: -1.26456321183 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.20000000298, g: 0.192499995232, b: 0.192499995232, a: 0.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:265;2847:3483"), x: 537.599914551, y: -89.2000045776, width: 12.0, height: 1.60000002384, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:265;2847:3483"), x: 537.599914551, y: -89.2000045776, width: 12.0, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L0 1.6 L12 1.6 L10 0 L0 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:265;2847:3483") } +LayerStart { node_id: NodeId("I1:266;2847:3488"), x: 537.999938965, y: -87.4000091553, width: 13.1999998093, height: 27.2000007629, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:266;2847:3488"), x: 537.999938965, y: -87.4000091553, width: 13.1999998093, height: 27.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M13.2 4.3337 L9.84 6.33417e-06 L0 0 L0 24.8716 L2 27.2 L11.28 27.2 L11.6 27.2 C12.4837 27.2 13.2 26.4837 13.2 25.6 L13.2 4.3337 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:266;2847:3488") } +Path { node_id: NodeId("I1:266;2847:3489"), x: 536.799926758, y: -88.5999984741, width: 12.0, height: 25.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.8 C0 0.358172 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.8 C12 25.2418 11.6418 25.6 11.2 25.6 L0.8 25.6 C0.358172 25.6 0 25.2418 0 24.8 L0 0.8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:266;2847:3490"), x: 537.599914551, y: -88.1999969482, width: 12.0, height: 25.6000003815, blend: Normal, opacity: 0.20000000298 } +Path { node_id: NodeId("I1:266;2847:3490"), x: 537.599914551, y: -88.1999969482, width: 12.0, height: 25.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.8 C0 0.358172 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.8 C12 25.2418 11.6418 25.6 11.2 25.6 L0.8 25.6 C0.358172 25.6 0 25.2418 0 24.8 L0 0.8 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.545833349228, g: 0.545833349228, b: 0.545833349228, a: 1.0 }, gradient: Some(GradientLinear { transform: Some(AffineTransform2D { a: 1.46666658488, b: -1.3641465082, tx: 0.315406664458, c: 1.3606556978, d: 0.268449082876, ty: -0.42110977359 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.545833349228, g: 0.545833349228, b: 0.545833349228, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:266;2847:3490") } +Rect { node_id: NodeId("1:267"), x: 744.800003052, y: -177.599990845, width: 37.1999969482, height: 120.200004578, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 3.14159265359, authored: false } +Path { node_id: NodeId("1:268"), x: 745.439208984, y: -175.497711182, width: 24.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:269"), x: 771.303283691, y: -155.897705078, width: 12.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:270"), x: 771.761535645, y: -128.69770813, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:271"), x: 771.581848145, y: -103.018173218, width: 11.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:272"), x: 771.447692871, y: -75.0977172852, width: 5.0, height: 10.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("1:273"), x: 745.799987793, y: -165.599990845, width: 17.7999954224, height: 107.800003052, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("1:274"), x: 745.799987793, y: -165.599990845, width: 15.1999998093, height: 105.199996948, blend: Normal, opacity: 0.800000011921 } +Path { node_id: NodeId("1:274"), x: 745.799987793, y: -165.599990845, width: 15.1999998093, height: 105.199996948, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.800004 C0 0.358176 0.358172 0 0.8 0 L14.4 0 C14.8418 0 15.2 0.358172 15.2 0.8 L15.2 104.4 C15.2 104.842 14.8418 105.2 14.4 105.2 L0.8 105.2 C0.358172 105.2 0 104.842 0 104.4 L0 0.800004 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.00784313771874, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("1:274") } +LayerStart { node_id: NodeId("I1:275;2847:3461"), x: 749.599975586, y: -163.199996948, width: 15.8000001907, height: 28.2000007629, blend: Normal, opacity: 0.40000000596 } +Path { node_id: NodeId("I1:275;2847:3461"), x: 749.599975586, y: -163.199996948, width: 15.8000001907, height: 28.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M15.8 5.8 L9.8 0 L0 0 L0 26.4 L2.6 28.2 L11.6 28.2 L14.2 28.2 C15.0837 28.2 15.8 27.4837 15.8 26.6 L15.8 5.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:275;2847:3461") } +Path { node_id: NodeId("I1:275;2847:3459"), x: 746.599975586, y: -164.800003052, width: 12.0, height: 25.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799998 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.4 C12 24.8418 11.6418 25.2 11.2 25.2 L0.8 25.2 C0.358172 25.2 0 24.8418 0 24.4 L0 0.799998 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:275;2847:3484"), x: 747.399963379, y: -164.0, width: 12.0, height: 23.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799999 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 22.4 C12 22.8418 11.6418 23.2 11.2 23.2 L0.8 23.2 C0.358172 23.2 0 22.8418 0 22.4 L0 0.799999 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 }, gradient: Some(GradientRadial { transform: Some(AffineTransform2D { a: 1.86666660395, b: -2.24476368891, tx: 0.255715127422, c: 2.26785717858, d: 0.314840664264, ty: -1.26456321183 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.20000000298, g: 0.192499995232, b: 0.192499995232, a: 0.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:275;2847:3483"), x: 747.399963379, y: -140.0, width: 12.0, height: 1.60000002384, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:275;2847:3483"), x: 747.399963379, y: -140.0, width: 12.0, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L0 1.6 L12 1.6 L10 0 L0 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:275;2847:3483") } +LayerStart { node_id: NodeId("I1:276;2847:3461"), x: 749.599975586, y: -137.799987793, width: 15.8000001907, height: 28.2000007629, blend: Normal, opacity: 0.40000000596 } +Path { node_id: NodeId("I1:276;2847:3461"), x: 749.599975586, y: -137.799987793, width: 15.8000001907, height: 28.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M15.8 5.8 L9.8 0 L0 0 L0 26.4 L2.6 28.2 L11.6 28.2 L14.2 28.2 C15.0837 28.2 15.8 27.4837 15.8 26.6 L15.8 5.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:276;2847:3461") } +Path { node_id: NodeId("I1:276;2847:3459"), x: 746.599975586, y: -139.399993896, width: 12.0, height: 25.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799998 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.4 C12 24.8418 11.6418 25.2 11.2 25.2 L0.8 25.2 C0.358172 25.2 0 24.8418 0 24.4 L0 0.799998 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:276;2847:3484"), x: 747.399963379, y: -138.599990845, width: 12.0, height: 23.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799999 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 22.4 C12 22.8418 11.6418 23.2 11.2 23.2 L0.8 23.2 C0.358172 23.2 0 22.8418 0 22.4 L0 0.799999 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 }, gradient: Some(GradientRadial { transform: Some(AffineTransform2D { a: 1.86666660395, b: -2.24476368891, tx: 0.255715127422, c: 2.26785717858, d: 0.314840664264, ty: -1.26456321183 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.20000000298, g: 0.192499995232, b: 0.192499995232, a: 0.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:276;2847:3483"), x: 747.399963379, y: -114.599998474, width: 12.0, height: 1.60000002384, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:276;2847:3483"), x: 747.399963379, y: -114.599998474, width: 12.0, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L0 1.6 L12 1.6 L10 0 L0 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:276;2847:3483") } +LayerStart { node_id: NodeId("I1:277;2847:3461"), x: 749.599975586, y: -112.400001526, width: 15.8000001907, height: 28.2000007629, blend: Normal, opacity: 0.40000000596 } +Path { node_id: NodeId("I1:277;2847:3461"), x: 749.599975586, y: -112.400001526, width: 15.8000001907, height: 28.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M15.8 5.8 L9.8 0 L0 0 L0 26.4 L2.6 28.2 L11.6 28.2 L14.2 28.2 C15.0837 28.2 15.8 27.4837 15.8 26.6 L15.8 5.8 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:277;2847:3461") } +Path { node_id: NodeId("I1:277;2847:3459"), x: 746.599975586, y: -114.0, width: 12.0, height: 25.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799998 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.4 C12 24.8418 11.6418 25.2 11.2 25.2 L0.8 25.2 C0.358172 25.2 0 24.8418 0 24.4 L0 0.799998 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:277;2847:3484"), x: 747.399963379, y: -113.200004578, width: 12.0, height: 23.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.799999 C0 0.358171 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 22.4 C12 22.8418 11.6418 23.2 11.2 23.2 L0.8 23.2 C0.358172 23.2 0 22.8418 0 22.4 L0 0.799999 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 }, gradient: Some(GradientRadial { transform: Some(AffineTransform2D { a: 1.86666660395, b: -2.24476368891, tx: 0.255715127422, c: 2.26785717858, d: 0.314840664264, ty: -1.26456321183 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.387499988079, g: 0.372968763113, b: 0.372968763113, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.20000000298, g: 0.192499995232, b: 0.192499995232, a: 0.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:277;2847:3483"), x: 747.399963379, y: -89.2000045776, width: 12.0, height: 1.60000002384, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:277;2847:3483"), x: 747.399963379, y: -89.2000045776, width: 12.0, height: 1.60000002384, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L0 1.6 L12 1.6 L10 0 L0 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:277;2847:3483") } +LayerStart { node_id: NodeId("I1:278;2847:3488"), x: 747.799987793, y: -87.4000091553, width: 13.1999998093, height: 27.2000007629, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:278;2847:3488"), x: 747.799987793, y: -87.4000091553, width: 13.1999998093, height: 27.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M13.2 4.3337 L9.84 6.33417e-06 L0 0 L0 24.8716 L2 27.2 L11.28 27.2 L11.6 27.2 C12.4837 27.2 13.2 26.4837 13.2 25.6 L13.2 4.3337 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:278;2847:3488") } +Path { node_id: NodeId("I1:278;2847:3489"), x: 746.599975586, y: -88.5999984741, width: 12.0, height: 25.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.8 C0 0.358172 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.8 C12 25.2418 11.6418 25.6 11.2 25.6 L0.8 25.6 C0.358172 25.6 0 25.2418 0 24.8 L0 0.8 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:278;2847:3490"), x: 747.399963379, y: -88.1999969482, width: 12.0, height: 25.6000003815, blend: Normal, opacity: 0.20000000298 } +Path { node_id: NodeId("I1:278;2847:3490"), x: 747.399963379, y: -88.1999969482, width: 12.0, height: 25.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0.8 C0 0.358172 0.358172 0 0.8 0 L11.2 0 C11.6418 0 12 0.358172 12 0.8 L12 24.8 C12 25.2418 11.6418 25.6 11.2 25.6 L0.8 25.6 C0.358172 25.6 0 25.2418 0 24.8 L0 0.8 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 0.149019613862, g: 0.149019613862, b: 0.149019613862, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.545833349228, g: 0.545833349228, b: 0.545833349228, a: 1.0 }, gradient: Some(GradientLinear { transform: Some(AffineTransform2D { a: 1.46666658488, b: -1.3641465082, tx: 0.315406664458, c: 1.3606556978, d: 0.268449082876, ty: -0.42110977359 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.545833349228, g: 0.545833349228, b: 0.545833349228, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:278;2847:3490") } +Rect { node_id: NodeId("1:279"), x: 572.799987793, y: -186.0, width: 154.800003052, height: 156.0, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -1.52450105833e-16, b: -0.832051292224, tx: 0.916025646112, c: 0.832051292224, d: -9.44366049896e-16, ty: 0.167948720174 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.0352941192687, g: 0.364705890417, b: 0.505882382393, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.0143750002608, g: 0.272645801306, b: 0.383333325386, a: 1.0 } }] }, Normal), (Image { image_hash: Some("2100cf81935675556d2ec435206bf323403a7aa9"), data_base64: "", alpha: 0.25, scale_mode: Crop, image_transform: Some(AffineTransform2D { a: 0.936748325825, b: 0.0, tx: 0.0316258333623, c: 0.0, d: 0.795918345451, ty: 0.102040819824 }) }, Multiply)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:280;2853:3628"), x: 581.599975586, y: -158.399993896, width: 138.800003052, height: 82.0, blend: Normal, opacity: 0.40000000596 } +Path { node_id: NodeId("I1:280;2853:3628"), x: 581.599975586, y: -158.399993896, width: 138.800003052, height: 82.0, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:280;2853:3628") } +Rect { node_id: NodeId("I1:280;2852:3600"), x: 579.599975586, y: -158.399993896, width: 138.400009155, height: 79.2000045776, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.945086755673, b: 1.06607137086e-8, tx: 0.00722542790484, c: -3.02813594995e-16, d: 2.88598096502, ty: -1.3041016199 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.117647059262, g: 0.117647059262, b: 0.1254902035, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.108333334327, g: 0.10427083075, b: 0.10427083075, a: 1.0 } }] }, Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.304166674614, g: 0.292760401964, b: 0.292760401964, a: 1.0 }, gradient: Some(GradientLinear { transform: Some(AffineTransform2D { a: 0.359826643034, b: -0.628787938369, tx: 0.531156926694, c: 0.628787938369, d: 1.09879106429, ty: -0.665557065142 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.304166674614, g: 0.292760401964, b: 0.292760401964, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } }] }), width: 0.800000011921, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:280;2852:3602"), x: 583.199951172, y: -154.0, width: 131.199996948, height: 72.0, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.980882365322, b: 1.13811530259e-8, tx: 0.0191176577019, c: -1.72841615637e-16, d: 3.17432329623, ty: -1.22208227403 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.156862750649, g: 0.156862750649, b: 0.156862750649, a: 1.0 } }, GradientStop { offset: 0.229166671634, color: Rgba { r: 0.239215686917, g: 0.215686276555, b: 0.168627455831, a: 1.0 } }, GradientStop { offset: 0.463541656733, color: Rgba { r: 0.156862750649, g: 0.156862750649, b: 0.156862750649, a: 1.0 } }, GradientStop { offset: 0.744791686535, color: Rgba { r: 0.243137255311, g: 0.219607844949, b: 0.168627455831, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.156862750649, g: 0.156862750649, b: 0.156862750649, a: 0.920000016689 } }] }, Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.25, g: 0.223958328366, b: 0.223958328366, a: 1.0 }, gradient: None, width: 0.40000000596, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), corner_radii: CornerRadii { top_left: 0.800000011921, top_right: 0.800000011921, bottom_right: 0.800000011921, bottom_left: 0.800000011921 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:280;2852:3624"), x: 585.199951172, y: -97.2000045776, width: 126.800003052, height: 11.6000003815, fills: [(Solid(Rgba { r: 0.152941182256, g: 0.152941182256, b: 0.152941182256, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.600000023842 }, offset_x: 0.0, offset_y: 0.800000011921, radius: 0.40000000596, spread: -0.40000000596 }], blend: Normal, rotation: 0.0, authored: false } +ClipStart { node_id: NodeId("I1:280;2847:3543"), x: 584.799926758, y: -152.799987793, width: 126.800003052, height: 55.2000007629, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 } } +Rect { node_id: NodeId("I1:280;2847:3543"), x: 584.799926758, y: -152.799987793, width: 126.800003052, height: 55.2000007629, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: 0.00158724194865, b: -0.652173929118, tx: 0.826087022135, c: 0.652173929118, d: 0.00827001083633, ty: 0.318328735048 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.8125, g: 0.474136322737, b: 0.0981770828366, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.6875, g: 0.374054074287, b: 0.0257812496275, a: 1.0 } }] }, Normal), (Solid(Rgba { r: 0.637499988079, g: 0.164276108146, b: 0.0, a: 0.40000000596 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.125, g: 0.119270831347, b: 0.119270831347, a: 1.0 }, gradient: None, width: 0.40000000596, cap: None, join: Miter, align: Outside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3545"), x: 636.846923828, y: -152.399993896, width: 50.3174591064, height: 50.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M50.3175 25 C50.3175 38.8071 39.0535 50 25.1587 50 C11.2639 50 0 38.8071 0 25 C0 11.1929 11.2639 0 25.1587 0 C39.0535 0 50.3175 11.1929 50.3175 25 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.895833313465, g: 0.765519797802, b: 0.30607637763, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 19.2000007629, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3546"), x: 585.199951172, y: -152.399993896, width: 61.1860313416, height: 60.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: ["M61.186 30.4 C61.186 47.1895 47.4891 60.8 30.593 60.8 C13.697 60.8 0 47.1895 0 30.4 C0 13.6105 13.697 0 30.593 0 C47.4891 0 61.186 13.6105 61.186 30.4 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.895833313465, g: 0.765519797802, b: 0.30607637763, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 21.6000003815, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3557"), x: 590.514221191, y: -152.399993896, width: 30.995557785, height: 10.4000005722, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 1.0, g: 0.949362933636, b: 0.770833313465, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 12.8000001907, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3558"), x: 652.907897949, y: -152.399993896, width: 30.995557785, height: 10.4000005722, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 1.0, g: 0.949362933636, b: 0.770833313465, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 12.8000001907, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("I1:280;2852:3621"), x: 594.246459961, y: -146.200119019, width: 110.20879364, height: 48.0812492371, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3598"), x: 638.350280762, y: -114.63306427, width: 18.7180957794, height: 11.4000005722, normalize_to_origin: false, text_outline: false, fill_paths: ["M9.25841 0 L0 5.6 L9.66095 11.4 L18.7181 5.6 L9.25841 0 Z"], stroke_paths: [""], svg_base64: Some(""), fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, gradient: None, width: 0.40000000596, cap: Round, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3599"), x: 645.179626465, y: -112.399993896, width: 7.24571466446, height: 6.80000019073, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.21397 1.10698 L2.21397 4 L4.42794 4 L4.42794 1.10698 C4.42794 0.495614 3.93232 0 3.32095 0 C2.70958 0 2.21397 0.495613 2.21397 1.10698 Z"], stroke_paths: [""], svg_base64: Some(""), fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, gradient: None, width: 0.40000000596, cap: Round, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3597"), x: 650.616394043, y: -146.200119019, width: 10.4660320282, height: 47.7999992371, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: [""], svg_base64: Some(""), fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, gradient: None, width: 1.20000004768, cap: Round, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3609"), x: 594.568359375, y: -137.481246948, width: 10.5281248093, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 2.36875 L1.49688 2.36875 L1.49688 2.98438 L0 2.98438 L0 2.36875 Z", "", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3604"), x: 607.914672852, y: -121.081237793, width: 3.52187514305, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3603"), x: 613.085205078, y: -121.881240845, width: 8.00312519073, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3605"), x: 623.166809082, y: -123.881240845, width: 7.8562502861, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 2.9375 L2.20938 0 L2.9625 0 L2.9625 2.9375 L3.56562 2.9375 L3.56562 3.55313 L2.9625 3.55313 L2.9625 4.48125 L2.32188 4.48125 L2.32188 3.55313 L0 3.55313 L0 2.9375 Z M2.32188 2.9375 L2.32188 0.775 L0.696875 2.9375 L2.32188 2.9375 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3606"), x: 635.689941406, y: -124.681243896, width: 8.00312519073, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3607"), x: 647.637939453, y: -124.681243896, width: 8.0562505722, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3608"), x: 659.457885742, y: -124.681243896, width: 10.8249998093, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M1.40938 0 L1.40938 4.48125 L0.76875 4.48125 L0.76875 0.640625 L0 0.640625 L0 0 L1.40938 0 Z", "", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3610"), x: 609.462341309, y: -140.681243896, width: 8.3531255722, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 2.36875 L1.49688 2.36875 L1.49688 2.98438 L0 2.98438 L0 2.36875 Z", "M3.6125 0 L3.6125 4.48125 L2.97188 4.48125 L2.97188 0.640625 L2.20313 0.640625 L2.20313 0 L3.6125 0 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3611"), x: 620.733398438, y: -142.681243896, width: 5.72499990463, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 2.36875 L1.49688 2.36875 L1.49688 2.98438 L0 2.98438 L0 2.36875 Z", "M2.7625 4.48125 L4.77187 0.640625 L2.26875 0.640625 L2.26875 0 L5.725 0 L5.725 0.19375 L3.50312 4.48125 L2.7625 4.48125 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3612"), x: 631.199462891, y: -143.481246948, width: 5.71250009537, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 2.36875 L1.49688 2.36875 L1.49688 2.98438 L0 2.98438 L0 2.36875 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3613"), x: 642.068054199, y: -143.881240845, width: 5.8406252861, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 2.36875 L1.49688 2.36875 L1.49688 2.98438 L0 2.98438 L0 2.36875 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3614"), x: 658.572143555, y: -143.081237793, width: 3.61249995232, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 2.36875 L1.49688 2.36875 L1.49688 2.98438 L0 2.98438 L0 2.36875 Z", "M3.6125 0 L3.6125 4.48125 L2.97187 4.48125 L2.97187 0.640625 L2.20312 0.640625 L2.20312 0 L3.6125 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3615"), x: 667.893066406, y: -143.081237793, width: 3.52187514305, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3616"), x: 675.076293945, y: -141.481246948, width: 5.6062502861, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.05313 0.928125 L2.05313 2.36875 L3.49375 2.36875 L3.49375 2.98438 L2.05313 2.98438 L2.05313 4.425 L1.44063 4.425 L1.44063 2.98438 L0 2.98438 L0 2.36875 L1.44063 2.36875 L1.44063 0.928125 L2.05313 0.928125 Z", "M5.60625 0 L5.60625 4.48125 L4.96563 4.48125 L4.96563 0.640625 L4.19688 0.640625 L4.19688 0 L5.60625 0 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.992156863213, g: 0.235294118524, b: 0.113725490868, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3617"), x: 683.932128906, y: -139.881240845, width: 7.90937519073, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.05313 0.928125 L2.05313 2.36875 L3.49375 2.36875 L3.49375 2.98438 L2.05313 2.98438 L2.05313 4.425 L1.44063 4.425 L1.44063 2.98438 L0 2.98438 L0 2.36875 L1.44063 2.36875 L1.44063 0.928125 L2.05313 0.928125 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.992156863213, g: 0.235294118524, b: 0.113725490868, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3619"), x: 695.686523438, y: -145.481246948, width: 8.76875019073, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M1.72812 4.48125 L0 0 L0.6875 0 L2.08125 3.6875 L3.47813 0 L4.1625 0 L2.43438 4.48125 L1.72812 4.48125 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.992156863213, g: 0.235294118524, b: 0.113725490868, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3618"), x: 693.995666504, y: -136.681243896, width: 7.83437490463, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M2.05313 0.928125 L2.05313 2.36875 L3.49375 2.36875 L3.49375 2.98438 L2.05313 2.98438 L2.05313 4.425 L1.44063 4.425 L1.44063 2.98438 L0 2.98438 L0 2.36875 L1.44063 2.36875 L1.44063 0.928125 L2.05313 0.928125 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.992156863213, g: 0.235294118524, b: 0.113725490868, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2852:3620"), x: 594.246459961, y: -145.881240845, width: 8.76875019073, height: 4.4812502861, normalize_to_origin: false, text_outline: false, fill_paths: ["M1.72812 4.48125 L0 0 L0.6875 0 L2.08125 3.6875 L3.47813 0 L4.1625 0 L2.43438 4.48125 L1.72812 4.48125 Z", ""], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0470588244498, g: 0.0549019612372, b: 0.0588235296309, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3563"), x: 600.818359375, y: -137.616073608, width: 62.9974632263, height: 14.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: [""], svg_base64: Some(""), fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, gradient: None, width: 0.800000011921, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3559"), x: 607.672180176, y: -128.13835144, width: 80.7092056274, height: 6.80000019073, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: [""], svg_base64: Some(""), fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, gradient: None, width: 0.800000011921, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3564"), x: 665.528198242, y: -133.399993896, width: 29.1625862122, height: 10.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M23.146 10 C20.0867 9.2 6.44063 7 -6.98852e-06 6 C0.804344 4.00181 0.693696 2.27462 1.38735 0 C5.73478 0.48 21.1118 3.4 29.1626 5 C28.8942 5.2 27.8155 5.52 23.146 10 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.992156863213, g: 0.235294118524, b: 0.113725490868, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3566"), x: 665.132324219, y: -136.941223145, width: 32.0019035339, height: 13.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: [""], svg_base64: Some(""), fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.992156863213, g: 0.235294118524, b: 0.113725490868, a: 1.0 }, gradient: None, width: 0.800000011921, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:280;2847:3547"), x: 589.632995605, y: -148.800003052, width: 117.944129944, height: 53.2000007629, blend: SoftLight, opacity: 0.5 } +Path { node_id: NodeId("I1:280;2847:3547"), x: 589.632995605, y: -148.800003052, width: 117.944129944, height: 53.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L117.944 0 L117.944 53.2 L0 53.2 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 0.960784316063, b: 0.427450984716, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:280;2847:3547") } +LayerStart { node_id: NodeId("I1:280;2847:3565"), x: 589.632995605, y: -148.800003052, width: 117.944129944, height: 53.2000007629, blend: Normal, opacity: 0.5 } +Path { node_id: NodeId("I1:280;2847:3565"), x: 589.632995605, y: -148.800003052, width: 117.944129944, height: 53.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L117.944 0 L117.944 53.2 L0 53.2 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -3.24703814773e-16, b: -1.14661651335, tx: 1.0733082225, c: 1.14661651335, d: 5.30710154527e-9, ty: 0.135338341989 }), stops: [GradientStop { offset: 0.22853513062, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 0.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:280;2847:3565") } +Path { node_id: NodeId("I1:280;2847:3567"), x: 592.445678711, y: -98.0, width: 55.1479377747, height: 0.800000011921, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L55.1479 0 L55.1479 0.8 L0 0.8 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientRadial { transform: Some(AffineTransform2D { a: 1.0, b: 3.16007994407e-15, tx: -1.05234734998e-15, c: -1.11230901642e-14, d: 4692.24982433, ty: -2345.62498437 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.976470589638, g: 0.92549020052, b: 0.564705908298, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.976470589638, g: 0.929411768913, b: 0.572549045086, a: 0.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2847:3568"), x: 651.216430664, y: -98.0, width: 55.1479377747, height: 0.800000011921, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L55.1479 0 L55.1479 0.8 L0 0.8 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(GradientRadial { transform: Some(AffineTransform2D { a: 1.0, b: 3.16007994407e-15, tx: -1.05234734998e-15, c: -1.11230901642e-14, d: 4692.24982433, ty: -2345.62498437 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.976470589638, g: 0.92549020052, b: 0.564705908298, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.976470589638, g: 0.929411768913, b: 0.572549045086, a: 0.0 } }] }, Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:280;2852:3623"), x: 585.199951172, y: -152.399993896, width: 126.599998474, height: 55.2000007629, blend: Normal, opacity: 0.300000011921 } +Path { node_id: NodeId("I1:280;2852:3623"), x: 585.199951172, y: -152.399993896, width: 126.599998474, height: 55.2000007629, normalize_to_origin: false, text_outline: false, fill_paths: ["M4.8 55.2 L0 55.2 L0 0 L126.6 0 L126.6 55.2 L122.4 55.2 L122.4 4.8 L4.8 4.8 L4.8 55.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.704166650772, g: 0.145148858428, b: 0.0410763882101, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 1.60000002384, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:280;2852:3623") } +ClipEnd { node_id: NodeId("I1:280;2847:3543") } +Rect { node_id: NodeId("I1:280;2853:3637"), x: 643.053955078, y: -97.1999969482, width: 9.45403671265, height: 9.30806159973, fills: [], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2853:3630"), x: 643.053955078, y: -97.1999969482, width: 9.19999980927, height: 9.19999980927, normalize_to_origin: false, text_outline: false, fill_paths: ["M9.2 4.6 C9.2 7.14051 7.14051 9.2 4.6 9.2 C2.05949 9.2 0 7.14051 0 4.6 C0 2.05949 2.05949 0 4.6 0 C7.14051 0 9.2 2.05949 9.2 4.6 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.1254902035, g: 0.1254902035, b: 0.1254902035, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.787500023842, g: 0.787500023842, b: 0.787500023842, a: 0.25 }, offset_x: 0.800000011921, offset_y: 0.800000011921, radius: 0.40000000596, spread: 0.0 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.800000011921 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.220833331347, g: 0.21899305284, b: 0.21899305284, a: 1.0 }, offset_x: 0.40000000596, offset_y: 0.40000000596, radius: 0.40000000596, spread: 0.0 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.40000000596 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:280;2853:3635"), x: 644.819519043, y: -95.2344360352, width: 7.59999990463, height: 7.59999990463, normalize_to_origin: false, text_outline: false, fill_paths: ["M3.16667 4.43333 L3.16667 7.6 L4.43333 7.6 L4.43333 4.43333 L7.6 4.43333 L7.6 3.16667 L4.43333 3.16667 L4.43333 0 L3.16667 0 L3.16667 3.16667 L0 3.16667 L0 4.43333 L3.16667 4.43333 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.105882354081, g: 0.105882354081, b: 0.105882354081, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, offset_x: 0.0, offset_y: 0.40000000596, radius: 0.800000011921, spread: 0.0 }], blend: Normal, rotation: 0.261799389895, authored: false } +Path { node_id: NodeId("1:281"), x: 588.638061523, y: -65.6275558472, width: 125.0, height: 7.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("1:282"), x: 588.231201172, y: -56.8062515259, width: 127.0, height: 13.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.870833337307, g: 0.870833337307, b: 0.870833337307, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("I1:283;2845:3288"), x: 797.199951172, y: -160.0, width: 14.4000005722, height: 14.4000005722, normalize_to_origin: false, text_outline: false, fill_paths: ["M14.4 7.2 C14.4 11.1765 11.1765 14.4 7.2 14.4 C3.22355 14.4 0 11.1765 0 7.2 C0 3.22355 3.22355 0 7.2 0 C11.1765 0 14.4 3.22355 14.4 7.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.126985684037, g: 0.124869249761, b: 0.124869249761, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.483333319426, g: 0.483333319426, b: 0.483333319426, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: -0.800000011921, radius: 1.60000002384, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:283;2845:3299"), x: 798.399963379, y: -158.800003052, width: 13.6000003815, height: 13.6000003815, blend: Normal, opacity: 0.800000011921 } +Path { node_id: NodeId("I1:283;2845:3299"), x: 798.399963379, y: -158.800003052, width: 13.6000003815, height: 13.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M11.6083 1.99167 C10.6573 1.04068 9.44568 0.393039 8.12661 0.13066 C6.80754 -0.131719 5.44029 0.00294347 4.19775 0.51762 C2.95521 1.0323 1.8932 1.90387 1.14601 3.02212 C0.398813 4.14038 -2.03076e-07 5.45509 7.77156e-14 6.8 L6.8 6.8 L11.6083 1.99167 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.168627455831, g: 0.168627455831, b: 0.168627455831, a: 1.0 }), Normal), (GradientLinear { transform: Some(AffineTransform2D { a: 0.356725140803, b: -0.502923983861, tx: 0.47368421067, c: 0.502923983861, d: 0.356725140803, ty: -0.0438596472485 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.570833325386, g: 0.570833325386, b: 0.570833325386, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:283;2845:3299") } +Path { node_id: NodeId("I1:283;2845:3296"), x: 800.442993164, y: -156.756912231, width: 8.80000019073, height: 8.80000019073, normalize_to_origin: false, text_outline: false, fill_paths: ["M0.799999 5.2 C2.3464 5.2 3.6 6.4536 3.6 8 L3.6 8.8 L5.2 8.8 L5.2 8 C5.2 6.4536 6.4536 5.2 8 5.2 L8.8 5.2 L8.8 3.6 L8 3.6 C6.4536 3.6 5.2 2.3464 5.2 0.799999 L5.2 0 L3.6 0 L3.6 0.799999 C3.6 2.3464 2.3464 3.6 0.799999 3.6 L0 3.6 L0 5.2 L0.799999 5.2 Z"], stroke_paths: [""], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0156862754375, g: 0.0156862754375, b: 0.0156862754375, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.287204861641, g: 0.290080964565, b: 0.295833319426, a: 1.0 }, gradient: None, width: 0.40000000596, cap: None, join: Miter, align: Outside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } +Path { node_id: NodeId("I1:283;2845:3298"), x: 803.567871094, y: -153.832061768, width: 4.40000009537, height: 4.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -1.47743647709e-15, b: -0.5, tx: 0.749999880791, c: 0.5, d: -5.26352033248e-15, ty: 0.374999970198 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.1254902035, g: 0.133333340287, b: 0.129411771894, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.254901975393, g: 0.258823543787, b: 0.266666680574, a: 1.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } +Path { node_id: NodeId("I1:284;2845:3288"), x: 797.199951172, y: -75.5999984741, width: 14.4000005722, height: 14.4000005722, normalize_to_origin: false, text_outline: false, fill_paths: ["M14.4 7.2 C14.4 11.1765 11.1765 14.4 7.2 14.4 C3.22355 14.4 0 11.1765 0 7.2 C0 3.22355 3.22355 0 7.2 0 C11.1765 0 14.4 3.22355 14.4 7.2 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.126985684037, g: 0.124869249761, b: 0.124869249761, a: 1.0 }), Normal)], stroke: None, effects: [NodeEffect { kind: DropShadow, color: Rgba { r: 0.483333319426, g: 0.483333319426, b: 0.483333319426, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.800000011921 }, NodeEffect { kind: DropShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.699999988079 }, offset_x: 0.0, offset_y: 0.0, radius: 0.800000011921, spread: 0.40000000596 }, NodeEffect { kind: InnerShadow, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.5 }, offset_x: 0.0, offset_y: -0.800000011921, radius: 1.60000002384, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerStart { node_id: NodeId("I1:284;2845:3299"), x: 798.399963379, y: -74.4000015259, width: 13.6000003815, height: 13.6000003815, blend: Normal, opacity: 0.800000011921 } +Path { node_id: NodeId("I1:284;2845:3299"), x: 798.399963379, y: -74.4000015259, width: 13.6000003815, height: 13.6000003815, normalize_to_origin: false, text_outline: false, fill_paths: ["M11.6083 1.99167 C10.6573 1.04068 9.44568 0.393039 8.12661 0.13066 C6.80754 -0.131719 5.44029 0.00294347 4.19775 0.51762 C2.95521 1.0323 1.8932 1.90387 1.14601 3.02212 C0.398813 4.14038 -2.03076e-07 5.45509 7.77156e-14 6.8 L6.8 6.8 L11.6083 1.99167 Z"], stroke_paths: [], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.168627455831, g: 0.168627455831, b: 0.168627455831, a: 1.0 }), Normal), (GradientLinear { transform: Some(AffineTransform2D { a: 0.356725140803, b: -0.502923983861, tx: 0.47368421067, c: 0.502923983861, d: 0.356725140803, ty: -0.0438596472485 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.570833325386, g: 0.570833325386, b: 0.570833325386, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.172549024224, g: 0.172549024224, b: 0.164705887437, a: 0.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: 0.0, authored: false } +LayerEnd { node_id: NodeId("I1:284;2845:3299") } +Path { node_id: NodeId("I1:284;2845:3296"), x: 800.442993164, y: -72.3569259644, width: 8.80000019073, height: 8.80000019073, normalize_to_origin: false, text_outline: false, fill_paths: ["M0.799999 5.2 C2.3464 5.2 3.6 6.4536 3.6 8 L3.6 8.8 L5.2 8.8 L5.2 8 C5.2 6.4536 6.4536 5.2 8 5.2 L8.8 5.2 L8.8 3.6 L8 3.6 C6.4536 3.6 5.2 2.3464 5.2 0.799999 L5.2 0 L3.6 0 L3.6 0.799999 C3.6 2.3464 2.3464 3.6 0.799999 3.6 L0 3.6 L0 5.2 L0.799999 5.2 Z"], stroke_paths: [""], svg_base64: Some(""), fills: [(Solid(Rgba { r: 0.0156862754375, g: 0.0156862754375, b: 0.0156862754375, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.287204861641, g: 0.290080964565, b: 0.295833319426, a: 1.0 }, gradient: None, width: 0.40000000596, cap: None, join: Miter, align: Outside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } +Path { node_id: NodeId("I1:284;2845:3298"), x: 803.567871094, y: -69.4320678711, width: 4.40000009537, height: 4.40000009537, normalize_to_origin: false, text_outline: false, fill_paths: [""], stroke_paths: [], svg_base64: Some(""), fills: [(GradientLinear { transform: Some(AffineTransform2D { a: -1.47743647709e-15, b: -0.5, tx: 0.749999880791, c: 0.5, d: -5.26352033248e-15, ty: 0.374999970198 }), stops: [GradientStop { offset: 0.0, color: Rgba { r: 0.1254902035, g: 0.133333340287, b: 0.129411771894, a: 1.0 } }, GradientStop { offset: 1.0, color: Rgba { r: 0.254901975393, g: 0.258823543787, b: 0.266666680574, a: 1.0 } }] }, Normal)], stroke: None, effects: [NodeEffect { kind: LayerBlur, color: Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.25 }, offset_x: 0.0, offset_y: 0.0, radius: 0.40000000596, spread: 0.0 }], blend: Normal, rotation: -0.52359878337, authored: false } diff --git a/crates/frame/render/tests/parity/golden/strokes.caps-joins-dashes.primitives.txt b/crates/frame/render/tests/parity/golden/strokes.caps-joins-dashes.primitives.txt new file mode 100644 index 00000000..502efe01 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/strokes.caps-joins-dashes.primitives.txt @@ -0,0 +1,10 @@ +Rect { node_id: NodeId("8:840"), x: 0.0, y: 2280.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1327"), x: 24.0, y: 2304.0, width: 312.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1328"), x: 24.0, y: 2352.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1329"), x: 24.0, y: 2664.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1330"), x: 24.0, y: 2976.0, width: 78.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1331"), x: 200.0, y: 2430.0, width: 160.0, height: 120.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 120 L80 0 L160 120"], stroke_paths: ["M80 0 L88.3205 -5.547 L80 -18.0278 L71.6795 -5.547 L80 0 Z M0 120 L8.3205 125.547 L88.3205 5.547 L80 0 L71.6795 -5.547 L-8.3205 114.453 L0 120 Z M80 0 L71.6795 5.547 L151.68 125.547 L160 120 L168.32 114.453 L88.3205 -5.547 L80 0 Z"], svg_base64: None, fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0823529437184, g: 0.0823529437184, b: 0.0823529437184, a: 1.0 }, gradient: None, width: 20.0, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1332"), x: 480.0, y: 2430.0, width: 160.0, height: 120.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 120 L80 0 L160 120"], stroke_paths: [""], svg_base64: None, fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0823529437184, g: 0.0823529437184, b: 0.0823529437184, a: 1.0 }, gradient: None, width: 20.0, cap: None, join: Round, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1333"), x: 760.0, y: 2430.0, width: 160.0, height: 120.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 120 L80 0 L160 120"], stroke_paths: ["M80 0 L88.3205 -5.547 L71.6795 -5.547 L80 0 Z M0 120 L8.3205 125.547 L88.3205 5.547 L80 0 L71.6795 -5.547 L-8.3205 114.453 L0 120 Z M80 0 L71.6795 5.547 L151.68 125.547 L160 120 L168.32 114.453 L88.3205 -5.547 L80 0 Z"], svg_base64: None, fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.0823529437184, g: 0.0823529437184, b: 0.0823529437184, a: 1.0 }, gradient: None, width: 20.0, cap: None, join: Bevel, align: Center, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1334"), x: 220.0, y: 2770.0, width: 780.0, height: 0.0, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: [""], svg_base64: None, fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.141176477075, g: 0.380392163992, b: 0.960784316063, a: 1.0 }, gradient: None, width: 14.0, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [12.0, 8.0], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1335"), x: 220.0, y: 3080.0, width: 780.0, height: 0.0, normalize_to_origin: false, text_outline: false, fill_paths: [], stroke_paths: [""], svg_base64: None, fills: [], stroke: Some(StrokeStyle { color: Rgba { r: 0.960784316063, g: 0.450980395079, b: 0.141176477075, a: 1.0 }, gradient: None, width: 14.0, cap: None, join: Miter, align: Center, miter_limit: 4.0, dash_pattern: [20.0, 6.0, 4.0, 6.0], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/text.alignment-truncation.primitives.txt b/crates/frame/render/tests/parity/golden/text.alignment-truncation.primitives.txt new file mode 100644 index 00000000..cfc83685 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/text.alignment-truncation.primitives.txt @@ -0,0 +1,9 @@ +Rect { node_id: NodeId("8:925"), x: 0.0, y: 6840.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1404"), x: 24.0, y: 6864.0, width: 302.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1405"), x: 24.0, y: 6912.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1409"), x: 180.0, y: 6990.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.823529422283, g: 0.823529422283, b: 0.823529422283, a: 1.0 }, gradient: None, width: 1.0, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1410"), x: 190.0, y: 7000.0, width: 260.0, height: 160.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1411"), x: 500.0, y: 6990.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.823529422283, g: 0.823529422283, b: 0.823529422283, a: 1.0 }, gradient: None, width: 1.0, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1412"), x: 510.0, y: 7000.0, width: 260.0, height: 160.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1413"), x: 820.0, y: 6990.0, width: 280.0, height: 180.0, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L280 0 L280 180 L0 180 L0 0 Z"], stroke_paths: [""], svg_base64: None, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: Some(StrokeStyle { color: Rgba { r: 0.823529422283, g: 0.823529422283, b: 0.823529422283, a: 1.0 }, gradient: None, width: 1.0, cap: None, join: Miter, align: Inside, miter_limit: 4.0, dash_pattern: [], dash_offset: 0.0 }), effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1414"), x: 830.0, y: 7000.0, width: 260.0, height: 160.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/text.font-weight-style.primitives.txt b/crates/frame/render/tests/parity/golden/text.font-weight-style.primitives.txt new file mode 100644 index 00000000..a2df19fd --- /dev/null +++ b/crates/frame/render/tests/parity/golden/text.font-weight-style.primitives.txt @@ -0,0 +1,4 @@ +Rect { node_id: NodeId("8:918"), x: 1980.0, y: 5700.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1398"), x: 2004.0, y: 5724.0, width: 268.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1399"), x: 2004.0, y: 5772.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1403"), x: 2160.0, y: 5850.0, width: 598.0, height: 51.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/text.mixed-style-runs.primitives.txt b/crates/frame/render/tests/parity/golden/text.mixed-style-runs.primitives.txt new file mode 100644 index 00000000..df9e2d52 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/text.mixed-style-runs.primitives.txt @@ -0,0 +1,12 @@ +Rect { node_id: NodeId("8:868"), x: 1980.0, y: 3420.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1352"), x: 2004.0, y: 3444.0, width: 266.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1353"), x: 2004.0, y: 3492.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1354"), x: 2004.0, y: 3804.0, width: 71.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1355"), x: 2004.0, y: 4116.0, width: 78.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1357"), x: 2180.0, y: 3600.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.109803922474, g: 0.109803922474, b: 0.109803922474, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1357"), x: 2180.0, y: 3600.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1357"), x: 2180.0, y: 3600.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.313725501299, b: 0.313725501299, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1357"), x: 2180.0, y: 3600.0, width: 581.0, height: 34.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.235294118524, g: 0.450980395079, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1358"), x: 2180.0, y: 3890.0, width: 1120.0, height: 120.0, normalize_to_origin: true, text_outline: true, fill_paths: ["", ""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal), (Solid(Rgba { r: 0.274509817362, g: 0.568627476692, b: 0.960784316063, a: 1.0 }), Normal), (Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1359"), x: 2180.0, y: 4200.0, width: 780.0, height: 170.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1359"), x: 2180.0, y: 4200.0, width: 780.0, height: 170.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.470588237047, b: 0.313725501299, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/text.text-advanced.primitives.txt b/crates/frame/render/tests/parity/golden/text.text-advanced.primitives.txt new file mode 100644 index 00000000..dd79b175 --- /dev/null +++ b/crates/frame/render/tests/parity/golden/text.text-advanced.primitives.txt @@ -0,0 +1,4 @@ +Rect { node_id: NodeId("8:1017"), x: 0.0, y: 11400.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1488"), x: 24.0, y: 11424.0, width: 242.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1489"), x: 24.0, y: 11472.0, width: 90.0, height: 15.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1493"), x: 180.0, y: 11540.0, width: 494.0, height: 100.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } diff --git a/crates/frame/render/tests/parity/golden/transform.nested-transform-stack.primitives.txt b/crates/frame/render/tests/parity/golden/transform.nested-transform-stack.primitives.txt new file mode 100644 index 00000000..8d555f8a --- /dev/null +++ b/crates/frame/render/tests/parity/golden/transform.nested-transform-stack.primitives.txt @@ -0,0 +1,6 @@ +Rect { node_id: NodeId("8:1050"), x: 0.0, y: 13680.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1517"), x: 24.0, y: 13704.0, width: 385.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("15:1521"), x: 220.0, y: 13840.0, width: 560.0, height: 420.0, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1522"), x: 340.0, y: 13920.0, width: 291.74248606, height: 230.123610795, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L260 0 L260 180 L0 180 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.313725501299, g: 0.509803950787, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.209439499418, authored: false } +Path { node_id: NodeId("15:1523"), x: 400.0, y: 13960.0, width: 249.404649138, height: 191.62109077, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L220 0 L220 130 L0 130 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.509803950787, b: 0.313725501299, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.314159265836, authored: false } +Path { node_id: NodeId("15:1524"), x: 460.0, y: 14000.0, width: 163.520333469, height: 135.891469121, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L140 0 L140 90 L0 90 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.392156869173, g: 0.86274510622, b: 0.588235318661, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.383972418105, authored: false } diff --git a/crates/frame/render/tests/parity/golden/transform.transform-precision.primitives.txt b/crates/frame/render/tests/parity/golden/transform.transform-precision.primitives.txt new file mode 100644 index 00000000..b7b38f3c --- /dev/null +++ b/crates/frame/render/tests/parity/golden/transform.transform-precision.primitives.txt @@ -0,0 +1,6 @@ +Rect { node_id: NodeId("8:1032"), x: 0.0, y: 12540.0, width: 1920.0, height: 1080.0, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.960784316063, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1501"), x: 24.0, y: 12564.0, width: 346.0, height: 24.0, normalize_to_origin: true, text_outline: true, fill_paths: [""], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.0, authored: false } +Rect { node_id: NodeId("15:1505"), x: 180.5, y: 12680.5, width: 780.0, height: 500.0, fills: [(Solid(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), Normal)], stroke: None, corner_radii: CornerRadii { top_left: 0.0, top_right: 0.0, bottom_right: 0.0, bottom_left: 0.0 }, effects: [], blend: Normal, rotation: 0.0, authored: false } +Path { node_id: NodeId("15:1506"), x: 300.75, y: 12771.0, width: 357.976648435, height: 279.725835413, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L320.5 0 L320.5 220.25 L0 220.25 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.372549027205, g: 0.556862771511, b: 0.960784316063, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.200712875015, authored: false } +Path { node_id: NodeId("15:1507"), x: 361.25, y: 12820.75, width: 277.112600833, height: 224.763569683, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L240.25 0 L240.25 160.75 L0 160.75 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.960784316063, g: 0.549019634724, b: 0.372549027205, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: 0.301069290861, authored: false } +Path { node_id: NodeId("15:1508"), x: 400.600006104, y: 12870.8000031, width: 209.188838854, height: 171.70453842, normalize_to_origin: false, text_outline: false, fill_paths: ["M0 0 L180.5 0 L180.5 110.5 L0 110.5 L0 0 Z"], stroke_paths: [], svg_base64: None, fills: [(Solid(Rgba { r: 0.439215689898, g: 0.86274510622, b: 0.603921592236, a: 1.0 }), Normal)], stroke: None, effects: [], blend: Normal, rotation: -0.39706241164, authored: false } diff --git a/tests/frame/parity/reports/summary.json b/crates/frame/render/tests/parity/reports/summary.json similarity index 100% rename from tests/frame/parity/reports/summary.json rename to crates/frame/render/tests/parity/reports/summary.json diff --git a/tests/frame/parity/reports/summary.md b/crates/frame/render/tests/parity/reports/summary.md similarity index 100% rename from tests/frame/parity/reports/summary.md rename to crates/frame/render/tests/parity/reports/summary.md diff --git a/tests/frame/parity/reports/svg-hash-baselines.json b/crates/frame/render/tests/parity/reports/svg-hash-baselines.json similarity index 100% rename from tests/frame/parity/reports/svg-hash-baselines.json rename to crates/frame/render/tests/parity/reports/svg-hash-baselines.json diff --git a/tests/frame/parity/reports/svg-summary.json b/crates/frame/render/tests/parity/reports/svg-summary.json similarity index 100% rename from tests/frame/parity/reports/svg-summary.json rename to crates/frame/render/tests/parity/reports/svg-summary.json diff --git a/tests/frame/parity/reports/svg-summary.md b/crates/frame/render/tests/parity/reports/svg-summary.md similarity index 100% rename from tests/frame/parity/reports/svg-summary.md rename to crates/frame/render/tests/parity/reports/svg-summary.md diff --git a/tests/frame/parity/scenarios/.gitkeep b/crates/frame/render/tests/parity/scenarios/.gitkeep similarity index 100% rename from tests/frame/parity/scenarios/.gitkeep rename to crates/frame/render/tests/parity/scenarios/.gitkeep diff --git a/tests/frame/parity/scenarios/core10.txt b/crates/frame/render/tests/parity/scenarios/core10.txt similarity index 100% rename from tests/frame/parity/scenarios/core10.txt rename to crates/frame/render/tests/parity/scenarios/core10.txt diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/README.md b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/README.md similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/README.md rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/README.md diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/actual-svg.png b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/actual-svg.png diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/actual.png b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/actual.png similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/actual.png rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/actual.png diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/actual.svg b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/actual.svg rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/actual.svg diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/expected.png b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/expected.png similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/expected.png rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/expected.png diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/expected.svg b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/expected.svg rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/expected.svg diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/report.json b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/report.json rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/report.json diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/status.md b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/status.md similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/status.md rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/status.md diff --git a/tests/frame/parity/scenarios/effects.background-blur-stress/svg-report.json b/crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.background-blur-stress/svg-report.json rename to crates/frame/render/tests/parity/scenarios/effects.background-blur-stress/svg-report.json diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/README.md b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/README.md similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/README.md rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/README.md diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/actual-svg.png b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/actual-svg.png diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/actual.png b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/actual.png similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/actual.png rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/actual.png diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/actual.svg b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/actual.svg rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/actual.svg diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/expected.png b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/expected.png similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/expected.png rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/expected.png diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/expected.svg b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/expected.svg rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/expected.svg diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/report.json b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/report.json rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/report.json diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/status.md b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/status.md similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/status.md rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/status.md diff --git a/tests/frame/parity/scenarios/effects.drop-shadow-matrix/svg-report.json b/crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.drop-shadow-matrix/svg-report.json rename to crates/frame/render/tests/parity/scenarios/effects.drop-shadow-matrix/svg-report.json diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/README.md b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/README.md similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/README.md rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/README.md diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/actual-svg.png b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/actual-svg.png diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/actual.png b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/actual.png similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/actual.png rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/actual.png diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/actual.svg b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/actual.svg rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/actual.svg diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/expected.png b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/expected.png similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/expected.png rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/expected.png diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/expected.svg b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/expected.svg rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/expected.svg diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/report.json b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/report.json rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/report.json diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/status.md b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/status.md similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/status.md rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/status.md diff --git a/tests/frame/parity/scenarios/effects.effect-ordering/svg-report.json b/crates/frame/render/tests/parity/scenarios/effects.effect-ordering/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.effect-ordering/svg-report.json rename to crates/frame/render/tests/parity/scenarios/effects.effect-ordering/svg-report.json diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/README.md b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/README.md similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/README.md rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/README.md diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/actual-svg.png b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/actual-svg.png diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/actual.png b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/actual.png similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/actual.png rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/actual.png diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/actual.svg b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/actual.svg rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/actual.svg diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/expected.png b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/expected.png similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/expected.png rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/expected.png diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/expected.svg b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/expected.svg rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/expected.svg diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/report.json b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/report.json rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/report.json diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/status.md b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/status.md similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/status.md rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/status.md diff --git a/tests/frame/parity/scenarios/effects.inner-shadow-matrix/svg-report.json b/crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.inner-shadow-matrix/svg-report.json rename to crates/frame/render/tests/parity/scenarios/effects.inner-shadow-matrix/svg-report.json diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/README.md b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/README.md similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/README.md rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/README.md diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/actual-svg.png b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/actual-svg.png diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/actual.png b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/actual.png similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/actual.png rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/actual.png diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/actual.svg b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/actual.svg rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/actual.svg diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/expected.png b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/expected.png similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/expected.png rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/expected.png diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/expected.svg b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/expected.svg rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/expected.svg diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/report.json b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/report.json rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/report.json diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/status.md b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/status.md similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/status.md rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/status.md diff --git a/tests/frame/parity/scenarios/effects.layer-background-blur/svg-report.json b/crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.layer-background-blur/svg-report.json rename to crates/frame/render/tests/parity/scenarios/effects.layer-background-blur/svg-report.json diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/README.md b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/README.md similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/README.md rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/README.md diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/actual-svg.png b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/actual-svg.png diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/actual.png b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/actual.png similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/actual.png rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/actual.png diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/actual.svg b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/actual.svg rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/actual.svg diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/expected.png b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/expected.png similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/expected.png rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/expected.png diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/expected.svg b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/expected.svg rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/expected.svg diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/report.json b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/report.json rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/report.json diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/status.md b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/status.md similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/status.md rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/status.md diff --git a/tests/frame/parity/scenarios/effects.shadow-matrix/svg-report.json b/crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/effects.shadow-matrix/svg-report.json rename to crates/frame/render/tests/parity/scenarios/effects.shadow-matrix/svg-report.json diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/README.md b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/README.md similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/README.md rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/README.md diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/actual-svg.png b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/actual-svg.png diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/actual.png b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/actual.png similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/actual.png rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/actual.png diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/actual.svg b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/actual.svg rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/actual.svg diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/expected.png b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/expected.png similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/expected.png rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/expected.png diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/expected.svg b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/expected.svg rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/expected.svg diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/report.json b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/report.json similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/report.json rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/report.json diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/status.md b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/status.md similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/status.md rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/status.md diff --git a/tests/frame/parity/scenarios/images.fill-mode-matrix/svg-report.json b/crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/images.fill-mode-matrix/svg-report.json rename to crates/frame/render/tests/parity/scenarios/images.fill-mode-matrix/svg-report.json diff --git a/tests/frame/parity/scenarios/images.image-sampling/README.md b/crates/frame/render/tests/parity/scenarios/images.image-sampling/README.md similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/README.md rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/README.md diff --git a/tests/frame/parity/scenarios/images.image-sampling/actual-svg.png b/crates/frame/render/tests/parity/scenarios/images.image-sampling/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/actual-svg.png diff --git a/tests/frame/parity/scenarios/images.image-sampling/actual.png b/crates/frame/render/tests/parity/scenarios/images.image-sampling/actual.png similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/actual.png rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/actual.png diff --git a/tests/frame/parity/scenarios/images.image-sampling/actual.svg b/crates/frame/render/tests/parity/scenarios/images.image-sampling/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/actual.svg rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/actual.svg diff --git a/tests/frame/parity/scenarios/images.image-sampling/expected.png b/crates/frame/render/tests/parity/scenarios/images.image-sampling/expected.png similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/expected.png rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/expected.png diff --git a/tests/frame/parity/scenarios/images.image-sampling/expected.svg b/crates/frame/render/tests/parity/scenarios/images.image-sampling/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/expected.svg rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/expected.svg diff --git a/tests/frame/parity/scenarios/images.image-sampling/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/images.image-sampling/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/images.image-sampling/report.json b/crates/frame/render/tests/parity/scenarios/images.image-sampling/report.json similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/report.json rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/report.json diff --git a/tests/frame/parity/scenarios/images.image-sampling/status.md b/crates/frame/render/tests/parity/scenarios/images.image-sampling/status.md similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/status.md rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/status.md diff --git a/tests/frame/parity/scenarios/images.image-sampling/svg-report.json b/crates/frame/render/tests/parity/scenarios/images.image-sampling/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/images.image-sampling/svg-report.json rename to crates/frame/render/tests/parity/scenarios/images.image-sampling/svg-report.json diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/README.md b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/README.md similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/README.md rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/README.md diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/actual-svg.png b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/actual-svg.png diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/actual.png b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/actual.png similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/actual.png rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/actual.png diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/actual.svg b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/actual.svg rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/actual.svg diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/expected.png b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/expected.png similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/expected.png rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/expected.png diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/expected.svg b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/expected.svg rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/expected.svg diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/report.json b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/report.json similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/report.json rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/report.json diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/status.md b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/status.md similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/status.md rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/status.md diff --git a/tests/frame/parity/scenarios/layout.auto-layout-hug-fill/svg-report.json b/crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/layout.auto-layout-hug-fill/svg-report.json rename to crates/frame/render/tests/parity/scenarios/layout.auto-layout-hug-fill/svg-report.json diff --git a/tests/frame/parity/scenarios/manifest.template.json b/crates/frame/render/tests/parity/scenarios/manifest.template.json similarity index 100% rename from tests/frame/parity/scenarios/manifest.template.json rename to crates/frame/render/tests/parity/scenarios/manifest.template.json diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/README.md b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/README.md similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/README.md rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/README.md diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/actual-svg.png b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/actual-svg.png diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/actual.png b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/actual.png similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/actual.png rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/actual.png diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/actual.svg b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/actual.svg rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/actual.svg diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/expected.png b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/expected.png similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/expected.png rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/expected.png diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/expected.svg b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/expected.svg rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/expected.svg diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/report.json b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/report.json similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/report.json rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/report.json diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/status.md b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/status.md similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/status.md rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/status.md diff --git a/tests/frame/parity/scenarios/masks.clip-content-nesting/svg-report.json b/crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/masks.clip-content-nesting/svg-report.json rename to crates/frame/render/tests/parity/scenarios/masks.clip-content-nesting/svg-report.json diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/README.md b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/README.md similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/README.md rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/README.md diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/actual-svg.png b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/actual-svg.png diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/actual.png b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/actual.png similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/actual.png rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/actual.png diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/actual.svg b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/actual.svg rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/actual.svg diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/expected.png b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/expected.png similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/expected.png rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/expected.png diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/expected.svg b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/expected.svg rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/expected.svg diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/report.json b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/report.json similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/report.json rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/report.json diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/status.md b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/status.md similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/status.md rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/status.md diff --git a/tests/frame/parity/scenarios/masks.mask-clip-interactions/svg-report.json b/crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/masks.mask-clip-interactions/svg-report.json rename to crates/frame/render/tests/parity/scenarios/masks.mask-clip-interactions/svg-report.json diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/README.md b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/README.md similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/README.md rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/README.md diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/actual-svg.png b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/actual-svg.png diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/actual.png b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/actual.png similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/actual.png rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/actual.png diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/actual.svg b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/actual.svg rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/actual.svg diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/expected.png b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/expected.png similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/expected.png rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/expected.png diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/expected.svg b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/expected.svg rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/expected.svg diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/report.json b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/report.json similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/report.json rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/report.json diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/status.md b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/status.md similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/status.md rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/status.md diff --git a/tests/frame/parity/scenarios/masks.vector-text-image-mask/svg-report.json b/crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/masks.vector-text-image-mask/svg-report.json rename to crates/frame/render/tests/parity/scenarios/masks.vector-text-image-mask/svg-report.json diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/README.md b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/README.md similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/README.md rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/README.md diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/actual-svg.png b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/actual-svg.png diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/actual.png b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/actual.png similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/actual.png rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/actual.png diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/actual.svg b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/actual.svg rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/actual.svg diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/expected.png b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/expected.png similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/expected.png rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/expected.png diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/expected.svg b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/expected.svg rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/expected.svg diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/report.json b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/report.json similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/report.json rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/report.json diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/status.md b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/status.md similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/status.md rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/status.md diff --git a/tests/frame/parity/scenarios/paint.blend-mode-combos/svg-report.json b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-combos/svg-report.json rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-combos/svg-report.json diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/README.md b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/README.md similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/README.md rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/README.md diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/actual-svg.png b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/actual-svg.png diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/actual.png b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/actual.png similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/actual.png rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/actual.png diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/actual.svg b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/actual.svg rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/actual.svg diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/expected.png b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/expected.png similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/expected.png rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/expected.png diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/expected.svg b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/expected.svg rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/expected.svg diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/report.json b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/report.json similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/report.json rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/report.json diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/status.md b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/status.md similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/status.md rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/status.md diff --git a/tests/frame/parity/scenarios/paint.blend-mode-stack/svg-report.json b/crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/paint.blend-mode-stack/svg-report.json rename to crates/frame/render/tests/parity/scenarios/paint.blend-mode-stack/svg-report.json diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/README.md b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/README.md similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/README.md rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/README.md diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/actual-svg.png b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/actual-svg.png diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/actual.png b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/actual.png similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/actual.png rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/actual.png diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/actual.svg b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/actual.svg rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/actual.svg diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/expected.png b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/expected.png similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/expected.png rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/expected.png diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/expected.svg b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/expected.svg rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/expected.svg diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/report.json b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/report.json similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/report.json rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/report.json diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/status.md b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/status.md similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/status.md rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/status.md diff --git a/tests/frame/parity/scenarios/paint.gradient-matrix/svg-report.json b/crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/paint.gradient-matrix/svg-report.json rename to crates/frame/render/tests/parity/scenarios/paint.gradient-matrix/svg-report.json diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/README.md b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/README.md similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/README.md rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/README.md diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/actual-svg.png b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/actual-svg.png diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/actual.png b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/actual.png similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/actual.png rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/actual.png diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/actual.svg b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/actual.svg rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/actual.svg diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/expected.png b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/expected.png similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/expected.png rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/expected.png diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/expected.svg b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/expected.svg rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/expected.svg diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/report.json b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/report.json similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/report.json rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/report.json diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/report.md b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/report.md similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/report.md rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/report.md diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/status.md b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/status.md similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/status.md rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/status.md diff --git a/tests/frame/parity/scenarios/primitives.fractional-geometry/svg-report.json b/crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/primitives.fractional-geometry/svg-report.json rename to crates/frame/render/tests/parity/scenarios/primitives.fractional-geometry/svg-report.json diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/actual-svg.png b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/actual-svg.png diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/actual.png b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/actual.png similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/actual.png rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/actual.png diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/actual.svg b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/actual.svg rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/actual.svg diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/expected.png b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/expected.png similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/expected.png rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/expected.png diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/expected.svg b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/expected.svg rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/expected.svg diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/report.json b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/report.json similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/report.json rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/report.json diff --git a/tests/frame/parity/scenarios/primitives.single-pixel-anchor/svg-report.json b/crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/primitives.single-pixel-anchor/svg-report.json rename to crates/frame/render/tests/parity/scenarios/primitives.single-pixel-anchor/svg-report.json diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/README.md b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/README.md similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/README.md rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/README.md diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/actual-svg.png b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/actual-svg.png diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/actual.png b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/actual.png similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/actual.png rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/actual.png diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/actual.svg b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/actual.svg rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/actual.svg diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/expected.png b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/expected.png similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/expected.png rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/expected.png diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/expected.svg b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/expected.svg rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/expected.svg diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/report.json b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/report.json similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/report.json rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/report.json diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/status.md b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/status.md similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/status.md rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/status.md diff --git a/tests/frame/parity/scenarios/regression.kitchen-sink/svg-report.json b/crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/regression.kitchen-sink/svg-report.json rename to crates/frame/render/tests/parity/scenarios/regression.kitchen-sink/svg-report.json diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/README.md b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/README.md similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/README.md rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/README.md diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/actual-svg.png b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/actual-svg.png diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/actual.png b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/actual.png similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/actual.png rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/actual.png diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/actual.svg b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/actual.svg rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/actual.svg diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/expected.png b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/expected.png similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/expected.png rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/expected.png diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/expected.svg b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/expected.svg rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/expected.svg diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/report.json b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/report.json similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/report.json rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/report.json diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/status.md b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/status.md similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/status.md rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/status.md diff --git a/tests/frame/parity/scenarios/regression.tough-1176-blue-face/svg-report.json b/crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/regression.tough-1176-blue-face/svg-report.json rename to crates/frame/render/tests/parity/scenarios/regression.tough-1176-blue-face/svg-report.json diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/README.md b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/README.md similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/README.md rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/README.md diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/actual-svg.png b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/actual-svg.png diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/actual.png b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/actual.png similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/actual.png rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/actual.png diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/actual.svg b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/actual.svg rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/actual.svg diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/expected.png b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/expected.png similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/expected.png rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/expected.png diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/expected.svg b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/expected.svg rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/expected.svg diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/report.json b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/report.json similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/report.json rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/report.json diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/status.md b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/status.md similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/status.md rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/status.md diff --git a/tests/frame/parity/scenarios/strokes.caps-joins-dashes/svg-report.json b/crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/strokes.caps-joins-dashes/svg-report.json rename to crates/frame/render/tests/parity/scenarios/strokes.caps-joins-dashes/svg-report.json diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/README.md b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/README.md similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/README.md rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/README.md diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/actual-svg.png b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/actual-svg.png diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/actual.png b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/actual.png similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/actual.png rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/actual.png diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/actual.svg b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/actual.svg rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/actual.svg diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/expected.png b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/expected.png similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/expected.png rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/expected.png diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/expected.svg b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/expected.svg rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/expected.svg diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/report.json b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/report.json similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/report.json rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/report.json diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/status.md b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/status.md similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/status.md rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/status.md diff --git a/tests/frame/parity/scenarios/text.alignment-truncation/svg-report.json b/crates/frame/render/tests/parity/scenarios/text.alignment-truncation/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/text.alignment-truncation/svg-report.json rename to crates/frame/render/tests/parity/scenarios/text.alignment-truncation/svg-report.json diff --git a/tests/frame/parity/scenarios/text.font-weight-style/README.md b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/README.md similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/README.md rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/README.md diff --git a/tests/frame/parity/scenarios/text.font-weight-style/actual-svg.png b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/actual-svg.png diff --git a/tests/frame/parity/scenarios/text.font-weight-style/actual.png b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/actual.png similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/actual.png rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/actual.png diff --git a/tests/frame/parity/scenarios/text.font-weight-style/actual.svg b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/actual.svg rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/actual.svg diff --git a/tests/frame/parity/scenarios/text.font-weight-style/expected.png b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/expected.png similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/expected.png rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/expected.png diff --git a/tests/frame/parity/scenarios/text.font-weight-style/expected.svg b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/expected.svg rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/expected.svg diff --git a/tests/frame/parity/scenarios/text.font-weight-style/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/text.font-weight-style/report.json b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/report.json similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/report.json rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/report.json diff --git a/tests/frame/parity/scenarios/text.font-weight-style/status.md b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/status.md similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/status.md rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/status.md diff --git a/tests/frame/parity/scenarios/text.font-weight-style/svg-report.json b/crates/frame/render/tests/parity/scenarios/text.font-weight-style/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/text.font-weight-style/svg-report.json rename to crates/frame/render/tests/parity/scenarios/text.font-weight-style/svg-report.json diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/README.md b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/README.md similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/README.md rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/README.md diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/actual-svg.png b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/actual-svg.png diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/actual.png b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/actual.png similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/actual.png rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/actual.png diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/actual.svg b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/actual.svg rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/actual.svg diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/expected.png b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/expected.png similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/expected.png rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/expected.png diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/expected.svg b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/expected.svg rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/expected.svg diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/report.json b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/report.json similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/report.json rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/report.json diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/status.md b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/status.md similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/status.md rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/status.md diff --git a/tests/frame/parity/scenarios/text.mixed-style-runs/svg-report.json b/crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/text.mixed-style-runs/svg-report.json rename to crates/frame/render/tests/parity/scenarios/text.mixed-style-runs/svg-report.json diff --git a/tests/frame/parity/scenarios/text.text-advanced/README.md b/crates/frame/render/tests/parity/scenarios/text.text-advanced/README.md similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/README.md rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/README.md diff --git a/tests/frame/parity/scenarios/text.text-advanced/actual-svg.png b/crates/frame/render/tests/parity/scenarios/text.text-advanced/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/actual-svg.png diff --git a/tests/frame/parity/scenarios/text.text-advanced/actual.png b/crates/frame/render/tests/parity/scenarios/text.text-advanced/actual.png similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/actual.png rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/actual.png diff --git a/tests/frame/parity/scenarios/text.text-advanced/actual.svg b/crates/frame/render/tests/parity/scenarios/text.text-advanced/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/actual.svg rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/actual.svg diff --git a/tests/frame/parity/scenarios/text.text-advanced/expected.png b/crates/frame/render/tests/parity/scenarios/text.text-advanced/expected.png similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/expected.png rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/expected.png diff --git a/tests/frame/parity/scenarios/text.text-advanced/expected.svg b/crates/frame/render/tests/parity/scenarios/text.text-advanced/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/expected.svg rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/expected.svg diff --git a/tests/frame/parity/scenarios/text.text-advanced/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/text.text-advanced/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/text.text-advanced/report.json b/crates/frame/render/tests/parity/scenarios/text.text-advanced/report.json similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/report.json rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/report.json diff --git a/tests/frame/parity/scenarios/text.text-advanced/status.md b/crates/frame/render/tests/parity/scenarios/text.text-advanced/status.md similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/status.md rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/status.md diff --git a/tests/frame/parity/scenarios/text.text-advanced/svg-report.json b/crates/frame/render/tests/parity/scenarios/text.text-advanced/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/text.text-advanced/svg-report.json rename to crates/frame/render/tests/parity/scenarios/text.text-advanced/svg-report.json diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/README.md b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/README.md similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/README.md rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/README.md diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/actual-svg.png b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/actual-svg.png diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/actual.png b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/actual.png similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/actual.png rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/actual.png diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/actual.svg b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/actual.svg rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/actual.svg diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/expected.png b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/expected.png similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/expected.png rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/expected.png diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/expected.svg b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/expected.svg rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/expected.svg diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/report.json b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/report.json similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/report.json rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/report.json diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/status.md b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/status.md similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/status.md rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/status.md diff --git a/tests/frame/parity/scenarios/transform.nested-transform-stack/svg-report.json b/crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/transform.nested-transform-stack/svg-report.json rename to crates/frame/render/tests/parity/scenarios/transform.nested-transform-stack/svg-report.json diff --git a/tests/frame/parity/scenarios/transform.transform-precision/README.md b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/README.md similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/README.md rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/README.md diff --git a/tests/frame/parity/scenarios/transform.transform-precision/actual-svg.png b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/actual-svg.png similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/actual-svg.png rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/actual-svg.png diff --git a/tests/frame/parity/scenarios/transform.transform-precision/actual.png b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/actual.png similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/actual.png rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/actual.png diff --git a/tests/frame/parity/scenarios/transform.transform-precision/actual.svg b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/actual.svg similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/actual.svg rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/actual.svg diff --git a/tests/frame/parity/scenarios/transform.transform-precision/expected.png b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/expected.png similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/expected.png rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/expected.png diff --git a/tests/frame/parity/scenarios/transform.transform-precision/expected.svg b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/expected.svg similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/expected.svg rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/expected.svg diff --git a/tests/frame/parity/scenarios/transform.transform-precision/latest-bridge-export.json b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/latest-bridge-export.json similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/latest-bridge-export.json rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/latest-bridge-export.json diff --git a/tests/frame/parity/scenarios/transform.transform-precision/report.json b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/report.json similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/report.json rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/report.json diff --git a/tests/frame/parity/scenarios/transform.transform-precision/status.md b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/status.md similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/status.md rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/status.md diff --git a/tests/frame/parity/scenarios/transform.transform-precision/svg-report.json b/crates/frame/render/tests/parity/scenarios/transform.transform-precision/svg-report.json similarity index 100% rename from tests/frame/parity/scenarios/transform.transform-precision/svg-report.json rename to crates/frame/render/tests/parity/scenarios/transform.transform-precision/svg-report.json diff --git a/crates/frame/src/design/lint/mod.rs b/crates/frame/src/design/lint/mod.rs new file mode 100644 index 00000000..c72e0150 --- /dev/null +++ b/crates/frame/src/design/lint/mod.rs @@ -0,0 +1,771 @@ +//! Surface lint. +//! +//! Checks a scene (design + controls) against an engine catalog and returns +//! [`Issue`]s phrased so a model can fix them without further context: +//! which node, what's wrong, and what to do. The AI design loop is +//! *write → lint → fix* until clean. +//! +//! | rule | severity | checks | +//! |---|---|---| +//! | `binding` | error | control unbound, bound to an unknown address, or to the wrong kind of source (a plot bound to a missing feed or a param; a curve input or handle axis bound to a missing param) | +//! | `curve-input` | error | a curve lacks an input its provider needs (`comp.transfer` without `threshold`) | +//! | `curve-provider` | warning | a curve names a provider frame doesn't ship (only an engine that registers it draws it) | +//! | `handle-area` | error | a handle's parent (the area it moves in) has no size | +//! | `slot-dangling` | error | a slot names a node that isn't in the control's subtree | +//! | `slot-missing` | error | a control lacks the slots its kind needs to show its value | +//! | `midi-duplicate` | error | two controls listen to the same MIDI message | +//! | `overlap` | error | two input controls overlap (ambiguous touches); a declared concentric pair — one control nested in the other's node and box — is fine | +//! | `touch-target` | warning | an input control smaller than the minimum touch size | +//! | `size-inconsistent` | warning | same-kind controls in one group with different sizes | +//! | `contrast` | warning | text too close in luminance to what's behind it | +//! | `label-duplicate` | warning | two controls in one panel show the same label | +//! | `param-duplicate` | info | two controls bound to the same parameter | +//! | `unlabeled` | info | an input control with no label | +//! | `motion` | error / warning | a motion reads a param, feed or motion that doesn't exist (or a feed the wrong way), or doesn't parse; warns on a non-builtin shape provider or a moved `Group` | + +// Rule `motion`: node animations read things that exist. +mod motion; + +use std::collections::{BTreeMap, HashMap}; + +use crate::document::proto::{Color, Node, NodeId, PaintKind, Scene}; +use crate::render::LayoutBox; +use crate::surface::proto::{ + BUILTIN_CURVES, Catalog, Control, ControlKind, MidiMessage, SlotRole, Source, +}; +use facet::Facet; +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Facet, +)] +#[repr(u8)] +pub enum Severity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Issue { + pub severity: Severity, + pub rule: String, + pub node: NodeId, + /// The node's name, so messages read without a lookup. + pub node_name: String, + pub message: String, + /// What to do about it. + pub fix: String, +} + +impl std::fmt::Display for Issue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let sev = match self.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + }; + write!( + f, + "{sev}[{}] \"{}\" ({}): {} — fix: {}", + self.rule, self.node_name, self.node, self.message, self.fix + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Config { + /// Smallest input control edge, px. + pub min_touch: f64, + /// WCAG contrast ratio text must reach against its background. + pub min_contrast: f64, +} + +impl Default for Config { + fn default() -> Self { + Self { + min_touch: 44.0, + min_contrast: 4.5, + } + } +} + +/// Lint every control and text node under the scene's root. +#[must_use] +pub fn lint(scene: &Scene, catalog: &Catalog, config: &Config) -> Vec { + let boxes = crate::render::build_scene_layout_boxes(scene, scene.root); + let mut cx = Cx { + scene, + catalog, + config, + boxes: &boxes, + out: Vec::new(), + }; + let controls: Vec<(&Node, &Control)> = scene + .walk(scene.root) + .into_iter() + .filter_map(|id| scene.node(id)) + .filter_map(|n| n.control.as_ref().map(|c| (n, c))) + .collect(); + for (node, control) in &controls { + cx.binding(node, control); + cx.curve(node, control); + cx.handle(node, control); + cx.slots(node, control); + cx.touch(node, control); + cx.labeled(node, control); + } + cx.duplicates(&controls); + cx.labels(&controls); + cx.overlaps(&controls); + cx.sizes(&controls); + cx.contrast(); + motion::check(scene, catalog, &mut cx.out); + cx.out.sort_by(|a, b| { + b.severity + .cmp(&a.severity) + .then_with(|| a.rule.cmp(&b.rule)) + }); + cx.out +} + +/// `errors, warnings, infos`. +#[must_use] +pub fn counts(issues: &[Issue]) -> (usize, usize, usize) { + let n = |s| issues.iter().filter(|i| i.severity == s).count(); + (n(Severity::Error), n(Severity::Warning), n(Severity::Info)) +} + +struct Cx<'a> { + scene: &'a Scene, + catalog: &'a Catalog, + config: &'a Config, + boxes: &'a HashMap, + out: Vec, +} + +impl Cx<'_> { + fn push(&mut self, severity: Severity, rule: &str, node: &Node, message: String, fix: String) { + self.out.push(Issue { + severity, + rule: rule.into(), + node: node.id, + node_name: node.name.clone(), + message, + fix, + }); + } + + fn binding(&mut self, node: &Node, control: &Control) { + for issue in control.validate(self.catalog) { + let fix = match &issue { + crate::surface::proto::BindingIssue::Unbound => format!( + "bind it: set `binding.source` to a {} from the catalog", + if control.kind.is_input() { + "param or action" + } else { + "stream or param" + } + ), + crate::surface::proto::BindingIssue::UnknownParam { addr } => { + format!( + "use an existing address{}", + nearest( + &addr.0, + self.catalog.params.iter().map(|p| p.addr.0.as_str()) + ) + ) + } + crate::surface::proto::BindingIssue::UnknownAction { id } => { + format!( + "use an existing action{}", + nearest(&id.0, self.catalog.actions.iter().map(|a| a.id.0.as_str())) + ) + } + crate::surface::proto::BindingIssue::UnknownStream { id } => { + format!( + "use an existing stream{}", + nearest(&id.0, self.catalog.streams.iter().map(|s| s.id.0.as_str())) + ) + } + crate::surface::proto::BindingIssue::UnknownCollection { id } => format!( + "use an existing collection{}", + nearest( + &id.0, + self.catalog.collections.iter().map(|c| c.id.0.as_str()) + ) + ), + crate::surface::proto::BindingIssue::Mismatch { .. } => { + "change the control kind or bind a matching source".into() + } + }; + self.push(Severity::Error, "binding", node, issue.to_string(), fix); + } + } + + /// A curve names a provider frame knows (else only an engine that + /// registers it draws it) and binds every input that provider needs. + fn curve(&mut self, node: &Node, control: &Control) { + let ControlKind::Curve { + provider, inputs, .. + } = &control.kind + else { + return; + }; + let Some((_, required)) = BUILTIN_CURVES.iter().find(|(name, _)| name == provider) else { + let known: Vec<&str> = BUILTIN_CURVES.iter().map(|(n, _)| *n).collect(); + self.push( + Severity::Warning, + "curve-provider", + node, + format!("no builtin curve provider `{provider}`; it draws only where an engine registers one"), + format!("use a builtin{} or register `{provider}` with the runtime", nearest(provider, known.iter().copied())), + ); + return; + }; + let has = |name: &str| inputs.iter().any(|i| i.name == name); + let mut missing: Vec<&str> = required.iter().copied().filter(|r| !has(r)).collect(); + // An EQ needs at least one band. + if *provider == "eq.response" + && !inputs + .iter() + .any(|i| i.name.starts_with('b') && i.name.ends_with("_freq")) + { + missing.push("b1_freq"); + } + if !missing.is_empty() { + self.push( + Severity::Error, + "curve-input", + node, + format!("`{provider}` curve has no input `{}`", missing.join("`, `")), + format!( + "add {} to its `inputs`, bound to a param (or a constant)", + missing + .iter() + .map(|m| format!("`{m}`")) + .collect::>() + .join(", ") + ), + ); + } + } + + /// A handle moves inside its parent, so the parent needs a size. + fn handle(&mut self, node: &Node, control: &Control) { + if !matches!(control.kind, ControlKind::Handle { .. }) { + return; + } + let parent = node.parent.and_then(|p| self.scene.node(p)); + if parent.is_none_or(|p| p.size.width <= 0.0 || p.size.height <= 0.0) { + self.push( + Severity::Error, + "handle-area", + node, + "a handle moves within its parent, which has no size".into(), + "put the handle in the plot-area frame its axes describe".into(), + ); + } + } + + fn slots(&mut self, node: &Node, control: &Control) { + let subtree = self.scene.walk(node.id); + for slot in &control.slots { + let id = NodeId(slot.node); + if !subtree.contains(&id) || id == node.id && slot.role != SlotRole::Led { + self.push( + Severity::Error, + "slot-dangling", + node, + format!( + "{:?} slot points at {} which is not inside this control", + slot.role, id + ), + "point the slot at a descendant of the control, or remove it".into(), + ); + } + } + let has = |r: SlotRole| control.slots.iter().any(|s| s.role == r); + let needs: &[(&[SlotRole], &str)] = match control.kind { + ControlKind::Knob { .. } => &[( + &[SlotRole::Needle, SlotRole::ValueArc], + "a Needle or a ValueArc", + )], + ControlKind::Fader { .. } => { + &[(&[SlotRole::Fill, SlotRole::Thumb], "a Fill or a Thumb")] + } + ControlKind::Meter { .. } => &[( + &[SlotRole::Fill, SlotRole::Thumb, SlotRole::Led], + "a Fill, a Thumb or Led lamps", + )], + ControlKind::Gauge { .. } => &[( + &[ + SlotRole::Needle, + SlotRole::Fill, + SlotRole::Thumb, + SlotRole::Led, + SlotRole::Glow, + ], + "a Needle (or a Fill, a Thumb, Led lamps, a Glow)", + )], + ControlKind::Readout => &[( + &[ + SlotRole::Value, + SlotRole::Glow, + SlotRole::On, + SlotRole::Off, + SlotRole::Tint, + ], + "a Value text, a Glow, On/Off or Tint nodes", + )], + ControlKind::TextField { .. } => &[(&[SlotRole::Value], "a Value text")], + ControlKind::Plot { .. } => &[( + &[SlotRole::Trace, SlotRole::Area], + "a Trace (or an Area) vector", + )], + ControlKind::Curve { .. } => &[( + &[SlotRole::Trace, SlotRole::Area, SlotRole::Marker], + "a Trace, an Area or a Marker", + )], + ControlKind::Button { .. } + | ControlKind::Footswitch { .. } + | ControlKind::XyPad + | ControlKind::Selector + | ControlKind::Handle { .. } + | ControlKind::Repeat { .. } + | ControlKind::Canvas { .. } + | ControlKind::Popover { .. } + | ControlKind::Menu { .. } => &[], + }; + for (any_of, what) in needs { + if !any_of.iter().any(|r| has(*r)) { + self.push( + Severity::Error, + "slot-missing", + node, + format!("{} has no slot to show its value", control.kind.name()), + format!("add {what} slot pointing at a child node"), + ); + } + } + } + + /// Inside a popover or menu: it floats over the surface, drawn where + /// its anchor puts it, so its authored box says nothing about overlap. + fn floating(&self, node: &Node) -> bool { + let mut cur = Some(node.id); + while let Some(id) = cur { + let Some(n) = self.scene.node(id) else { + return false; + }; + if n.control.as_ref().is_some_and(|c| { + matches!( + c.kind, + ControlKind::Popover { .. } + | ControlKind::Menu { .. } + | ControlKind::Repeat { .. } + ) + }) { + return true; + } + cur = n.parent; + } + false + } + + fn touch(&mut self, node: &Node, control: &Control) { + if !control.kind.is_input() + || control.kind.is_container() + || matches!(control.kind, ControlKind::TextField { .. }) + { + return; + } + let Some(b) = self.boxes.get(&node.id) else { + return; + }; + let min = self.config.min_touch; + if b.width < min || b.height < min { + self.push( + Severity::Warning, + "touch-target", + node, + format!( + "{}×{} px is below the {min} px touch minimum", + b.width.round(), + b.height.round() + ), + format!("make it at least {min}×{min} (stage use: larger)"), + ); + } + } + + fn labeled(&mut self, node: &Node, control: &Control) { + if control.kind.is_input() + && !control.kind.is_container() + && !control.slots.iter().any(|s| s.role == SlotRole::Label) + { + let has_text_child = self + .scene + .walk(node.id) + .iter() + .any(|id| self.scene.node(*id).is_some_and(|n| n.text().is_some())); + if !has_text_child { + self.push( + Severity::Info, + "unlabeled", + node, + "no label".into(), + "add a Text child with a Label slot".into(), + ); + } + } + } + + fn duplicates(&mut self, controls: &[(&Node, &Control)]) { + let mut midi: BTreeMap<(Option, u8, u8), &Node> = BTreeMap::new(); + let mut params: BTreeMap<&str, &Node> = BTreeMap::new(); + let mut found = Vec::new(); + for (node, c) in controls { + if let Some(m) = &c.midi { + let key = match m.message { + MidiMessage::Cc { number } => (m.channel, 0, number), + MidiMessage::Note { number } => (m.channel, 1, number), + MidiMessage::PitchBend => (m.channel, 2, 0), + MidiMessage::Aftertouch => (m.channel, 3, 0), + }; + if let Some(first) = midi.insert(key, node) { + found.push(( + Severity::Error, + "midi-duplicate", + *node, + format!("same MIDI message as \"{}\"", first.name), + "give one of them a different CC/note or channel".to_string(), + )); + } + } + if let Some(b) = &c.binding + && let Source::Param { addr } = &b.source + && let Some(first) = params.insert(addr.0.as_str(), node) + { + found.push(( + Severity::Info, + "param-duplicate", + *node, + format!("`{}` is also controlled by \"{}\"", addr.0, first.name), + "fine for mirrored views; otherwise bind a different parameter".to_string(), + )); + } + } + for (s, r, n, m, f) in found { + self.push(s, r, n, m, f); + } + } + + /// Two controls showing the same label on one surface can't be told apart. + fn labels(&mut self, controls: &[(&Node, &Control)]) { + // Scope: the nearest filled ancestor (a panel) — the same label in + // two titled panels is clear from context. + let panel = |n: &Node| { + let mut cur = n.parent; + while let Some(id) = cur { + let Some(p) = self.scene.node(id) else { break }; + if p.fills.iter().any(|f| f.visible) { + return Some(id); + } + cur = p.parent; + } + None + }; + let mut seen: BTreeMap<(Option, String), &Node> = BTreeMap::new(); + let mut found = Vec::new(); + for (node, c) in controls { + let Some(label) = self.label_text(node, c) else { + continue; + }; + if let Some(first) = seen.insert((panel(node), label.to_lowercase()), node) { + found.push(( + *node, + format!("shows the label \"{label}\", like \"{}\"", first.name), + )); + } + } + for (n, m) in found { + self.push( + Severity::Warning, + "label-duplicate", + n, + m, + "give one a distinct label (e.g. prefix its group: \"Delay Mix\")".into(), + ); + } + } + + /// What the control's label will read: authored text, else the bound + /// parameter/action name. + fn label_text(&self, node: &Node, c: &Control) -> Option { + let authored = c + .slots_of(SlotRole::Label) + .filter_map(|id| { + self.scene + .node(NodeId(id))? + .text() + .map(|t| t.characters.clone()) + }) + .find(|t| !t.is_empty() && !t.starts_with('{')); + authored.or_else(|| match c.binding.as_ref().map(|b| &b.source) { + Some(Source::Param { addr }) => self + .catalog + .param(addr) + .map(|p| p.short_name.clone().unwrap_or_else(|| p.name.clone())), + Some(Source::Action { id }) => self.catalog.action(id).map(|a| a.name.clone()), + _ => { + let _ = node; + None + } + }) + } + + /// Whether `inner` is a control nested in control `outer`'s node and + /// drawn wholly inside its box: a declared concentric pair (a cap on a + /// collar). Touches inside the inner box go to it (it paints later), + /// the rest of the outer box to the outer. + fn nested(&self, (outer, ob): (&Node, &LayoutBox), (inner, ib): (&Node, &LayoutBox)) -> bool { + const EPS: f64 = 0.5; + let mut up = inner.parent; + while let Some(p) = up { + if p == outer.id { + return ib.x >= ob.x - EPS + && ib.y >= ob.y - EPS + && ib.x + ib.width <= ob.x + ob.width + EPS + && ib.y + ib.height <= ob.y + ob.height + EPS; + } + up = self.scene.node(p).and_then(|n| n.parent); + } + false + } + + /// Every `show_if` a node is shown under: its own control's and each + /// ancestor control's (a colourway layer hides everything on it). + fn shown_under<'n>(&'n self, node: &'n Node) -> Vec<&'n crate::surface::proto::Condition> { + let mut out = Vec::new(); + let mut cur = Some(node); + while let Some(n) = cur { + if let Some(c) = n.control.as_ref().and_then(|c| c.show_if.as_ref()) { + out.push(c); + } + cur = n.parent.and_then(|p| self.scene.node(p)); + } + out + } + + /// Whether two nodes can never be shown together: each is shown only + /// for a set of values of the same param, and the sets don't meet. + fn exclusive(&self, a: &Node, b: &Node) -> bool { + let (ca, cb) = (self.shown_under(a), self.shown_under(b)); + ca.iter().any(|x| { + cb.iter().any(|y| { + x.addr == y.addr + && !x.not + && !y.not + && !x.any_of.is_empty() + && !y.any_of.is_empty() + && x.any_of + .iter() + .all(|v| !y.any_of.iter().any(|w| (v - w).abs() < 1e-9)) + }) + }) + } + + fn overlaps(&mut self, controls: &[(&Node, &Control)]) { + // Handles move with their values (and sit on the plots they drag), + // so where their boxes are authored says nothing. + let inputs: Vec<(&Node, LayoutBox)> = controls + .iter() + .filter(|(n, c)| { + c.kind.is_input() + && !matches!( + c.kind, + ControlKind::Handle { .. } | ControlKind::Canvas { .. } + ) + && !self.floating(n) + }) + .filter_map(|(n, _)| self.boxes.get(&n.id).map(|b| (*n, *b))) + .collect(); + let mut found = Vec::new(); + for (i, (a, ab)) in inputs.iter().enumerate() { + for (b, bb) in &inputs[i + 1..] { + if self.nested((a, ab), (b, bb)) || self.nested((b, bb), (a, ab)) { + continue; + } + // Colourway / variant layers: never shown at the same time. + if self.exclusive(a, b) { + continue; + } + let ix = (ab.x + ab.width).min(bb.x + bb.width) - ab.x.max(bb.x); + let iy = (ab.y + ab.height).min(bb.y + bb.height) - ab.y.max(bb.y); + if ix > 0.5 && iy > 0.5 { + found.push(( + *b, + format!( + "overlaps \"{}\" by {}×{} px", + a.name, + ix.round(), + iy.round() + ), + )); + } + } + } + for (n, m) in found { + self.push( + Severity::Error, + "overlap", + n, + m, + "move it apart, or put both in an auto-layout container".into(), + ); + } + } + + fn sizes(&mut self, controls: &[(&Node, &Control)]) { + let mut groups: BTreeMap<(Option, &str), Vec<&Node>> = BTreeMap::new(); + for (n, c) in controls { + groups.entry((n.parent, c.kind.name())).or_default().push(n); + } + let mut found = Vec::new(); + for ((_, kind), nodes) in groups { + let Some(first) = nodes.first() else { continue }; + for n in &nodes[1..] { + if (n.size.width - first.size.width).abs() > 0.5 + || (n.size.height - first.size.height).abs() > 0.5 + { + found.push(( + *n, + format!( + "{kind} is {}×{} but its sibling \"{}\" is {}×{}", + n.size.width, + n.size.height, + first.name, + first.size.width, + first.size.height + ), + )); + } + } + } + for (n, m) in found { + self.push( + Severity::Warning, + "size-inconsistent", + n, + m, + "use the same size for same-kind controls in a group".into(), + ); + } + } + + fn contrast(&mut self) { + let mut found = Vec::new(); + for id in self.scene.walk(self.scene.root) { + let Some(node) = self.scene.node(id) else { + continue; + }; + if node.text().is_none_or(|t| t.characters.trim().is_empty()) || !node.visible { + continue; + } + let Some(fg) = solid(node) else { continue }; + let Some(bg) = self.background_of(node) else { + continue; + }; + let ratio = contrast_ratio(&fg, &bg); + if ratio < self.config.min_contrast { + found.push((node, ratio)); + } + } + for (node, ratio) in found { + let min = self.config.min_contrast; + self.push( + Severity::Warning, + "contrast", + node, + format!("text contrast {ratio:.1}:1 against its background (needs {min}:1)"), + "lighten the text or darken the background (or vice versa)".into(), + ); + } + } + + /// The nearest ancestor with an opaque solid fill. + fn background_of(&self, node: &Node) -> Option { + let mut cur = node.parent; + while let Some(id) = cur { + let n = self.scene.node(id)?; + if let Some(c) = solid(n).filter(|c| c.a > 0.99) { + return Some(c); + } + cur = n.parent; + } + None + } +} + +fn solid(n: &Node) -> Option { + n.fills + .iter() + .rev() + .filter(|p| p.visible) + .find_map(|p| match &p.kind { + PaintKind::Solid { color } => Some(Color { + a: color.a * p.opacity, + ..*color + }), + _ => None, + }) +} + +fn luminance(c: &Color) -> f64 { + let ch = |v: f64| { + let v = v.clamp(0.0, 1.0); + if v <= 0.039_28 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * ch(c.r) + 0.7152 * ch(c.g) + 0.0722 * ch(c.b) +} + +/// WCAG 2 contrast ratio (1..=21). +#[must_use] +pub fn contrast_ratio(a: &Color, b: &Color) -> f64 { + let (la, lb) = (luminance(a), luminance(b)); + let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +/// `" — did you mean `x`?"` for the closest candidate, if any is close. +fn nearest<'a>(wanted: &str, candidates: impl Iterator) -> String { + let best = candidates + .map(|c| (edit_distance(wanted, c), c)) + .min_by_key(|(d, _)| *d); + match best { + Some((d, c)) if d <= wanted.len().max(3) / 2 + 1 => format!(" — did you mean `{c}`?"), + _ => String::new(), + } +} + +fn edit_distance(a: &str, b: &str) -> usize { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut cur = vec![i + 1]; + for (j, cb) in b.iter().enumerate() { + cur.push( + (prev[j] + usize::from(ca != cb)) + .min(prev[j + 1] + 1) + .min(cur[j] + 1), + ); + } + prev = cur; + } + prev[b.len()] +} diff --git a/crates/frame/src/design/lint/motion.rs b/crates/frame/src/design/lint/motion.rs new file mode 100644 index 00000000..e902a4ee --- /dev/null +++ b/crates/frame/src/design/lint/motion.rs @@ -0,0 +1,160 @@ +//! Rule `motion`: every node's motions read things that exist. +//! +//! - error: an expression that doesn't parse, a param or feed the catalog +//! doesn't have, a feed read the wrong way (`level()` of a scalar), +//! `motion(name)` naming no motion, a morph with fewer than two shapes, +//! a colour that isn't one, keyframes out of order; +//! - warning: a shape provider that isn't built in (fine only if the host +//! registers it), a geometric motion on a `Group` (a group's transform +//! doesn't move its children — animate a frame). + +use std::collections::HashSet; + +use crate::document::proto::motion::{FeedRead, Source, Target, expr}; +use crate::document::proto::{NodeKind, Scene}; +use crate::surface::proto::{Catalog, EntryId, FeedKind, ParamAddr}; + +use super::{Issue, Severity, nearest}; + +const RULE: &str = "motion"; + +pub(crate) fn check(scene: &Scene, catalog: &Catalog, out: &mut Vec) { + let names: HashSet<&str> = scene + .nodes + .values() + .flat_map(|n| n.motion.iter().filter_map(|m| m.name.as_deref())) + .collect(); + for id in scene.walk(scene.root) { + let Some(node) = scene.node(id) else { continue }; + for (i, m) in node.motion.iter().enumerate() { + let mut push = |severity, message: String, fix: String| { + out.push(Issue { + severity, + rule: RULE.into(), + node: node.id, + node_name: node.name.clone(), + message: format!("motion[{i}]: {message}"), + fix, + }); + }; + for p in m.problems() { + push( + Severity::Error, + p, + "correct it (see the motion reference: expressions, targets, dynamics)".into(), + ); + } + for (at, src) in m.expressions() { + let Ok(e) = expr::parse(src) else { continue }; + for s in e.sources() { + if let Some((message, fix)) = source_issue(s, catalog) { + push(Severity::Error, format!("{at} `{src}`: {message}"), fix); + } + } + for name in e.motions() { + if !names.contains(name) { + push( + Severity::Error, + format!("{at} `{src}` reads motion `{name}`, which no node has"), + format!( + "name a motion `{name}` (\"name\":\"{name}\"), or read an existing one{}", + nearest(name, names.iter().copied()) + ), + ); + } + } + } + if let Target::Shape { provider, .. } = &m.target + && !crate::motion::runtime::shapes::BUILTIN.contains(&provider.as_str()) + { + push( + Severity::Warning, + format!( + "shape provider `{provider}` isn't built in; it draws nothing unless the host registers it" + ), + format!( + "use a builtin ({}){}", + crate::motion::runtime::shapes::BUILTIN.join(", "), + nearest( + provider, + crate::motion::runtime::shapes::BUILTIN.iter().copied() + ) + ), + ); + } + if geometric(&m.target) && matches!(node.kind, NodeKind::Group) { + push( + Severity::Warning, + "moves a Group, whose transform doesn't carry its children".into(), + "put the parts in a Frame and animate that".into(), + ); + } + } + } +} + +fn geometric(t: &Target) -> bool { + matches!( + t, + Target::X + | Target::Y + | Target::Rotate + | Target::Scale + | Target::StretchX + | Target::StretchY + | Target::SkewX + | Target::SkewY + ) +} + +fn source_issue(s: &Source, catalog: &Catalog) -> Option<(String, String)> { + match s { + Source::Param { addr, .. } => { + catalog + .param(&ParamAddr::new(addr.clone())) + .is_none() + .then(|| { + ( + format!("no param `{addr}` in the catalog"), + format!( + "use an existing address{}", + nearest(addr, catalog.params.iter().map(|p| p.addr.0.as_str())) + ), + ) + }) + } + Source::Feed { id, read } => { + let Some(info) = catalog.stream(&EntryId::new(id.clone())) else { + return Some(( + format!("no feed `{id}` in the catalog"), + format!( + "use an existing feed{}", + nearest(id, catalog.streams.iter().map(|s| s.id.0.as_str())) + ), + )); + }; + let ok = match read { + FeedRead::Raw(i) => (*i as usize) < info.kind.frame_len().max(1), + FeedRead::Level(ch) => { + matches!(info.kind, FeedKind::Level { channels } if ch.is_none_or(|c| c < channels.max(1))) + } + FeedRead::Gr => matches!(info.kind, FeedKind::GainReduction), + FeedRead::Scalar => matches!(info.kind, FeedKind::Scalar { .. }), + FeedRead::Band(..) => matches!(info.kind, FeedKind::Spectrum { .. }), + }; + (!ok).then(|| { + let how = match read { + FeedRead::Raw(i) => format!("feed({id}, {i})"), + FeedRead::Level(_) => format!("level({id})"), + FeedRead::Gr => format!("gr({id})"), + FeedRead::Scalar => format!("scalar({id})"), + FeedRead::Band(..) => format!("band({id}, …)"), + }; + ( + format!("`{how}` doesn't fit feed `{id}` ({:?})", info.kind), + "read it by its kind: level() for levels, gr() for gain reduction, scalar() for scalars, band() for spectra, feed(id, i) for a raw value".into(), + ) + }) + } + } +} diff --git a/crates/frame/src/design/mod.rs b/crates/frame/src/design/mod.rs new file mode 100644 index 00000000..900ab5e3 --- /dev/null +++ b/crates/frame/src/design/mod.rs @@ -0,0 +1,7 @@ +//! Design: linting a document and the editing session. + +#[cfg(feature = "design")] +pub mod lint; + +#[cfg(feature = "design")] +pub mod session; diff --git a/crates/frame/src/design/session/animate.rs b/crates/frame/src/design/session/animate.rs new file mode 100644 index 00000000..a639c9de --- /dev/null +++ b/crates/frame/src/design/session/animate.rs @@ -0,0 +1,163 @@ +//! The `animate` tool: add, replace, remove or list a node's motions. + +use crate::design::lint::Severity; +use crate::document::proto::motion::{Motion, Target}; +use crate::document::proto::{NodeId, Op, Prop}; +use crate::surface::kit::motion as kit; +use serde_json::{Value, json}; + +use super::{Args, Result, Session, ToolDef, ToolError, err}; + +pub(crate) fn def() -> ToolDef { + let node = json!({"type": "string", "description": "node id (uuid) or unique node name"}); + ToolDef { + name: "animate", + description: "Animate a node: each motion drives one property (target) from an input expression through physical dynamics. \ +target: none | x | y | rotate | scale | stretch_x | stretch_y | skew_x | skew_y | opacity | emission | dash_offset | {\"fill\":{\"colors\":[…]}} | {\"stroke\":{\"colors\":[…]}} | {\"morph\":{\"shapes\":[path…]}} | {\"shape\":{\"provider\":\"coil|reverb.room|wave\",\"part\":…,\"inputs\":{name: number|expr}}}. \ +input (expression): norm(param) | param(param) | level(feed[,ch]) | gr(feed) | scalar(feed) | band(feed,lo,hi) | feed(feed,i) | motion(name) | time | hover | press, with + - * / and clamp min max lerp smoothstep curve or…. \ +dynamics: \"follow\" | {\"spring\":{stiffness,damping,mass}} | {\"oscillator\":{freq,decay|damping,excite:expr,gain}} (a struck resonator: rings when excite rises, dies over decay s) | {\"lfo\":{rate,shape,phase}} | {\"noise\":{rate,amount}} | {\"ballistic\":{attack,release}} | {\"keyframes\":{times,values,easing,loop}} | \"spin\" (input = turns/s); any number may be an expression. \ +from/to map output 0/1 onto the target (degrees, px, 0..1); lag: add only the dynamics' overshoot to a part a control already moves; pivot {x,y} in the node's box; name: for motion(name). \ +Or a preset: spring_follow {param} | ring_on {feed, freq, decay} | spin {speed} | flicker {amount} | pulse {rate, low} — with optional target/from/to overrides. \ +Pass `add` (a motion or list), or `set` (replace all), or `remove` (an index), or `clear`; with none of them, lists the node's motions.", + input_schema: json!({ + "type": "object", + "properties": { + "node": node, + "add": {"description": "a motion object, or a list of them, to append"}, + "set": {"type": "array", "description": "replace every motion on the node"}, + "remove": {"type": "integer", "minimum": 0}, + "clear": {"type": "boolean"}, + "preset": {"type": "string", "enum": ["spring_follow", "ring_on", "spin", "flicker", "pulse"]}, + "args": {"type": "object", "description": "preset arguments: param | feed, freq, decay | speed | amount | rate, low"}, + "target": {"description": "preset: a target to use instead of its default"}, + "from": {"type": "number"}, + "to": {"type": "number"} + }, + "required": ["node"] + }), + mutates: true, + } +} + +fn motions(v: &Value) -> Result> { + let list = match v { + Value::Array(_) => v.clone(), + other => Value::Array(vec![other.clone()]), + }; + serde_json::from_value(list).map_err(|e| ToolError(format!("not a motion: {e}"))) +} + +fn preset(a: &Args<'_>) -> Result> { + let Some(name) = a.opt_str("preset")? else { + return Ok(None); + }; + let args = a.0.get("args").cloned().unwrap_or(Value::Null); + let s = |k: &str| -> Result { + match args.get(k) { + Some(Value::String(s)) => Ok(s.clone()), + Some(Value::Number(n)) => Ok(n.to_string()), + _ => err(format!("preset `{name}` needs args.{k}")), + } + }; + let num = |k: &str, default: f64| -> crate::document::proto::motion::Num { + match args.get(k) { + Some(Value::Number(n)) => n.as_f64().unwrap_or(default).into(), + Some(Value::String(s)) => s.as_str().into(), + _ => default.into(), + } + }; + let mut m = match name { + "spring_follow" => kit::spring_follow(&s("param")?), + "ring_on" => kit::ring_on(&s("feed")?, num("freq", 4.0), num("decay", 1.5)), + "spin" => kit::spin(&s("speed")?), + "flicker" => kit::flicker(num("amount", 0.1)), + "pulse" => kit::pulse( + num("rate", 1.0), + args.get("low").and_then(Value::as_f64).unwrap_or(0.2), + ), + other => { + return err(format!( + "unknown preset `{other}`; presets: spring_follow, ring_on, spin, flicker, pulse" + )); + } + }; + if let Some(t) = a.0.get("target") { + m.target = serde_json::from_value::(t.clone()) + .map_err(|e| ToolError(format!("target: {e}")))?; + } + if let Some(f) = a.0.get("from").and_then(Value::as_f64) { + m.from = f; + } + if let Some(t) = a.0.get("to").and_then(Value::as_f64) { + m.to = t; + } + Ok(Some(m)) +} + +impl Session { + pub(crate) fn animate(&mut self, node: NodeId, a: &Args<'_>) -> Result { + let current = self + .scene + .node(node) + .map(|n| n.motion.clone()) + .unwrap_or_default(); + let next = if let Some(v) = a.0.get("set") { + Some(motions(v)?) + } else if a.0.get("clear").and_then(Value::as_bool) == Some(true) { + Some(Vec::new()) + } else if let Some(i) = a.opt_u32("remove")? { + let i = i as usize; + if i >= current.len() { + return err(format!( + "the node has {} motion(s); no index {i}", + current.len() + )); + } + let mut v = current.clone(); + v.remove(i); + Some(v) + } else { + let mut added = match a.0.get("add") { + Some(v) => motions(v)?, + None => Vec::new(), + }; + added.extend(preset(a)?); + (!added.is_empty()).then(|| current.iter().cloned().chain(added).collect()) + }; + if let Some(v) = next { + for (i, m) in v.iter().enumerate() { + if let Some(p) = m.problems().into_iter().next() { + return err(format!("motion[{i}]: {p}")); + } + } + self.commit(vec![Op::Set { + id: node, + prop: Prop::Motion(v), + }])?; + } + let now = self + .scene + .node(node) + .map(|n| n.motion.clone()) + .unwrap_or_default(); + let mut out = format!("{} motion(s) on {node}:\n", now.len()); + for (i, m) in now.iter().enumerate() { + out.push_str(&format!( + " [{i}] {}\n", + serde_json::to_string(m).unwrap_or_default() + )); + } + let issues: Vec = self + .issues() + .into_iter() + .filter(|i| i.rule == "motion" && i.node == node && i.severity >= Severity::Warning) + .map(|i| i.to_string()) + .collect(); + if issues.is_empty() { + out.push_str("motion lint: clean"); + } else { + out.push_str(&format!("motion lint:\n {}", issues.join("\n "))); + } + Ok(out) + } +} diff --git a/crates/frame/src/design/session/mod.rs b/crates/frame/src/design/session/mod.rs new file mode 100644 index 00000000..2e0dd9ec --- /dev/null +++ b/crates/frame/src/design/session/mod.rs @@ -0,0 +1,1238 @@ +//! A design session: one document, one catalog, one undo history, and one +//! tool registry. +//! +//! Every AI surface — MCP, in-app chat, the CLI — lists [`tools`] and +//! dispatches [`Session::call`]. Each call is a single atomic transaction: +//! it applies fully or not at all, and one `undo` reverts it. Tools speak +//! frame markup (see `document/text/REFERENCE.md`) and return text meant +//! for a model to read: what changed, ids to refer to, and lint results. + +use std::collections::BTreeMap; + +// The `animate` tool: motions on nodes. +mod animate; + +use crate::design::lint::{Config, Issue, Severity}; +use crate::document::proto::{History, Node, NodeId, NodeKind, Op, OpError, Prop, Scene}; +use crate::surface::proto::{ + Binding, ButtonMode, Catalog, Control, ControlKind, EntryId, Orientation, ParamKind, Slot, + SlotRole, Source, +}; +use crate::surface::widgets::{Builder, ControlSize, Options, Theme, surface_for_catalog}; +use serde_json::{Value, json}; + +/// A tool as adapters advertise it. +#[derive(Debug, Clone, PartialEq)] +pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + /// JSON Schema for the arguments object. + pub input_schema: Value, + /// Whether the tool edits the document. + pub mutates: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolError(pub String); + +impl std::fmt::Display for ToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ToolError {} + +impl From for ToolError { + fn from(e: OpError) -> Self { + Self(e.to_string()) + } +} + +type Result = std::result::Result; + +fn err(msg: impl Into) -> Result { + Err(ToolError(msg.into())) +} + +pub struct Session { + scene: Scene, + catalog: Catalog, + theme: Theme, + history: History, + lint: Config, +} + +impl Session { + #[must_use] + pub fn new(scene: Scene, catalog: Catalog) -> Self { + Self { + scene, + catalog, + theme: Theme::default(), + history: History::default(), + lint: Config::default(), + } + } + + /// An empty page, ready to design against `catalog`. + #[must_use] + pub fn blank(catalog: Catalog) -> Self { + Self::new(Scene::new("Surface"), catalog) + } + + /// Edit the surface at `path` (`.frame` with its `.frame.assets/` textures + /// and fonts, a legacy `.fm`, or a `.frame` bundle). + /// + /// # Errors + /// See [`crate::document::io::load_surface`]. + pub fn open( + path: impl AsRef, + catalog: Catalog, + ) -> std::result::Result { + Ok(Self::new(crate::document::io::load_surface(path)?, catalog)) + } + + /// Write the document to `path`, assets and all (see + /// [`crate::document::io::save_surface`]). + /// + /// # Errors + /// I/O, or an extension that isn't `.frame` / `.fm`. + pub fn save( + &self, + path: impl AsRef, + ) -> std::result::Result<(), crate::document::io::Error> { + crate::document::io::save_surface(path, &self.scene) + } + + #[must_use] + pub fn scene(&self) -> &Scene { + &self.scene + } + + #[must_use] + pub fn catalog(&self) -> &Catalog { + &self.catalog + } + + /// Design against a different catalog (e.g. a live engine's, re-fetched). + /// The document is untouched; bindings the new catalog lacks show up + /// in [`issues`](Self::issues). + pub fn set_catalog(&mut self, catalog: Catalog) { + self.catalog = catalog; + } + + #[must_use] + pub fn issues(&self) -> Vec { + crate::design::lint::lint(&self.scene, &self.catalog, &self.lint) + } + + /// Run a tool by name with a JSON arguments object. + /// + /// # Errors + /// A message for the model: unknown tool, bad arguments, parse errors + /// (with line:col), or ops that don't fit the document. + pub fn call(&mut self, name: &str, args: &Value) -> Result { + let a = Args(args); + match name { + "describe" => self.describe(a.opt_node(&self.scene, "node")?), + "read" => self.read(a.opt_node(&self.scene, "node")?), + "catalog" => Ok(self.catalog_text()), + "lint" => Ok(self.lint_text()), + "write" => { + let parent = a.node(&self.scene, "parent")?; + self.write(parent, a.str("markup")?, a.opt_u32("index")?) + } + "replace" => { + let node = a.node(&self.scene, "node")?; + self.replace(node, a.str("markup")?) + } + "remove" => { + let node = a.node(&self.scene, "node")?; + self.commit(vec![Op::Remove { id: node }]) + .map(|()| format!("removed {node}")) + } + "move" => { + let node = a.node(&self.scene, "node")?; + let parent = a.node(&self.scene, "parent")?; + let index = a.opt_u32("index")?.unwrap_or(u32::MAX); + self.commit(vec![Op::Move { + id: node, + parent, + index, + }]) + .map(|()| format!("moved {node}")) + } + "add_control" => { + let parent = a.node(&self.scene, "parent")?; + self.add_control( + parent, + a.str("kind")?, + a.opt_str("bind")?, + a.opt_str("size")?, + a.opt_str("name")?, + ) + } + "components" => Ok(components_text(a.opt_str("family")?)), + "fonts" => Ok(fonts_text()), + "add_component" => { + let parent = a.node(&self.scene, "parent")?; + self.add_component(parent, &a) + } + "bind" => { + let node = a.node(&self.scene, "node")?; + self.bind( + node, + a.opt_str("source")?, + a.opt_str("kind")?, + a.0.get("slots"), + ) + } + "generate" => self.generate(a.opt_str("theme")?), + "animate" => { + let node = a.node(&self.scene, "node")?; + self.animate(node, &a) + } + "set_theme" => { + self.theme = parse_theme(a.str("theme")?)?; + Ok(format!( + "theme is now {} (applies to controls added from here on)", + self.theme.name + )) + } + "undo" => Ok(if self.history.undo(&mut self.scene)? { + "undone".into() + } else { + "nothing to undo".into() + }), + "redo" => Ok(if self.history.redo(&mut self.scene)? { + "redone".into() + } else { + "nothing to redo".into() + }), + other => err(format!( + "unknown tool `{other}`; tools: {}", + tools() + .iter() + .map(|t| t.name) + .collect::>() + .join(", ") + )), + } + } + + /// Apply `ops` as one undoable step, re-hugging containers in the + /// same transaction. + fn commit(&mut self, mut ops: Vec) -> Result<()> { + let mut probe = self.scene.clone(); + probe.transact(ops.clone())?; + ops.extend(probe.hug_ops(probe.root)); + Ok(self.history.commit(&mut self.scene, ops)?) + } + + fn page(&self) -> NodeId { + self.scene + .pages() + .first() + .copied() + .unwrap_or(self.scene.root) + } + + // ── reading ────────────────────────────────────────────────────────── + + fn describe(&self, node: Option) -> Result { + let root = node.unwrap_or(self.scene.root); + let mut out = String::new(); + let mut stack = vec![(root, 0usize)]; + while let Some((id, depth)) = stack.pop() { + let Some(n) = self.scene.node(id) else { + continue; + }; + out.push_str(&" ".repeat(depth)); + out.push_str(&outline_line(n)); + out.push('\n'); + for c in n.children.iter().rev() { + stack.push((*c, depth + 1)); + } + } + let issues = self.issues(); + let (e, w, i) = crate::design::lint::counts(&issues); + out.push_str(&format!( + "\nlint: {e} errors, {w} warnings, {i} notes (run `lint` for details)\n" + )); + Ok(out) + } + + fn read(&self, node: Option) -> Result { + match node { + None => Ok(crate::document::text::print(&self.scene)), + Some(id) => crate::document::text::print_subtree(&self.scene, id) + .ok_or_else(|| ToolError(format!("no node {id}"))), + } + } + + fn catalog_text(&self) -> String { + let c = &self.catalog; + let mut out = format!( + "engine `{}` v{}\n\nparams (bind as param:; ★ = featured, what a performer reaches for):\n", + c.id, c.version + ); + for (group, params) in c.groups() { + out.push_str(&format!(" [{}]\n", group.unwrap_or("Other"))); + for p in params { + let kind = match &p.kind { + ParamKind::Continuous => format!( + "{}..{} {}{}", + p.range.format(p.range.min), + p.range.format(p.range.max), + if p.bipolar { "bipolar " } else { "" }, + match p.range.scale { + crate::surface::proto::Scale::Logarithmic => "log", + _ => "", + } + ), + ParamKind::Toggle => "toggle".into(), + ParamKind::Text => "text (bind a text-field)".into(), + ParamKind::Choice { options } => format!("choice: {}", options.join(" | ")), + }; + let star = if p.featured { "★ " } else { " " }; + out.push_str(&format!( + " {star}{:<18} {:<12} {} default {}\n", + p.addr.0, + p.name, + kind.trim(), + p.display(p.default) + )); + } + } + out.push_str("\nactions (bind as action:):\n"); + for a in &c.actions { + out.push_str(&format!(" {:<18} {}\n", a.id.0, a.name)); + } + out.push_str("\nstreams (bind as stream:[:channel]):\n"); + for s in &c.streams { + out.push_str(&format!( + " {:<18} {} ({:?}, {} Hz)\n", + s.id.0, s.name, s.kind, s.rate_hz + )); + } + out + } + + fn lint_text(&self) -> String { + let issues = self.issues(); + if issues.is_empty() { + return "clean: no issues".into(); + } + let (e, w, i) = crate::design::lint::counts(&issues); + let mut out = format!("{e} errors, {w} warnings, {i} notes\n"); + for issue in issues + .iter() + .filter(|i| i.severity != Severity::Info) + .chain( + issues + .iter() + .filter(|i| i.severity == Severity::Info) + .take(10), + ) + { + out.push_str(&format!("{issue}\n")); + } + out + } + + // ── writing ────────────────────────────────────────────────────────── + + fn write(&mut self, parent: NodeId, markup: &str, index: Option) -> Result { + let nodes = crate::document::text::parse_fragment(markup) + .map_err(|e| ToolError(format!("markup {e}")))?; + let ops = insert_ops(&self.scene, parent, index, nodes)?; + let created: Vec = ops + .iter() + .filter_map(|o| match o { + Op::Insert { + node, parent: p, .. + } if *p == parent => Some(format!("{} \"{}\"", node.id, node.name)), + _ => None, + }) + .collect(); + let n = ops.len(); + self.commit(ops)?; + Ok(format!( + "inserted {n} nodes; top-level: {}\n{}", + created.join(", "), + self.lint_summary() + )) + } + + fn replace(&mut self, node: NodeId, markup: &str) -> Result { + let n = self + .scene + .node(node) + .ok_or_else(|| ToolError(format!("no node {node}")))?; + let parent = n + .parent + .ok_or_else(|| ToolError("can't replace the root".into()))?; + let index = self + .scene + .node(parent) + .and_then(|p| p.children.iter().position(|c| *c == node)) + .unwrap_or(0); + let mut nodes = crate::document::text::parse_fragment(markup) + .map_err(|e| ToolError(format!("markup {e}")))?; + // Replacing in place keeps the node's identity (and anything bound to it). + if let Some(top) = nodes.iter_mut().find(|n| n.parent.is_none()) { + let new_id = top.id; + top.id = node; + for n in &mut nodes { + if n.parent == Some(new_id) { + n.parent = Some(node); + } + } + } + let mut ops = vec![Op::Remove { id: node }]; + // The remove happens first inside the transaction; build inserts + // against a scene without the old subtree. + let mut probe = self.scene.clone(); + probe.apply(Op::Remove { id: node })?; + ops.extend(insert_ops( + &probe, + parent, + Some(u32::try_from(index).unwrap_or(u32::MAX)), + nodes, + )?); + self.commit(ops)?; + Ok(format!("replaced {node}\n{}", self.lint_summary())) + } + + fn add_control( + &mut self, + parent: NodeId, + kind: &str, + bind: Option<&str>, + size: Option<&str>, + name: Option<&str>, + ) -> Result { + let binding = bind.map(parse_source).transpose()?; + let size = match size.unwrap_or("medium") { + "small" => ControlSize::Small, + "medium" => ControlSize::Medium, + "large" => ControlSize::Large, + "stage" => ControlSize::Stage, + other => { + return err(format!( + "unknown size `{other}`; use small | medium | large | stage" + )); + } + }; + let default_name = binding + .as_ref() + .and_then(|b| match &b.source { + Source::Param { addr } => self.catalog.param(addr).map(|p| p.name.clone()), + Source::Action { id } => self.catalog.action(id).map(|a| a.name.clone()), + Source::Stream { id, .. } => self.catalog.stream(id).map(|s| s.name.clone()), + Source::Collection { id } => self.catalog.collection(id).map(|c| c.name.clone()), + }) + .unwrap_or_else(|| kind.to_string()); + // Keep control names (and so labels) unique: "Mix" twice becomes + // "Delay Mix" / "Reverb Mix". + let group = binding.as_ref().and_then(|b| match &b.source { + Source::Param { addr } => self.catalog.param(addr).and_then(|p| p.group.clone()), + _ => None, + }); + let taken = |n: &str| { + self.scene + .nodes + .values() + .any(|x| x.name == n && x.control.is_some()) + }; + let name = match (name, group) { + (Some(n), _) => n.to_string(), + (None, Some(g)) if taken(&default_name) && !default_name.starts_with(&g) => { + format!("{g} {default_name}") + } + (None, _) => default_name.clone(), + }; + let authored_label = (name != default_name).then(|| name.clone()); + let theme = self.theme.clone(); + let (ops, root) = self.build(parent, |b| match kind { + "knob" => Ok(b.knob(parent, &name, size, binding.clone())), + "fader" => Ok(b.fader(parent, &name, size.px() * 3.0, binding.clone())), + "button" => Ok(b.button(parent, &name, size, ButtonMode::Toggle, binding.clone())), + "trigger" => Ok(b.button(parent, &name, size, ButtonMode::Trigger, binding.clone())), + "footswitch" => Ok(b.footswitch(parent, &name, binding.clone())), + "meter" => Ok(b.meter(parent, &name, size.px() * 3.5, binding.clone())), + "readout" => Ok(b.readout(parent, &name, size.px() * 1.5, binding.clone())), + other => err(format!("unknown control kind `{other}`; use knob | fader | button | trigger | footswitch | meter | readout")), + }, &theme)?; + let ops = match authored_label { + Some(label) => with_label(ops, root, &label), + None => ops, + }; + self.commit(ops)?; + Ok(format!( + "added {kind} {root} \"{name}\"\n{}", + self.lint_summary() + )) + } + + fn add_component(&mut self, parent: NodeId, a: &Args<'_>) -> Result { + let id = a.str("component")?; + let binding = a.opt_str("bind")?.map(parse_source).transpose()?; + let list = |k: &str| -> Result>> { + match a.0.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(v)) => Ok(Some( + v.iter() + .map(|x| { + x.as_str() + .map_or_else(|| x.to_string(), ToString::to_string) + }) + .collect(), + )), + Some(_) => err(format!("`{k}` must be an array of strings")), + } + }; + let default_name = binding + .as_ref() + .and_then(|b| match &b.source { + Source::Param { addr } => self.catalog.param(addr).map(|p| p.name.clone()), + Source::Action { id } => self.catalog.action(id).map(|a| a.name.clone()), + Source::Stream { id, .. } => self.catalog.stream(id).map(|s| s.name.clone()), + Source::Collection { id } => self.catalog.collection(id).map(|c| c.name.clone()), + }) + .unwrap_or_else(|| id.rsplit('.').next().unwrap_or(id).to_string()); + // A choice param's options label a bank / selector unless given. + let options = list("options")?.or_else(|| match binding.as_ref().map(|b| &b.source) { + Some(Source::Param { addr }) => match self.catalog.param(addr).map(|p| &p.kind) { + Some(ParamKind::Choice { options }) => Some(options.clone()), + _ => None, + }, + _ => None, + }); + let number = |k: &str| -> Result> { + match a.0.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + Ok(Some(v.as_f64().ok_or_else(|| { + ToolError(format!("`{k}` must be a number")) + })?)) + } + } + }; + let flag = |k: &str| a.0.get(k).and_then(Value::as_bool).unwrap_or(false); + let ink = match a.opt_str("ink")? { + None => None, + Some("light") => Some(crate::surface::kit::Ink::Light), + Some("dark") => Some(crate::surface::kit::Ink::Dark), + Some(c) if c.starts_with('#') => Some(crate::surface::kit::Ink::Color(c.to_string())), + Some(other) => return err(format!("`ink` is light | dark | #hex, not `{other}`")), + }; + let place = crate::surface::kit::Place { + name: a.opt_str("name")?.map_or(default_name, ToString::to_string), + size: number("size")?, + binding, + variant: a.opt_str("variant")?.map(ToString::to_string), + legend: a.opt_str("legend")?.map(ToString::to_string), + scale: list("scale")?, + options, + ink, + keep_case: flag("keep_case"), + sweep: number("sweep")?, + touch: number("touch")?, + reverse: flag("reverse"), + ..crate::surface::kit::Place::default() + }; + let theme = self.theme.clone(); + let (ops, root) = self.build( + parent, + |b| crate::surface::kit::place(b, parent, id, &place).map_err(ToolError), + &theme, + )?; + self.commit(ops)?; + Ok(format!( + "added {id} {root} \"{}\"\n{}", + place.name, + self.lint_summary() + )) + } + + /// Run a widget builder against a scratch copy and turn what it added + /// into `Insert` ops (so it's one undoable transaction). + fn build( + &self, + parent: NodeId, + f: impl FnOnce(&mut Builder<'_>) -> Result, + theme: &Theme, + ) -> Result<(Vec, NodeId)> { + if self.scene.node(parent).is_none() { + return err(format!("no node {parent}")); + } + let mut scratch = self.scene.clone(); + let mut controls = BTreeMap::new(); + let root = { + let mut b = Builder { + scene: &mut scratch, + controls: &mut controls, + theme, + }; + f(&mut b)? + }; + for (id, c) in controls { + if let Some(n) = scratch.node_mut(id) { + n.control = Some(c); + } + } + // Assets the builder added (a material's texture tiles) go in first. + let mut ops: Vec = scratch + .assets + .values() + .filter(|a| self.scene.asset(&a.id).is_none()) + .map(|a| Op::PutAsset { asset: a.clone() }) + .collect(); + for id in scratch.walk(root) { + let mut n = scratch + .node(id) + .cloned() + .ok_or_else(|| ToolError("builder lost a node".into()))?; + let p = n.parent.unwrap_or(parent); + let index = if id == root { + u32::MAX + } else { + position(&scratch, p, id) + }; + n.children.clear(); + ops.push(Op::Insert { + node: n, + parent: p, + index, + }); + } + Ok((ops, root)) + } + + fn bind( + &mut self, + node: NodeId, + source: Option<&str>, + kind: Option<&str>, + slots: Option<&Value>, + ) -> Result { + let n = self + .scene + .node(node) + .ok_or_else(|| ToolError(format!("no node {node}")))?; + let binding = source.map(parse_source).transpose()?; + let mut control = match (n.control.clone(), kind) { + (_, Some(k)) => { + let mut c = Control::new(parse_kind(k)?); + if let Some(old) = &n.control { + c.binding = old.binding.clone(); + c.slots = old.slots.clone(); + c.midi = old.midi.clone(); + } + c + } + (Some(c), None) => c, + (None, None) => { + return err( + "this node isn't a control yet; pass `kind` (knob | fader | button | trigger | footswitch | meter | readout | xy | selector | plot | spectrum | history)", + ); + } + }; + if binding.is_some() { + control.binding = binding; + } + if let Some(slots) = slots { + let map = slots.as_object().ok_or_else(|| { + ToolError("`slots` must be an object like {\"needle\": \"\"}".into()) + })?; + control.slots.clear(); + for (role, v) in map { + let target = v + .as_str() + .ok_or_else(|| ToolError(format!("slot `{role}` must name a node")))?; + control.slots.push(Slot { + role: parse_role(role)?, + node: resolve(&self.scene, target)?.0, + }); + } + } + self.commit(vec![Op::Set { + id: node, + prop: Prop::Control(Some(control)), + }])?; + Ok(format!("bound {node}\n{}", self.lint_summary())) + } + + fn generate(&mut self, theme: Option<&str>) -> Result { + let theme = parse_theme(theme.unwrap_or("stage-dark"))?; + self.theme = theme.clone(); + let generated = surface_for_catalog( + &self.catalog, + &Options { + theme, + ..Options::default() + }, + ) + .to_scene(); + let gen_page = generated.pages()[0]; + let page = self.page(); + let mut ops: Vec = self + .scene + .node(page) + .map(|p| p.children.iter().map(|c| Op::Remove { id: *c }).collect()) + .unwrap_or_default(); + for id in generated.walk(gen_page).into_iter().skip(1) { + let mut n = generated + .node(id) + .cloned() + .ok_or_else(|| ToolError("generator lost a node".into()))?; + let p = if n.parent == Some(gen_page) { + page + } else { + n.parent.unwrap_or(page) + }; + n.children.clear(); + ops.push(Op::Insert { + node: n, + parent: p, + index: u32::MAX, + }); + } + self.commit(ops)?; + Ok(format!( + "generated a surface for `{}` ({} controls)\n{}", + self.catalog.id, + self.scene + .nodes + .values() + .filter(|n| n.control.is_some()) + .count(), + self.lint_summary() + )) + } + + fn lint_summary(&self) -> String { + let issues = self.issues(); + let (e, w, _) = crate::design::lint::counts(&issues); + let mut s = format!("lint: {e} errors, {w} warnings"); + for i in issues + .iter() + .filter(|i| i.severity == Severity::Error) + .take(5) + { + s.push_str(&format!("\n {i}")); + } + s + } +} + +/// Write `label` into the control's Label slot node (an authored label wins +/// over the parameter name at runtime). +fn with_label(mut ops: Vec, root: NodeId, label: &str) -> Vec { + let slot = ops.iter().find_map(|o| match o { + Op::Insert { node, .. } if node.id == root => { + node.control.as_ref()?.slots_of(SlotRole::Label).next() + } + _ => None, + }); + if let Some(slot) = slot { + for o in &mut ops { + if let Op::Insert { node, .. } = o + && node.id.0 == slot + && let NodeKind::Text(t) = &mut node.kind + { + t.characters = label.to_string(); + } + } + } + ops +} + +/// The bundled font families, for a model lettering a surface. +fn fonts_text() -> String { + let mut out = String::from( + "fonts every renderer bundles — (runs: font_family, font_weight, font_style, font_width).\n\ + Unknown families and missing glyphs fall back to Inter; missing weights are emboldened, missing italics slanted. \ + Use real weight and family, never overlapping copies or outlines to fake them.\n\n", + ); + for f in crate::render::font_book::BUNDLED { + let weights = if f.weights.0 == f.weights.1 { + f.weights.0.to_string() + } else { + format!("{}–{}", f.weights.0, f.weights.1) + }; + let mut extra = Vec::new(); + if f.italic { + extra.push("italic".to_string()); + } + if f.widths.0 != f.widths.1 { + extra.push(format!("stretch {}–{}", f.widths.0, f.widths.1)); + } + let extra = if extra.is_empty() { + String::new() + } else { + format!(", {}", extra.join(", ")) + }; + out.push_str(&format!( + " {:<18} weight {weights}{extra} — {}\n", + f.name, f.role + )); + } + out.push_str("\ngeneric: sans-serif (Inter), serif (Playfair Display), monospace (JetBrains Mono), cursive (Dancing Script), fantasy (Pirata One); also DSEG7, DSEG14, lcd, dot-matrix, marker, blackletter.\n"); + out +} + +/// The kit, grouped by family, for a model choosing components. +fn components_text(family: Option<&str>) -> String { + let all: Vec<_> = crate::surface::kit::components() + .into_iter() + .filter(|c| family.is_none_or(|f| c.family.name() == f)) + .collect(); + if all.is_empty() { + return "no components (for that family); families: knob, meter, button, switch, lever, panel, material".into(); + } + let mut out = String::from( + "hardware kit — place with add_component {parent, component, bind, size?, variant?, legend?, scale?, options?}\n", + ); + let mut fam = None; + for c in all { + if fam != Some(c.family) { + out.push_str(&format!("\n[{}]\n", c.family.name())); + fam = Some(c.family); + } + let variants = if c.variants.is_empty() { + String::new() + } else { + format!(" variants: {}.", c.variants.join(" | ")) + }; + out.push_str(&format!( + " {:<24} binds {:<13} {}px — {}{}\n", + c.id, c.binds, c.default_size, c.description, variants + )); + } + out +} + +fn position(scene: &Scene, parent: NodeId, id: NodeId) -> u32 { + scene + .node(parent) + .and_then(|p| p.children.iter().position(|c| *c == id)) + .map_or(u32::MAX, |i| u32::try_from(i).unwrap_or(u32::MAX)) +} + +/// Insert ops for a parsed fragment (pre-order, parents wired; top-level +/// nodes go under `parent` starting at `index`). +fn insert_ops( + scene: &Scene, + parent: NodeId, + index: Option, + nodes: Vec, +) -> Result> { + if scene.node(parent).is_none() { + return err(format!("no node {parent}")); + } + let mut next = index.unwrap_or(u32::MAX); + let mut ops = Vec::new(); + for mut n in nodes { + if scene.node(n.id).is_some() { + return err(format!( + "id {} is already in the document; omit `id` to get a fresh one", + n.id + )); + } + n.children.clear(); + match n.parent { + Some(p) => ops.push(Op::Insert { + node: n, + parent: p, + index: u32::MAX, + }), + None => { + ops.push(Op::Insert { + node: n, + parent, + index: next, + }); + next = next.saturating_add(1); + } + } + } + Ok(ops) +} + +fn outline_line(n: &Node) -> String { + let kind = match &n.kind { + NodeKind::Text(t) => format!( + "Text \"{}\"", + t.characters.chars().take(40).collect::() + ), + NodeKind::Instance { .. } => "Instance".into(), + NodeKind::BooleanOperation { .. } => "BooleanOperation".into(), + NodeKind::Other { tag } => tag.clone(), + k => format!("{k:?}"), + }; + let mut s = format!( + "{kind} \"{}\" {} {}×{}", + n.name, + n.id, + n.size.width.round(), + n.size.height.round() + ); + if let Some(l) = &n.layout { + s.push_str(&format!( + " [{}]", + if matches!( + l.direction, + crate::document::proto::LayoutDirection::Horizontal + ) { + "row" + } else { + "column" + } + )); + } + if let Some(c) = &n.control { + s.push_str(&format!(" ⟨{}", c.kind.name())); + if let Some(b) = &c.binding { + s.push_str(&format!(" → {}", source_text(&b.source))); + } + s.push('⟩'); + } + s +} + +fn source_text(s: &Source) -> String { + match s { + Source::Param { addr } => format!("param:{}", addr.0), + Source::Action { id } => format!("action:{}", id.0), + Source::Stream { + id, + channel: Some(c), + } => format!("stream:{}:{c}", id.0), + Source::Stream { id, channel: None } => format!("stream:{}", id.0), + Source::Collection { id } => format!("collection:{}", id.0), + } +} + +/// `param:amp/gain` · `action:tap_tempo` · `stream:out_level[:ch]` · `collection:songs`. +fn parse_source(s: &str) -> Result { + let (kind, rest) = s.split_once(':').ok_or_else(|| { + ToolError(format!( + "source `{s}` needs a prefix: param: | action: | stream: | collection:" + )) + })?; + let source = match kind { + "param" => return Ok(Binding::param(rest)), + "action" => Source::Action { + id: EntryId::new(rest), + }, + "stream" => match rest.rsplit_once(':') { + Some((id, ch)) if ch.parse::().is_ok() => Source::Stream { + id: EntryId::new(id), + channel: ch.parse().ok(), + }, + _ => Source::Stream { + id: EntryId::new(rest), + channel: None, + }, + }, + "collection" => Source::Collection { + id: EntryId::new(rest), + }, + other => { + return err(format!( + "unknown source kind `{other}`; use param | action | stream | collection" + )); + } + }; + Ok(Binding { + source, + ..Binding::param("") + }) +} + +fn parse_theme(t: &str) -> Result { + match t { + "stage-dark" => Ok(Theme::stage_dark()), + "daylight" => Ok(Theme::daylight()), + other => err(format!( + "unknown theme `{other}`; use stage-dark | daylight" + )), + } +} + +fn parse_kind(k: &str) -> Result { + Ok(match k { + "knob" => ControlKind::knob(), + "fader" => ControlKind::fader(), + "hfader" => ControlKind::Fader { + orientation: Orientation::Horizontal, + }, + "button" => ControlKind::Button { + mode: ButtonMode::Toggle, + }, + "momentary" => ControlKind::Button { + mode: ButtonMode::Momentary, + }, + "trigger" => ControlKind::Button { + mode: ButtonMode::Trigger, + }, + "footswitch" => ControlKind::footswitch(), + "meter" => ControlKind::Meter { + orientation: Orientation::Vertical, + }, + "gauge" | "vu" => ControlKind::Gauge { + sweep_degrees: 96.0, + floor_db: -20.0, + ceiling_db: 3.0, + }, + "xy" => ControlKind::XyPad, + "selector" => ControlKind::Selector, + "readout" => ControlKind::Readout, + // Plots of a feed; curves and handles carry their own inputs and + // axes, so they are authored in markup or placed from the viz kit. + "plot" | "scope" => ControlKind::Plot { + style: crate::surface::proto::PlotStyle::Line, + x: crate::surface::proto::Axis::Linear { min: 0.0, max: 1.0 }, + y: crate::surface::proto::PlotRange::linear(-1.0, 1.0), + }, + "spectrum" => ControlKind::Plot { + style: crate::surface::proto::PlotStyle::Line, + x: crate::surface::proto::Axis::Log { + f_min: 20.0, + f_max: 20_000.0, + }, + y: crate::surface::proto::PlotRange::linear(-90.0, 0.0), + }, + "history" => ControlKind::Plot { + style: crate::surface::proto::PlotStyle::Area { baseline: 0.0 }, + x: crate::surface::proto::Axis::Time { len: 240 }, + y: crate::surface::proto::PlotRange::linear(-24.0, 0.0), + }, + other => return err(format!("unknown control kind `{other}`")), + }) +} + +fn parse_role(r: &str) -> Result { + Ok( + match r.to_ascii_lowercase().replace(['-', '_'], "").as_str() { + "needle" | "pointer" => SlotRole::Needle, + "valuearc" | "arc" => SlotRole::ValueArc, + "modarc" => SlotRole::ModArc, + "fill" => SlotRole::Fill, + "thumb" | "cap" => SlotRole::Thumb, + "value" | "readout" => SlotRole::Value, + "label" => SlotRole::Label, + "led" => SlotRole::Led, + "peakhold" | "peak" => SlotRole::PeakHold, + "glow" => SlotRole::Glow, + "trace" | "line" => SlotRole::Trace, + "area" | "region" => SlotRole::Area, + "marker" | "ball" => SlotRole::Marker, + _ => { + return err(format!( + "unknown slot role `{r}`; use needle | value-arc | mod-arc | fill | thumb | value | label | led | peak-hold | glow | trace | area | marker" + )); + } + }, + ) +} + +/// A node by uuid, or by name when the name is unique. +fn resolve(scene: &Scene, r: &str) -> Result { + if let Ok(u) = uuid::Uuid::parse_str(r) { + return scene + .node(NodeId(u)) + .map(|n| n.id) + .ok_or_else(|| ToolError(format!("no node {r}"))); + } + let hits: Vec<&Node> = scene.nodes.values().filter(|n| n.name == r).collect(); + match hits.as_slice() { + [one] => Ok(one.id), + [] => err(format!( + "no node named \"{r}\"; use `describe` to list nodes" + )), + many => err(format!( + "{} nodes are named \"{r}\"; pass an id instead ({})", + many.len(), + many.iter() + .take(4) + .map(|n| n.id.to_string()) + .collect::>() + .join(", ") + )), + } +} + +pub(crate) struct Args<'a>(&'a Value); + +impl Args<'_> { + fn opt_str(&self, k: &str) -> Result> { + match self.0.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => err(format!("`{k}` must be a string")), + } + } + fn str(&self, k: &str) -> Result<&str> { + self.opt_str(k)? + .ok_or_else(|| ToolError(format!("missing `{k}`"))) + } + fn opt_u32(&self, k: &str) -> Result> { + match self.0.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .map(Some) + .ok_or_else(|| ToolError(format!("`{k}` must be a non-negative integer"))), + } + } + fn opt_node(&self, scene: &Scene, k: &str) -> Result> { + self.opt_str(k)?.map(|r| resolve(scene, r)).transpose() + } + fn node(&self, scene: &Scene, k: &str) -> Result { + resolve(scene, self.str(k)?) + } +} + +/// The tool registry every adapter advertises. +#[must_use] +pub fn tools() -> Vec { + let node = json!({"type": "string", "description": "node id (uuid) or unique node name"}); + let obj = |props: Value, required: &[&str]| json!({"type": "object", "properties": props, "required": required}); + vec![ + ToolDef { + name: "describe", + description: "Outline the document (or a subtree): one line per node with kind, name, id, size, layout and control binding; plus a lint summary. Start here.", + input_schema: obj(json!({"node": node}), &[]), + mutates: false, + }, + ToolDef { + name: "read", + description: "Frame markup of the whole document or one subtree (lossless; see the markup reference).", + input_schema: obj(json!({"node": node}), &[]), + mutates: false, + }, + ToolDef { + name: "catalog", + description: "What the engine exposes: parameters (addr, name, range/unit, default), actions, telemetry streams. Bind controls only to these.", + input_schema: obj(json!({}), &[]), + mutates: false, + }, + ToolDef { + name: "lint", + description: "Check the surface: wiring, missing slots, overlaps, touch targets, consistency, contrast. Each issue says how to fix it. Iterate until there are no errors.", + input_schema: obj(json!({}), &[]), + mutates: false, + }, + ToolDef { + name: "write", + description: "Insert a frame-markup fragment (one or more elements) under `parent` at `index` (default: end). Omit ids to get fresh ones. Returns the new top-level ids and lint. Letter text with font/weight/style/stretch (see `fonts`).", + input_schema: obj( + json!({"parent": node, "markup": {"type": "string"}, "index": {"type": "integer", "minimum": 0}}), + &["parent", "markup"], + ), + mutates: true, + }, + ToolDef { + name: "replace", + description: "Replace a node's subtree with a markup fragment; the node keeps its id.", + input_schema: obj( + json!({"node": node, "markup": {"type": "string"}}), + &["node", "markup"], + ), + mutates: true, + }, + ToolDef { + name: "remove", + description: "Delete a node and its subtree.", + input_schema: obj(json!({"node": node}), &["node"]), + mutates: true, + }, + ToolDef { + name: "move", + description: "Reparent/reorder a node.", + input_schema: obj( + json!({"node": node, "parent": node, "index": {"type": "integer", "minimum": 0}}), + &["node", "parent"], + ), + mutates: true, + }, + ToolDef { + name: "add_control", + description: "Add a canonical, themed control under `parent`, fully wired. kind: knob | fader | button | trigger | footswitch | meter | readout. bind: param: | action: | stream:[:channel]. size: small | medium | large | stage.", + input_schema: obj( + json!({"parent": node, "kind": {"type": "string", "enum": ["knob", "fader", "button", "trigger", "footswitch", "meter", "readout"]}, "bind": {"type": "string"}, "size": {"type": "string", "enum": ["small", "medium", "large", "stage"]}, "name": {"type": "string"}}), + &["parent", "kind"], + ), + mutates: true, + }, + ToolDef { + name: "components", + description: "The hardware component kit: knobs (Bakelite, Daka, Marconi, Collet, silver-top…), VU meters, buttons, switches, levers, panels — each with what it binds to, its default size and variants. Use these for hardware-style surfaces.", + input_schema: obj( + json!({"family": {"type": "string", "enum": ["knob", "meter", "button", "switch", "lever", "panel", "material"]}}), + &[], + ), + mutates: false, + }, + ToolDef { + name: "fonts", + description: "The font families every renderer bundles — UI sans (Inter), condensed display (Oswald, Saira with a width axis), engraved and tech caps (Michroma, Orbitron), scripts (Dancing Script, Pacifico), markers (Caveat Brush, Knewave), serif (Playfair Display), 7/14-segment LCD (DSEG7/DSEG14 Classic), dot matrix (Doto, VT323), monospace (JetBrains Mono), blackletter (UnifrakturCook, Pirata One) — with their weights and what each suits. Set them on with font, weight, style and stretch.", + input_schema: obj(json!({}), &[]), + mutates: false, + }, + ToolDef { + name: "add_component", + description: "Place a kit component (see `components`) under `parent`, fully wired. bind: param: | stream:[:ch] | action:. size: px (primary dimension). variant: a colour/finish from the component's list. legend: printed name. scale: printed scale labels (start → end of travel). options: labels for banks/selectors (defaults to the choice param's options). Panels bind nothing and return a frame to place other components into. ink: light | dark | #hex for the printed scale and legends (dark on light panels). keep_case: print legends as written, not in capitals. sweep: degrees of travel for rotaries (a stepped knob with options picks its own). touch: px — a tight hit box (the knob's body, padded to this; 44 for the lint) with the print hanging outside. reverse: lay a bank/ladder out last option first.", + input_schema: obj( + json!({"parent": node, "component": {"type": "string"}, "bind": {"type": "string"}, "size": {"type": "number"}, "variant": {"type": "string"}, "legend": {"type": "string"}, "name": {"type": "string"}, "scale": {"type": "array", "items": {"type": "string"}}, "options": {"type": "array", "items": {"type": "string"}}, "ink": {"type": "string"}, "keep_case": {"type": "boolean"}, "sweep": {"type": "number"}, "touch": {"type": "number"}, "reverse": {"type": "boolean"}}), + &["parent", "component"], + ), + mutates: true, + }, + ToolDef { + name: "bind", + description: "Make any drawn node a control, or rewire one. kind (to create/change), source (param:… | action:… | stream:… | collection:…), slots: {role: node} with roles needle, value-arc, mod-arc, fill, thumb, value, label, led, peak-hold.", + input_schema: obj( + json!({"node": node, "kind": {"type": "string"}, "source": {"type": "string"}, "slots": {"type": "object", "additionalProperties": {"type": "string"}}}), + &["node"], + ), + mutates: true, + }, + ToolDef { + name: "generate", + description: "Replace the page with a complete, bound surface generated from the catalog (a panel per group, output strip, footswitch row). A good starting point to refine.", + input_schema: obj( + json!({"theme": {"type": "string", "enum": ["stage-dark", "daylight"]}}), + &[], + ), + mutates: true, + }, + ToolDef { + name: "set_theme", + description: "Theme for controls added from now on (add_control): stage-dark (dim stages) | daylight (bright rooms). Existing nodes keep their colors.", + input_schema: obj( + json!({"theme": {"type": "string", "enum": ["stage-dark", "daylight"]}}), + &["theme"], + ), + mutates: false, + }, + animate::def(), + ToolDef { + name: "undo", + description: "Revert the last edit (one tool call).", + input_schema: obj(json!({}), &[]), + mutates: true, + }, + ToolDef { + name: "redo", + description: "Re-apply the last undone edit.", + input_schema: obj(json!({}), &[]), + mutates: true, + }, + ] +} diff --git a/crates/frame/src/document/bundle/mod.rs b/crates/frame/src/document/bundle/mod.rs new file mode 100644 index 00000000..e815ad38 --- /dev/null +++ b/crates/frame/src/document/bundle/mod.rs @@ -0,0 +1,225 @@ +//! The `.frame` file. +//! +//! A `.frame` bundle is a zip archive (deflate) with three kinds of entry: +//! +//! ```text +//! manifest.styx Manifest — format version, doc id, title, timestamps, +//! the param catalogs the document needs +//! doc.loro the Loro snapshot of the scene (history included, +//! asset *bytes* excluded — see document-loro) +//! assets/ raw asset bytes; the name is their blake3 hex, the +//! mime lives in the doc's asset metadata +//! ``` +//! +//! Asset bytes sit beside the CRDT, not in it: they're immutable and +//! content-addressed, so the doc only needs to name them, and a snapshot +//! stays small however many images a document carries. Loading checks +//! every asset against its name (a flipped bit is an error, not a wrong +//! image) and that every asset the doc references is present. +//! +//! The manifest is written with `facet-styx` from the [`Manifest`] struct. + +use std::io::{Cursor, Read, Write}; +use std::path::Path; + +use crate::document::loro::{AssetStorage, LoroScene}; +use crate::document::proto::{AssetId, Scene}; +use chrono::{DateTime, Utc}; +use facet::Facet; +use uuid::Uuid; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +/// The bundle format this crate writes (and the only one it reads). +pub const FORMAT_VERSION: u32 = 1; + +pub const MANIFEST: &str = "manifest.styx"; +pub const DOC: &str = "doc.loro"; +pub const ASSETS_DIR: &str = "assets/"; + +/// A bundle's `manifest.styx`: what the file is, before opening the doc. +#[derive(Debug, Clone, PartialEq, Facet)] +pub struct Manifest { + /// The bundle format ([`FORMAT_VERSION`]). + pub format: u32, + /// The document's identity — the same id its sync channel and index + /// row (`crate::document::proto::Document`) use. + pub doc_id: Uuid, + pub title: String, + pub created: DateTime, + pub updated: DateTime, + /// Param catalogs the document's bindings need, by name. + pub catalogs: Vec, +} + +impl Manifest { + /// A manifest for a new document, created now. + #[must_use] + pub fn new(title: impl Into) -> Self { + let now = now_seconds(); + Self { + format: FORMAT_VERSION, + doc_id: Uuid::new_v4(), + title: title.into(), + created: now, + updated: now, + catalogs: Vec::new(), + } + } + + /// Mark the document as updated now. + pub fn touch(&mut self) { + self.updated = now_seconds(); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("zip: {0}")] + Zip(#[from] zip::result::ZipError), + #[error("manifest: {0}")] + Manifest(String), + #[error("unsupported bundle format {0} (this build reads {FORMAT_VERSION})")] + UnsupportedFormat(u32), + #[error("doc.loro: {0}")] + Doc(String), + #[error("asset {0} is missing")] + MissingAsset(AssetId), + #[error("asset {0} doesn't match its content hash")] + CorruptAsset(AssetId), +} + +/// What [`save`] / [`to_bytes`] accept: a live replica (its history is +/// kept) or a plain scene (written as a fresh doc). +pub trait Content { + /// Call `f` with a replica whose snapshot carries no asset bytes. + fn with_live(&self, f: impl FnOnce(&LoroScene) -> R) -> R; +} + +impl Content for LoroScene { + fn with_live(&self, f: impl FnOnce(&LoroScene) -> R) -> R { + match self.storage() { + AssetStorage::External => f(self), + // Inline bytes would ride inside doc.loro (and its history, for + // good): re-base onto an external-storage replica instead. + AssetStorage::Inline => f(&LoroScene::from_scene(&self.scene())), + } + } +} + +impl Content for Scene { + fn with_live(&self, f: impl FnOnce(&LoroScene) -> R) -> R { + f(&LoroScene::from_scene(self)) + } +} + +/// Write a bundle to `path`. +/// +/// # Errors +/// I/O, or [`Error::MissingAsset`] when the replica references an asset it +/// holds no bytes for. +pub fn save( + path: impl AsRef, + content: &impl Content, + manifest: &Manifest, +) -> Result<(), Error> { + let bytes = to_bytes(content, manifest)?; + std::fs::write(path, bytes)?; + Ok(()) +} + +/// Read a bundle from `path`. +/// +/// # Errors +/// See [`from_bytes`]. +pub fn load(path: impl AsRef) -> Result<(Manifest, LoroScene), Error> { + from_bytes(&std::fs::read(path)?) +} + +/// A bundle, in memory. +/// +/// # Errors +/// See [`save`]. +pub fn to_bytes(content: &impl Content, manifest: &Manifest) -> Result, Error> { + content.with_live(|live| { + let scene = live.scene(); + if let Some(missing) = live.missing_assets().into_iter().next() { + return Err(Error::MissingAsset(missing)); + } + let manifest_text = + facet_styx::to_string(manifest).map_err(|e| Error::Manifest(e.to_string()))?; + + let deflate = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file(MANIFEST, deflate)?; + zip.write_all(manifest_text.as_bytes())?; + zip.start_file(DOC, deflate)?; + zip.write_all(&live.export_snapshot())?; + for asset in scene.assets.values() { + zip.start_file(format!("{ASSETS_DIR}{}", asset.id), deflate)?; + zip.write_all(&asset.bytes)?; + } + Ok(zip.finish()?.into_inner()) + }) +} + +/// Open an in-memory bundle: the manifest, and a replica of its doc with +/// every asset's bytes loaded and verified. +/// +/// # Errors +/// Not a zip / missing entries, an unreadable manifest, a newer format, +/// a doc Loro can't import, an asset whose bytes don't hash to its name +/// ([`Error::CorruptAsset`]), or one the doc references that isn't there +/// ([`Error::MissingAsset`]). +pub fn from_bytes(bytes: &[u8]) -> Result<(Manifest, LoroScene), Error> { + let mut zip = ZipArchive::new(Cursor::new(bytes))?; + + let manifest_text = String::from_utf8(read_entry(&mut zip, MANIFEST)?) + .map_err(|e| Error::Manifest(e.to_string()))?; + let manifest: Manifest = + facet_styx::from_str(&manifest_text).map_err(|e| Error::Manifest(e.to_string()))?; + if manifest.format != FORMAT_VERSION { + return Err(Error::UnsupportedFormat(manifest.format)); + } + + let doc = read_entry(&mut zip, DOC)?; + let live = LoroScene::from_snapshot(&doc, AssetStorage::External) + .map_err(|e| Error::Doc(e.to_string()))?; + + for i in 0..zip.len() { + let mut entry = zip.by_index(i)?; + let Some(name) = entry.name().strip_prefix(ASSETS_DIR).map(str::to_owned) else { + continue; + }; + if name.is_empty() { + continue; + } + let mut data = Vec::with_capacity(usize::try_from(entry.size()).unwrap_or(0)); + entry.read_to_end(&mut data)?; + let id = AssetId(name); + if AssetId::of(&data) != id { + return Err(Error::CorruptAsset(id)); + } + live.put_blob(data); + } + if let Some(missing) = live.missing_assets().into_iter().next() { + return Err(Error::MissingAsset(missing)); + } + Ok((manifest, live)) +} + +fn read_entry(zip: &mut ZipArchive>, name: &str) -> Result, Error> { + let mut entry = zip.by_name(name)?; + let mut out = Vec::new(); + entry.read_to_end(&mut out)?; + Ok(out) +} + +/// Now, at whole-second precision: manifests round-trip through styx, which +/// stores timestamps to the second. +fn now_seconds() -> chrono::DateTime { + use chrono::SubsecRound as _; + Utc::now().trunc_subsecs(0) +} diff --git a/crates/frame/src/document/io/mod.rs b/crates/frame/src/document/io/mod.rs new file mode 100644 index 00000000..0f2d01ab --- /dev/null +++ b/crates/frame/src/document/io/mod.rs @@ -0,0 +1,551 @@ +//! Surfaces on disk: the one load/save API every host uses. +//! +//! A surface is frame markup (`.frame`; the older `.fm` is still read and +//! written where it already exists) or a bundle (also `.frame`: one zip with +//! manifest, Loro snapshot and assets — see `document-bundle`). The content +//! decides which a `.frame` is: a zip (`PK\x03\x04`) is a bundle, anything +//! else is markup. Markup names +//! its image and font assets by id only, so their bytes live in a sidecar +//! folder next to it: +//! +//! ```text +//! 06-ranch-head.frame the markup (text, diffable, editable) +//! 06-ranch-head.frame.assets/ +//! manifest.json { "format": 1, "assets": [ { "id", "mime", "file" } ] } +//! 9f2c…e1.png asset bytes; the name is their blake3 hex +//! 41ab…07.ttf +//! ``` +//! +//! Why a sidecar rather than base64 in the markup or bundles only: +//! +//! - the `.frame` stays the small, human- and agent-editable text it was — a +//! model reading or rewriting a face never wades through megabytes of +//! base64, and a diff of a face shows design changes, not re-encoded PNGs; +//! - asset files are content-addressed: a name *is* the hash of the bytes, +//! so writes are idempotent, loading verifies every file, and identical +//! textures (a suite's shared tolex) are identical files — git stores one +//! blob for all of them; +//! - one folder per surface keeps a surface self-contained: copying, +//! moving or deleting it is `file + file.assets/`, no shared store to +//! garbage-collect or race on; +//! - the manifest keeps each asset's mime (fonts are found by `font/*`, +//! not by file extension) and says which files are the document's. +//! +//! [`load_surface`] / [`save_surface`] pick the format by extension. The +//! lower-level [`read_markup`] / [`write_markup`] move markup *text* plus +//! assets without parsing (a library serves a half-edited file as is). +//! [`fingerprint`] covers the file and its sidecar, so a watcher that polls +//! it reloads when a texture changes as well as when the markup does. + +use std::collections::{BTreeMap, BTreeSet}; +use std::hash::{Hash as _, Hasher as _}; +use std::path::{Path, PathBuf}; + +use crate::document::proto::{Asset, AssetId, Scene}; +use serde::{Deserialize, Serialize}; + +/// The extension of a frame document (markup). +pub const EXTENSION: &str = "frame"; +/// The extension frame markup used before it was `.frame`; still read. +pub const LEGACY_EXTENSION: &str = "fm"; + +/// Whether `path` names a frame document: `.frame` (markup or a bundle) or +/// the legacy `.fm`. +#[must_use] +pub fn is_frame_document(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| matches!(e, EXTENSION | LEGACY_EXTENSION)) +} + +/// Appended to a markup file's name for its assets folder (`a.frame.assets`). +pub const ASSETS_SUFFIX: &str = ".assets"; +/// The sidecar's index of assets. +pub const MANIFEST: &str = "manifest.json"; +/// The sidecar format this crate writes (and the newest it reads). +pub const MANIFEST_FORMAT: u32 = 1; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{}: {source}", path.display())] + Io { + path: PathBuf, + source: std::io::Error, + }, + /// Displays as `path:line:col: message`. + #[error("{}:{error}", path.display())] + Parse { + path: PathBuf, + error: crate::document::text::ParseError, + }, + #[error("{}: {error}", path.display())] + Bundle { + path: PathBuf, + error: crate::document::bundle::Error, + }, + #[error("{}: {message}", path.display())] + Manifest { path: PathBuf, message: String }, + #[error("{}: asset {id} is missing", path.display())] + MissingAsset { path: PathBuf, id: AssetId }, + #[error("{}: asset {id} doesn't match its content hash", path.display())] + CorruptAsset { path: PathBuf, id: AssetId }, + #[error("{}: not a surface file (.frame or .fm)", path.display())] + UnknownFormat { path: PathBuf }, +} + +fn io(path: &Path) -> impl FnOnce(std::io::Error) -> Error + '_ { + move |source| Error::Io { + path: path.to_owned(), + source, + } +} + +/// A surface file's format. Markup and bundles share the `.frame` +/// extension; the content decides (see [`Format::of`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// `.frame` (or legacy `.fm`) markup text (+ its `.frame.assets/` sidecar). + Markup, + /// A `.frame` bundle: one zip. + Bundle, +} + +impl Format { + /// The format of the surface file at `path`. A `.frame` that exists is + /// a bundle when it starts with the zip magic, markup otherwise; a + /// `.frame` not written yet is markup (see [`save_surface_as`] to pick). + /// `.fm` is always markup. + #[must_use] + pub fn of(path: &Path) -> Option { + match path.extension()?.to_str()? { + LEGACY_EXTENSION => Some(Self::Markup), + EXTENSION => Some(if is_zip(path) { + Self::Bundle + } else { + Self::Markup + }), + _ => None, + } + } +} + +/// The zip local-file header every bundle starts with. +const ZIP_MAGIC: &[u8; 4] = b"PK\x03\x04"; + +/// Whether `bytes` is a bundle (a zip) rather than markup text. +#[must_use] +pub fn is_bundle_bytes(bytes: &[u8]) -> bool { + bytes.starts_with(ZIP_MAGIC) +} + +/// Whether the file at `path` starts with a zip local-file header. +fn is_zip(path: &Path) -> bool { + use std::io::Read as _; + let mut magic = [0u8; 4]; + std::fs::File::open(path) + .and_then(|mut f| f.read_exact(&mut magic)) + .is_ok() + && is_bundle_bytes(&magic) +} + +/// Markup text and the assets it names, as stored. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Markup { + pub text: String, + pub assets: BTreeMap, +} + +/// Load a surface: a bundle, or `.frame` / `.fm` markup with its assets. +/// +/// # Errors +/// Unreadable file, a markup parse error (`path:line:col: message`), a +/// broken bundle, or a sidecar asset that's missing or corrupt. +pub fn load_surface(path: impl AsRef) -> Result { + let path = path.as_ref(); + match Format::of(path) { + Some(Format::Bundle) => crate::document::bundle::load(path) + .map(|(_, doc)| doc.scene()) + .map_err(|error| Error::Bundle { + path: path.to_owned(), + error, + }), + Some(Format::Markup) => { + let Markup { text, assets } = read_markup(path)?; + crate::document::text::parse(&text, assets).map_err(|error| Error::Parse { + path: path.to_owned(), + error, + }) + } + None => Err(Error::UnknownFormat { + path: path.to_owned(), + }), + } +} + +/// Save a surface in the format [`Format::of`] gives `path`: `.fm`, a new +/// `.frame` or an existing text `.frame` writes the markup plus its assets +/// sidecar (removed when the scene has no assets); an existing bundle +/// `.frame` writes a bundle, keeping its identity (doc id, created). To +/// write a bundle to a new path, use [`save_surface_as`]. +/// +/// Each file is written then renamed into place, assets before the markup, +/// so a watcher never sees markup naming assets that aren't there yet. +/// +/// # Errors +/// I/O, an unknown extension, or a bundle that can't be written. +pub fn save_surface(path: impl AsRef, scene: &Scene) -> Result<(), Error> { + let path = path.as_ref(); + let format = Format::of(path).ok_or_else(|| Error::UnknownFormat { + path: path.to_owned(), + })?; + save_surface_as(path, scene, format) +} + +/// Save a surface to `path` (a `.frame` or `.fm`) in an explicit `format`. +/// A bundle replaces a markup file's assets sidecar; markup replaces a +/// bundle's bytes. Same atomicity as [`save_surface`]. +/// +/// # Errors +/// I/O, an unknown extension, or a bundle that can't be written. +pub fn save_surface_as(path: impl AsRef, scene: &Scene, format: Format) -> Result<(), Error> { + let path = path.as_ref(); + if !is_frame_document(path) { + return Err(Error::UnknownFormat { + path: path.to_owned(), + }); + } + match format { + Format::Markup => write_markup( + path, + &crate::document::text::print(scene), + scene.assets.values(), + ), + Format::Bundle => { + let bundle = |error| Error::Bundle { + path: path.to_owned(), + error, + }; + let manifest = match crate::document::bundle::load(path) { + Ok((mut m, _)) => { + m.touch(); + m + } + Err(_) => crate::document::bundle::Manifest::new(title_of(scene, path)), + }; + let bytes = crate::document::bundle::to_bytes(scene, &manifest).map_err(bundle)?; + write_atomic(path, &bytes)?; + prune_assets(path) + } + } +} + +/// The first top-level node's name, else the file stem. +fn title_of(scene: &Scene, path: &Path) -> String { + scene + .pages() + .first() + .and_then(|p| scene.node(*p)) + .and_then(|p| p.children.first()) + .and_then(|n| scene.node(*n)) + .map(|n| n.name.trim().to_owned()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| { + path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + }) +} + +/// Read markup text and its sidecar assets, without parsing the text. +/// +/// # Errors +/// Unreadable / non-UTF-8 file, or a broken sidecar (see [`read_assets`]). +pub fn read_markup(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(io(path))?; + Ok(Markup { + text, + assets: read_assets(path)?, + }) +} + +/// Write markup text and its assets: the sidecar's new files and manifest +/// first, then the text, then the sidecar's stale files are pruned. +/// +/// # Errors +/// I/O. +pub fn write_markup<'a>( + path: impl AsRef, + text: &str, + assets: impl IntoIterator, +) -> Result<(), Error> { + let path = path.as_ref(); + let assets: Vec<&Asset> = assets.into_iter().collect(); + let keep = put_assets(path, &assets)?; + write_atomic(path, text.as_bytes())?; + prune(path, &keep) +} + +/// The assets in `path`'s sidecar (none when it has no sidecar). Every +/// file is checked against its content hash. +/// +/// # Errors +/// An unreadable or newer-format manifest, a listed file that's missing +/// ([`Error::MissingAsset`]) or whose bytes don't hash to its id +/// ([`Error::CorruptAsset`]). +pub fn read_assets(path: impl AsRef) -> Result, Error> { + let dir = assets_dir(path.as_ref()); + let manifest_path = dir.join(MANIFEST); + let text = match std::fs::read_to_string(&manifest_path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(e) => return Err(io(&manifest_path)(e)), + }; + let bad = |message: String| Error::Manifest { + path: manifest_path.clone(), + message, + }; + let manifest: Manifest = serde_json::from_str(&text).map_err(|e| bad(e.to_string()))?; + if manifest.format > MANIFEST_FORMAT { + return Err(bad(format!( + "sidecar format {} (this build reads {MANIFEST_FORMAT})", + manifest.format + ))); + } + let mut out = BTreeMap::new(); + for entry in manifest.assets { + if !plain_name(&entry.file) { + return Err(bad(format!( + "`{}` isn't a file name in the assets folder", + entry.file + ))); + } + let file = dir.join(&entry.file); + let bytes = match std::fs::read(&file) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(Error::MissingAsset { + path: file, + id: entry.id, + }); + } + Err(e) => return Err(io(&file)(e)), + }; + if AssetId::of(&bytes) != entry.id { + return Err(Error::CorruptAsset { + path: file, + id: entry.id, + }); + } + out.insert( + entry.id.clone(), + Asset { + id: entry.id, + mime: entry.mime, + bytes, + }, + ); + } + Ok(out) +} + +/// Remove a surface file and (for markup) its assets sidecar. +/// +/// # Errors +/// I/O other than the file already being gone. +pub fn remove_surface(path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(io(path)(e)), + } + prune(path, &BTreeSet::new()) +} + +/// `path`'s assets folder: `a.frame` → `a.frame.assets`. +#[must_use] +pub fn assets_dir(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(ASSETS_SUFFIX); + PathBuf::from(name) +} + +/// Whether `dir` is some markup file's assets folder (for directory walks +/// that should skip them). +#[must_use] +pub fn is_assets_dir(dir: &Path) -> bool { + dir.file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(ASSETS_SUFFIX)) + .is_some_and(|stem| Format::of(Path::new(stem)) == Some(Format::Markup)) +} + +/// A cheap identity of the surface on disk — the file's and every sidecar +/// entry's name, mtime and length — for change polling. `None` when the +/// file doesn't exist. Changes when the markup changes *or* an asset does. +#[must_use] +pub fn fingerprint(path: impl AsRef) -> Option { + let path = path.as_ref(); + let stamp = |p: &Path| { + std::fs::metadata(p) + .ok() + .map(|m| (m.modified().ok(), m.len())) + }; + let mut h = std::collections::hash_map::DefaultHasher::new(); + stamp(path)?.hash(&mut h); + if Format::of(path) == Some(Format::Markup) + && let Ok(read) = std::fs::read_dir(assets_dir(path)) + { + let mut names: Vec = read.filter_map(|e| e.ok().map(|e| e.path())).collect(); + names.sort(); + for p in names { + p.file_name().hash(&mut h); + stamp(&p).hash(&mut h); + } + } + Some(h.finish()) +} + +/// The file extension an asset of `mime` is stored with (cosmetic: the +/// manifest, not the extension, is the asset's mime). +#[must_use] +pub fn extension_for(mime: &str) -> &'static str { + match mime { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + "font/ttf" => "ttf", + "font/otf" => "otf", + "font/woff" => "woff", + "font/woff2" => "woff2", + "text/wgsl" => "wgsl", + _ => "bin", + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct Manifest { + format: u32, + assets: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ManifestEntry { + id: AssetId, + mime: String, + file: String, +} + +fn file_name(asset: &Asset) -> String { + format!("{}.{}", asset.id, extension_for(&asset.mime)) +} + +/// Write `assets`' files (unless already there) and the manifest (unless +/// unchanged). Returns the file names the sidecar now holds. +fn put_assets(path: &Path, assets: &[&Asset]) -> Result, Error> { + if assets.is_empty() { + return Ok(BTreeSet::new()); + } + let dir = assets_dir(path); + std::fs::create_dir_all(&dir).map_err(io(&dir))?; + let mut sorted = assets.to_vec(); + sorted.sort_by(|a, b| a.id.cmp(&b.id)); + sorted.dedup_by(|a, b| a.id == b.id); + let mut keep = BTreeSet::new(); + let mut entries = Vec::with_capacity(sorted.len()); + for asset in sorted { + let name = file_name(asset); + let file = dir.join(&name); + // Content-addressed: a file of that name holding those bytes is done. + if std::fs::read(&file).ok().as_deref() != Some(asset.bytes.as_slice()) { + write_atomic(&file, &asset.bytes)?; + } + entries.push(ManifestEntry { + id: asset.id.clone(), + mime: asset.mime.clone(), + file: name.clone(), + }); + keep.insert(name); + } + let manifest = Manifest { + format: MANIFEST_FORMAT, + assets: entries, + }; + let mut text = serde_json::to_string_pretty(&manifest).map_err(|e| Error::Manifest { + path: dir.join(MANIFEST), + message: e.to_string(), + })?; + text.push('\n'); + let manifest_path = dir.join(MANIFEST); + if std::fs::read_to_string(&manifest_path).ok().as_deref() != Some(text.as_str()) { + write_atomic(&manifest_path, text.as_bytes())?; + } + keep.insert(MANIFEST.to_owned()); + Ok(keep) +} + +/// Remove the sidecar's asset files not in `keep` (and the manifest and +/// folder too when `keep` is empty). Only files named like assets are +/// touched: anything else someone put there stays. +/// Remove the assets sidecar of `path` (a bundle carries its own assets, so +/// one left over from when the file was markup is stale). +/// +/// # Errors +/// I/O other than the files already being gone. +pub fn prune_assets(path: &Path) -> Result<(), Error> { + prune(path, &BTreeSet::new()) +} + +fn prune(path: &Path, keep: &BTreeSet) -> Result<(), Error> { + let dir = assets_dir(path); + let Ok(read) = std::fs::read_dir(&dir) else { + return Ok(()); + }; + for entry in read.filter_map(Result::ok) { + let name = entry.file_name().to_string_lossy().into_owned(); + if keep.contains(&name) { + continue; + } + let ours = name == MANIFEST || name.split('.').next().is_some_and(is_asset_id); + if ours { + let p = entry.path(); + match std::fs::remove_file(&p) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(io(&p)(e)), + } + } + } + if keep.is_empty() { + // Fails (and stays) when someone else's files are in it. + let _ = std::fs::remove_dir(&dir); + } + Ok(()) +} + +/// A blake3 hex id. +fn is_asset_id(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +fn plain_name(name: &str) -> bool { + !name.is_empty() && !name.starts_with('.') && !name.contains(['/', '\\']) +} + +/// Write-then-rename, so a watcher (or any reader) never sees half a file. +/// The temp name ends in `.tmp`, which nothing scans as a surface. +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), Error> { + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(io(dir))?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + std::fs::write(&tmp, bytes).map_err(io(&tmp))?; + std::fs::rename(&tmp, path).map_err(io(path)) +} diff --git a/crates/frame/src/document/loro/mod.rs b/crates/frame/src/document/loro/mod.rs new file mode 100644 index 00000000..c742db02 --- /dev/null +++ b/crates/frame/src/document/loro/mod.rs @@ -0,0 +1,604 @@ +//! The live, collaborative scene store: a [`Scene`] held as a Loro CRDT. +//! +//! A [`LoroScene`] is one replica of a document. Local edits are the same +//! typed [`Op`]s the in-memory model takes ([`LoroScene::apply`], +//! [`LoroScene::transact`]) and return the same inverse ops; remote edits +//! arrive as Loro update bytes ([`LoroScene::import`]) and merge. Reads +//! materialize back into a plain [`Scene`] ([`LoroScene::scene`]), so +//! everything downstream (render, tools, the `.frame` bundle) keeps reading +//! only the model. +//! +//! # Schema (inside the `LoroDoc`) +//! +//! ```text +//! "nodes" LoroTree one tree node per scene node, fractional-index ordered +//! └─ meta LoroMap "id" (NodeId uuid string), "name", "kind", and one key +//! per Prop field: visible, locked, transform, size, +//! opacity, blend, corner_radii, clips_content, mask, +//! fills, stroke, effects, fill_geometry, stroke_geometry, +//! svg_snapshot, layout, layout_child, control, source +//! "scene" LoroMap "root" → the root node's id +//! "assets" LoroMap asset id → { mime, size, bytes? (Binary) } +//! ``` +//! +//! Each meta value is the [`LoroValue`](crdt::loro::LoroValue) image of the +//! field's serde JSON form (see [`value`]), written whole: a prop is the +//! unit of last-writer-wins, so concurrent edits to *different* props of +//! one node merge, and edits to the *same* prop resolve to one of them. +//! Parent/child structure and sibling order are the tree's own — Loro's +//! movable tree guarantees concurrent moves converge without cycles or +//! duplicated nodes. +//! +//! # Assets +//! +//! Asset *metadata* always lives in the doc. Where the *bytes* live is the +//! replica's [`AssetStorage`]: +//! +//! - [`AssetStorage::External`] (the default) keeps bytes in a local, +//! content-addressed blob store next to the doc. Assets are immutable +//! and named by their blake3, so they need no merging — keeping them out +//! of the CRDT keeps its history (and every snapshot) small. The `.frame` +//! bundle stores them as separate files. A replica that learns of an +//! asset whose bytes it doesn't hold lists it in +//! [`LoroScene::missing_assets`] and leaves it out of [`LoroScene::scene`] +//! until [`LoroScene::put_blob`] supplies them. +//! - [`AssetStorage::Inline`] also writes the bytes into the doc (a +//! `Binary` value), so plain Loro sync carries them — convenient for small +//! documents without a blob channel, at the cost of snapshot size. +//! +//! Reads accept both shapes regardless of the replica's own mode. + +pub mod value; + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use crate::document::proto::{Asset, AssetId, Node, NodeId, Op, OpError, Prop, Scene}; +use crdt::CrdtDoc; +use crdt::loro::{ + ExportMode, Frontiers, LoroDoc, LoroError, LoroMap, LoroTree, LoroValue, Subscription, TreeID, + ValueOrContainer, VersionVector, +}; +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +/// The tree of scene nodes. +pub const NODES: &str = "nodes"; +/// Scene-level fields (`"root"`). +pub const SCENE: &str = "scene"; +/// Asset metadata (and inline bytes). +pub const ASSETS: &str = "assets"; + +/// Where a replica keeps asset bytes. See the crate docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AssetStorage { + /// Bytes in a local blob store beside the doc; only metadata in the doc. + #[default] + External, + /// Bytes inside the doc as well, synced like any other value. + Inline, +} + +/// Why a [`LoroScene`] edit failed. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The ops don't fit the scene; nothing was written. + #[error(transparent)] + Op(#[from] OpError), + /// Loro rejected a write the model accepted (the doc was changed + /// underneath this replica mid-edit). The replica re-reads the doc on + /// its next access. + #[error("loro: {0}")] + Loro(#[from] LoroError), +} + +/// One replica of a scene. Cheap reads (a cached materialization that is +/// refreshed whenever the doc's version moves), `&self` edits. +pub struct LoroScene { + doc: CrdtDoc, + storage: AssetStorage, + state: Mutex, +} + +struct State { + /// The doc version `scene`/`index` reflect; `None` = stale. + frontiers: Option, + scene: Scene, + index: HashMap, + blobs: BTreeMap>, +} + +impl LoroScene { + /// Wrap a doc (empty, or holding a scene written by any replica), with + /// external asset storage. + #[must_use] + pub fn new(doc: CrdtDoc) -> Self { + Self::with_storage(doc, AssetStorage::External) + } + + /// [`Self::new`] with an explicit [`AssetStorage`]. + #[must_use] + pub fn with_storage(doc: CrdtDoc, storage: AssetStorage) -> Self { + doc.loro().get_tree(NODES).enable_fractional_index(0); + let state = State { + frontiers: None, + scene: empty_scene(), + index: HashMap::new(), + blobs: BTreeMap::new(), + }; + Self { + doc, + storage, + state: Mutex::new(state), + } + } + + /// A fresh ephemeral replica holding `scene` (external asset storage). + #[must_use] + pub fn from_scene(scene: &Scene) -> Self { + Self::from_scene_with(scene, AssetStorage::External) + } + + /// [`Self::from_scene`] with an explicit [`AssetStorage`]. + #[must_use] + pub fn from_scene_with(scene: &Scene, storage: AssetStorage) -> Self { + let this = Self::with_storage(CrdtDoc::ephemeral(), storage); + // A fresh doc accepts every write below; a failure here is a bug. + this.write_scene(scene) + .expect("writing a scene into a fresh doc"); + this + } + + /// A replica restored from snapshot/update bytes (e.g. a bundle's + /// `doc.loro`). With external storage, supply the bytes through + /// [`Self::put_blob`]. + /// + /// # Errors + /// The bytes aren't a Loro export. + pub fn from_snapshot(bytes: &[u8], storage: AssetStorage) -> Result { + let this = Self::with_storage(CrdtDoc::ephemeral(), storage); + this.import(bytes)?; + Ok(this) + } + + /// The underlying doc (sync transport, persistence, time travel). + #[must_use] + pub fn doc(&self) -> &CrdtDoc { + &self.doc + } + + #[must_use] + pub fn storage(&self) -> AssetStorage { + self.storage + } + + /// The current scene, materialized. + #[must_use] + pub fn scene(&self) -> Scene { + self.lock_fresh().scene.clone() + } + + /// Read the current scene without cloning it. + pub fn with_scene(&self, f: impl FnOnce(&Scene) -> R) -> R { + f(&self.lock_fresh().scene) + } + + /// Apply one op; returns the ops that revert it (as [`Scene::apply`]). + /// + /// # Errors + /// [`Error::Op`] leaves the doc untouched. + pub fn apply(&self, op: Op) -> Result, Error> { + let mut st = self.lock_fresh(); + let inverse = st.scene.apply(op.clone())?; + self.write_ops(&mut st, std::slice::from_ref(&op))?; + Ok(inverse) + } + + /// Apply ops atomically — validated against the model first, then + /// written to the doc as a single commit. Returns the inverse + /// transaction (as [`Scene::transact`]). + /// + /// # Errors + /// [`Error::Op`] (the first failing op) leaves the doc untouched. + pub fn transact(&self, ops: impl IntoIterator) -> Result, Error> { + let ops: Vec = ops.into_iter().collect(); + let mut st = self.lock_fresh(); + let mut trial = st.scene.clone(); + let inverse = trial.transact(ops.clone())?; + st.scene = trial; + self.write_ops(&mut st, &ops)?; + Ok(inverse) + } + + /// Merge remote snapshot/update bytes. + /// + /// # Errors + /// The bytes aren't a Loro export (or don't apply to this doc). + pub fn import(&self, bytes: &[u8]) -> Result<(), LoroError> { + self.doc.loro().import(bytes)?; + Ok(()) + } + + /// A full snapshot of the doc (history included; asset bytes only with + /// [`AssetStorage::Inline`]). + /// + /// # Panics + /// Never in practice: a snapshot export of an attached doc can't fail. + #[must_use] + pub fn export_snapshot(&self) -> Vec { + self.doc + .loro() + .export(ExportMode::Snapshot) + .expect("snapshot export") + } + + /// The updates a peer at `since` is missing. + /// + /// # Panics + /// Never in practice: an update export of an attached doc can't fail. + #[must_use] + pub fn export_updates(&self, since: &VersionVector) -> Vec { + self.doc + .loro() + .export(ExportMode::updates(since)) + .expect("updates export") + } + + /// This replica's version vector (what to send a peer to get a delta). + #[must_use] + pub fn version(&self) -> VersionVector { + self.doc.loro().oplog_vv() + } + + /// Call `f` after every change to the doc — local commits and imports. + /// Keep the returned subscription alive for as long as you listen. + pub fn subscribe(&self, f: impl Fn() + Send + Sync + 'static) -> Subscription { + self.doc.loro().subscribe_root(Arc::new(move |_| f())) + } + + /// Store asset bytes in the local blob store (external storage) and + /// return their id. Doesn't add the asset to the scene — that's + /// [`Op::PutAsset`]; this supplies bytes the doc already references. + pub fn put_blob(&self, bytes: Vec) -> AssetId { + let id = AssetId::of(&bytes); + let mut st = self.lock(); + st.blobs.insert(id.clone(), bytes); + st.frontiers = None; + id + } + + /// Every asset the doc references, with its mime type — whether or not + /// this replica holds its bytes. + #[must_use] + pub fn asset_entries(&self) -> Vec<(AssetId, String)> { + let assets = self.doc.loro().get_map(ASSETS); + let mut out = Vec::new(); + assets.for_each(|k, v| { + if let ValueOrContainer::Value(LoroValue::Map(m)) = v { + let mime = match m.get("mime") { + Some(LoroValue::String(s)) => s.to_string(), + _ => String::new(), + }; + out.push((AssetId(k.to_string()), mime)); + } + }); + out.sort(); + out + } + + /// Assets the doc references whose bytes this replica doesn't have. + #[must_use] + pub fn missing_assets(&self) -> Vec { + self.with_scene(|scene| { + self.asset_entries() + .into_iter() + .map(|(id, _)| id) + .filter(|id| !scene.assets.contains_key(id)) + .collect() + }) + } + + // ── internals ──────────────────────────────────────────────────────── + + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The state, rematerialized if the doc moved since it was built. + fn lock_fresh(&self) -> MutexGuard<'_, State> { + let mut st = self.lock(); + let now = self.doc.loro().state_frontiers(); + if st.frontiers.as_ref() != Some(&now) { + let (scene, index) = materialize(self.doc.loro(), &st.blobs); + st.scene = scene; + st.index = index; + st.frontiers = Some(now); + } + st + } + + /// Write validated ops (already applied to `st.scene`) and commit once. + fn write_ops(&self, st: &mut State, ops: &[Op]) -> Result<(), Error> { + let doc = self.doc.loro(); + let result = ops.iter().try_for_each(|op| self.write_op(doc, st, op)); + doc.commit(); + match result { + Ok(()) => { + st.frontiers = Some(doc.state_frontiers()); + Ok(()) + } + Err(e) => { + st.frontiers = None; + Err(e.into()) + } + } + } + + fn write_op(&self, doc: &LoroDoc, st: &mut State, op: &Op) -> Result<(), LoroError> { + let tree = doc.get_tree(NODES); + let tid = |st: &State, id: NodeId| { + st.index + .get(&id) + .copied() + .ok_or(LoroError::NotFoundError(id.to_string().into())) + }; + match op { + Op::Insert { + node, + parent, + index, + } => { + let parent = tid(st, *parent)?; + let len = tree.children_num(parent).unwrap_or(0); + let at = (*index as usize).min(len); + let created = tree.create_at(parent, at)?; + write_node(&tree.get_meta(created)?, node)?; + st.index.insert(node.id, created); + } + Op::Remove { id } => tree.delete(tid(st, *id)?)?, + Op::Move { id, parent, index } => { + let target = tid(st, *id)?; + let parent = tid(st, *parent)?; + // `Scene` semantics: take the node out, then insert at `index` + // (clamped) — i.e. `index` is the final position. + let others = tree + .children(parent) + .unwrap_or_default() + .into_iter() + .filter(|c| *c != target) + .count(); + tree.mov_to(target, parent, (*index as usize).min(others))?; + } + Op::Set { id, prop } => { + let meta = tree.get_meta(tid(st, *id)?)?; + let (key, value) = prop_entry(prop); + meta.insert(key, value::to_loro(&value))?; + } + Op::PutAsset { asset } => self.write_asset(doc, st, asset)?, + Op::DropAsset { id } => { + doc.get_map(ASSETS).delete(&id.0)?; + st.blobs.remove(id); + } + } + Ok(()) + } + + fn write_asset(&self, doc: &LoroDoc, st: &mut State, asset: &Asset) -> Result<(), LoroError> { + let mut entry: HashMap = HashMap::new(); + entry.insert("mime".into(), asset.mime.as_str().into()); + entry.insert( + "size".into(), + LoroValue::I64(i64::try_from(asset.bytes.len()).unwrap_or(i64::MAX)), + ); + match self.storage { + AssetStorage::Inline => { + entry.insert("bytes".into(), asset.bytes.clone().into()); + } + AssetStorage::External => { + st.blobs.insert(asset.id.clone(), asset.bytes.clone()); + } + } + doc.get_map(ASSETS) + .insert(&asset.id.0, LoroValue::Map(entry.into())) + } + + /// Write a whole scene into this (empty) replica, as one commit. + fn write_scene(&self, scene: &Scene) -> Result<(), LoroError> { + let doc = self.doc.loro(); + let tree = doc.get_tree(NODES); + let mut st = self.lock(); + doc.get_map(SCENE).insert("root", scene.root.to_string())?; + let mut stack: Vec<(NodeId, Option)> = vec![(scene.root, None)]; + while let Some((id, parent)) = stack.pop() { + let Some(node) = scene.node(id) else { continue }; + let created = tree.create(parent)?; + write_node(&tree.get_meta(created)?, node)?; + st.index.insert(id, created); + // Reverse push → children are created (appended) in order. + stack.extend(node.children.iter().rev().map(|c| (*c, Some(created)))); + } + for asset in scene.assets.values() { + self.write_asset(doc, &mut st, asset)?; + } + doc.commit(); + st.frontiers = None; + Ok(()) + } +} + +impl std::fmt::Debug for LoroScene { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoroScene") + .field("storage", &self.storage) + .finish_non_exhaustive() + } +} + +// ── encoding ───────────────────────────────────────────────────────────── + +/// The fields of `Node` that are structure, not meta. +const STRUCTURAL: [&str; 2] = ["parent", "children"]; + +/// Serialize a model value. The model's types are plain data (string map +/// keys, no custom serializers), so this can't fail. +fn json(value: &T) -> Value { + serde_json::to_value(value).unwrap_or(Value::Null) +} + +/// Write every meta key of `node` (all fields but parent/children). +fn write_node(meta: &LoroMap, node: &Node) -> Result<(), LoroError> { + let Value::Object(fields) = json(node) else { + return Ok(()); + }; + for (key, value) in &fields { + if !STRUCTURAL.contains(&key.as_str()) { + meta.insert(key, value::to_loro(value))?; + } + } + Ok(()) +} + +/// Read a node (children empty, parent unset) back out of its meta. +fn read_node(meta: &LoroMap) -> Option { + let LoroValue::Map(map) = meta.get_value() else { + return None; + }; + let Value::Object(mut fields) = value::from_loro(&LoroValue::Map(map)) else { + return None; + }; + fields.insert("parent".into(), Value::Null); + fields.insert("children".into(), Value::Array(Vec::new())); + serde_json::from_value(Value::Object(fields)).ok() +} + +fn meta_id(meta: &LoroMap) -> Option { + match meta.get("id") { + Some(ValueOrContainer::Value(LoroValue::String(s))) => Uuid::parse_str(&s).ok().map(NodeId), + _ => None, + } +} + +/// The meta key and serialized value a `Prop` assigns — the key is the +/// `Node` field the prop sets. +fn prop_entry(prop: &Prop) -> (&'static str, Value) { + match prop { + Prop::Name(v) => ("name", json(v)), + Prop::Kind(v) => ("kind", json(v)), + Prop::Visible(v) => ("visible", json(v)), + Prop::Locked(v) => ("locked", json(v)), + Prop::Transform(v) => ("transform", json(v)), + Prop::Size(v) => ("size", json(v)), + Prop::Opacity(v) => ("opacity", json(v)), + Prop::Blend(v) => ("blend", json(v)), + Prop::CornerRadii(v) => ("corner_radii", json(v)), + Prop::ClipsContent(v) => ("clips_content", json(v)), + Prop::Mask(v) => ("mask", json(v)), + Prop::Fills(v) => ("fills", json(v)), + Prop::Stroke(v) => ("stroke", json(v)), + Prop::Effects(v) => ("effects", json(v)), + Prop::FillGeometry(v) => ("fill_geometry", json(v)), + Prop::StrokeGeometry(v) => ("stroke_geometry", json(v)), + Prop::SvgSnapshot(v) => ("svg_snapshot", json(v)), + Prop::Layout(v) => ("layout", json(v)), + Prop::LayoutChild(v) => ("layout_child", json(v)), + Prop::Control(v) => ("control", json(v)), + Prop::Emission(v) => ("emission", json(v)), + Prop::Shader(v) => ("shader", json(v)), + Prop::Motion(v) => ("motion", json(v)), + } +} + +// ── materialization ────────────────────────────────────────────────────── + +/// A placeholder for a doc that holds no scene yet. +fn empty_scene() -> Scene { + Scene { + root: NodeId(Uuid::nil()), + nodes: BTreeMap::new(), + assets: BTreeMap::new(), + variables: crate::document::proto::Variables::default(), + } +} + +/// Read the doc back into a `Scene`, plus the `NodeId → TreeID` index. +/// +/// Lenient by design — a replica must render whatever peers sent: tree +/// nodes that don't decode are skipped with their subtrees, and if two +/// peers concurrently inserted the same `NodeId`, the lowest `TreeID` wins +/// (on every replica alike) and the other copy is ignored. +fn materialize( + doc: &LoroDoc, + blobs: &BTreeMap>, +) -> (Scene, HashMap) { + let tree: LoroTree = doc.get_tree(NODES); + let mut index: HashMap = HashMap::new(); + for tid in tree.nodes() { + if tree.is_node_deleted(&tid).unwrap_or(true) { + continue; + } + let Some(id) = tree.get_meta(tid).ok().as_ref().and_then(meta_id) else { + continue; + }; + index + .entry(id) + .and_modify(|t| *t = (*t).min(tid)) + .or_insert(tid); + } + + let mut scene = empty_scene(); + scene.assets = read_assets(doc, blobs); + let root = match doc.get_map(SCENE).get("root") { + Some(ValueOrContainer::Value(LoroValue::String(s))) => Uuid::parse_str(&s).ok().map(NodeId), + _ => None, + }; + let Some(root) = root else { + return (scene, index); + }; + scene.root = root; + let Some(root_tid) = index.get(&root).copied() else { + return (scene, index); + }; + let Some(root_node) = tree.get_meta(root_tid).ok().as_ref().and_then(read_node) else { + return (scene, index); + }; + + let mut stack = vec![(root_tid, root_node)]; + while let Some((tid, mut node)) = stack.pop() { + for child_tid in tree.children(tid).unwrap_or_default() { + let Some(mut child) = tree.get_meta(child_tid).ok().as_ref().and_then(read_node) else { + continue; + }; + if index.get(&child.id) != Some(&child_tid) { + continue; + } + child.parent = Some(node.id); + node.children.push(child.id); + stack.push((child_tid, child)); + } + scene.nodes.insert(node.id, node); + } + (scene, index) +} + +fn read_assets(doc: &LoroDoc, blobs: &BTreeMap>) -> BTreeMap { + let mut out = BTreeMap::new(); + doc.get_map(ASSETS).for_each(|key, v| { + let ValueOrContainer::Value(LoroValue::Map(entry)) = v else { + return; + }; + let id = AssetId(key.to_string()); + let mime = match entry.get("mime") { + Some(LoroValue::String(s)) => s.to_string(), + _ => String::new(), + }; + let bytes = match entry.get("bytes") { + Some(LoroValue::Binary(b)) => Some(b.to_vec()), + _ => blobs.get(&id).cloned(), + }; + if let Some(bytes) = bytes { + out.insert(id.clone(), Asset { id, mime, bytes }); + } + }); + out +} diff --git a/crates/frame/src/document/loro/value.rs b/crates/frame/src/document/loro/value.rs new file mode 100644 index 00000000..fdeb3db5 --- /dev/null +++ b/crates/frame/src/document/loro/value.rs @@ -0,0 +1,65 @@ +//! Lossless `serde_json::Value` ⇄ [`LoroValue`] conversion. +//! +//! Every typed prop is stored in the Loro doc as the `LoroValue` image of +//! its serde JSON representation. The mapping is structural: +//! +//! | JSON | Loro | +//! | ------------------------- | -------------------- | +//! | `null` / bool / string | `Null` / `Bool` / `String` | +//! | integer (fits `i64`) | `I64` | +//! | float | `Double` (bit-exact) | +//! | array / object | `List` / `Map` | +//! +//! Floats round-trip bit-for-bit: serde_json keeps an `f64` as an `f64`, +//! and `Double` stores it unchanged. Integers larger than `i64::MAX` (no +//! field of the model has them) fall back to `Double`. The reverse +//! direction maps `Binary` to an array of byte numbers and a `Container` +//! reference to its id string — neither is produced by [`to_loro`]. + +use crdt::loro::LoroValue; +use serde_json::{Map, Number, Value}; + +/// JSON → Loro. +#[must_use] +pub fn to_loro(value: &Value) -> LoroValue { + match value { + Value::Null => LoroValue::Null, + Value::Bool(b) => LoroValue::Bool(*b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + LoroValue::I64(i) + } else { + // A float (or a u64 beyond i64): `as_f64` is exact for floats. + LoroValue::Double(n.as_f64().unwrap_or(f64::NAN)) + } + } + Value::String(s) => LoroValue::String(s.as_str().into()), + Value::Array(items) => LoroValue::List(items.iter().map(to_loro).collect()), + Value::Object(map) => { + LoroValue::Map(map.iter().map(|(k, v)| (k.clone(), to_loro(v))).collect()) + } + } +} + +/// Loro → JSON. +#[must_use] +pub fn from_loro(value: &LoroValue) -> Value { + match value { + LoroValue::Null => Value::Null, + LoroValue::Bool(b) => Value::Bool(*b), + // Non-finite doubles have no JSON form (serde_json writes them as `null` too). + LoroValue::Double(d) => Number::from_f64(*d).map_or(Value::Null, Value::Number), + LoroValue::I64(i) => Value::Number((*i).into()), + LoroValue::String(s) => Value::String(s.to_string()), + LoroValue::Binary(b) => { + Value::Array(b.iter().map(|x| Value::Number((*x).into())).collect()) + } + LoroValue::List(items) => Value::Array(items.iter().map(from_loro).collect()), + LoroValue::Map(map) => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), from_loro(v))) + .collect::>(), + ), + LoroValue::Container(id) => Value::String(id.to_string()), + } +} diff --git a/crates/frame/src/document/mod.rs b/crates/frame/src/document/mod.rs new file mode 100644 index 00000000..ffa5663d --- /dev/null +++ b/crates/frame/src/document/mod.rs @@ -0,0 +1,14 @@ +//! Documents: the frame document model, its text form, file IO, bundles and the Loro CRDT store. + +pub mod proto; + +pub mod text; + +#[cfg(feature = "io")] +pub mod io; + +#[cfg(feature = "io")] +pub mod bundle; + +#[cfg(feature = "io")] +pub mod loro; diff --git a/crates/frame/src/document/proto/hug.rs b/crates/frame/src/document/proto/hug.rs new file mode 100644 index 00000000..9aac407d --- /dev/null +++ b/crates/frame/src/document/proto/hug.rs @@ -0,0 +1,75 @@ +//! "Hug contents": auto-layout containers flagged `hug_main`/`hug_cross` +//! size themselves to their children. An authoring rule, applied by editors +//! as part of each edit (so it's undoable and synced like any other change); +//! renderers just use the stored sizes. + +use crate::document::proto::model::{LayoutDirection, NodeId, Scene, Size}; +use crate::document::proto::ops::{Op, Prop}; + +impl Scene { + /// `Set Size` ops that bring every hugging container under `root` to its + /// content size (children first, so nested hugs compose). + #[must_use] + pub fn hug_ops(&self, root: NodeId) -> Vec { + let mut sizes: std::collections::HashMap = std::collections::HashMap::new(); + let mut ops = Vec::new(); + // Post-order: reverse of a pre-order walk visits children before parents. + for id in self.walk(root).into_iter().rev() { + let Some(node) = self.node(id) else { continue }; + let Some(al) = &node.layout else { continue }; + if !(al.hug_main || al.hug_cross) { + continue; + } + let kids: Vec = node + .children + .iter() + .filter_map(|c| self.node(*c)) + .filter(|c| c.visible && !c.layout_child.absolute) + .map(|c| sizes.get(&c.id).copied().unwrap_or(c.size)) + .collect(); + let gaps = al.gap * kids.len().saturating_sub(1) as f64; + let row = matches!(al.direction, LayoutDirection::Horizontal); + let along: f64 = kids + .iter() + .map(|s| if row { s.width } else { s.height }) + .sum::() + + gaps; + let across = kids + .iter() + .map(|s| if row { s.height } else { s.width }) + .fold(0.0, f64::max); + let (pad_w, pad_h) = ( + al.padding_left + al.padding_right, + al.padding_top + al.padding_bottom, + ); + let mut size = node.size; + let (main, cross) = if row { + (along + pad_w, across + pad_h) + } else { + (along + pad_h, across + pad_w) + }; + if al.hug_main { + if row { + size.width = main + } else { + size.height = main + } + } + if al.hug_cross { + if row { + size.height = cross + } else { + size.width = cross + } + } + sizes.insert(id, size); + if size != node.size { + ops.push(Op::Set { + id, + prop: Prop::Size(size), + }); + } + } + ops + } +} diff --git a/crates/frame/src/document/proto/mod.rs b/crates/frame/src/document/proto/mod.rs new file mode 100644 index 00000000..6b35b3f2 --- /dev/null +++ b/crates/frame/src/document/proto/mod.rs @@ -0,0 +1,13 @@ +//! The `document` contract: frame's own document model ([`model`]) and the +//! typed edits over it ([`ops`]). + +mod hug; +pub mod model; +pub mod ops; +pub mod shader_input; + +/// The motion contract nodes carry (`Node::motion`). +pub use crate::motion::proto as motion; +pub use model::*; +pub use ops::{History, Op, OpError, Prop}; +pub use shader_input::{InputMap, ShaderInput, ShaderInputSource}; diff --git a/crates/frame/src/document/proto/model.rs b/crates/frame/src/document/proto/model.rs new file mode 100644 index 00000000..d8e68bdb --- /dev/null +++ b/crates/frame/src/document/proto/model.rs @@ -0,0 +1,1027 @@ +//! The frame document model. +//! +//! A `Scene` is a tree of typed `Node`s plus a content-addressed asset table. +//! This is frame's own model: importers (Figma REST, `.fig`) translate into +//! it once, and everything downstream (render, surface runtime, AI tools, +//! the `.frame` bundle) reads only this. +//! +//! Values are stored *as authored* (paint opacity, stroke weight, node +//! opacity…); resolution into draw calls (opacity inheritance, layers, +//! clip decisions) is the renderer's job. + +use std::collections::BTreeMap; + +use facet::Facet; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── ids ────────────────────────────────────────────────────────────────── + +/// A node's identity. Stable across saves, edits and replicas. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Facet, +)] +#[facet(transparent)] +#[serde(transparent)] +pub struct NodeId(pub Uuid); + +impl NodeId { + #[must_use] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for NodeId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// An asset's identity: the lowercase hex blake3 of its bytes, so the same +/// image imported twice is stored once. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Facet)] +#[facet(transparent)] +#[serde(transparent)] +pub struct AssetId(pub String); + +impl AssetId { + #[must_use] + pub fn of(bytes: &[u8]) -> Self { + Self(blake3::hash(bytes).to_hex().to_string()) + } +} + +impl std::fmt::Display for AssetId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +// ── geometry ───────────────────────────────────────────────────────────── + +/// Row-major 2×3 affine: `x' = a·x + b·y + tx`, `y' = c·x + d·y + ty` +/// (the same layout Figma's `relativeTransform` uses). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Facet)] +pub struct Transform { + pub a: f64, + pub b: f64, + pub tx: f64, + pub c: f64, + pub d: f64, + pub ty: f64, +} + +impl Transform { + pub const IDENTITY: Self = Self { + a: 1.0, + b: 0.0, + tx: 0.0, + c: 0.0, + d: 1.0, + ty: 0.0, + }; + + #[must_use] + pub const fn translate(tx: f64, ty: f64) -> Self { + Self { + a: 1.0, + b: 0.0, + tx, + c: 0.0, + d: 1.0, + ty, + } + } + + /// Rotation in radians, from the matrix columns (`atan2(c, a)`). + #[must_use] + pub fn rotation(&self) -> f64 { + self.c.atan2(self.a) + } +} + +impl Default for Transform { + fn default() -> Self { + Self::IDENTITY + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct Size { + pub width: f64, + pub height: f64, +} + +/// An axis-aligned rectangle. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct CornerRadii { + pub top_left: f64, + pub top_right: f64, + pub bottom_right: f64, + pub bottom_left: f64, +} + +impl CornerRadii { + #[must_use] + pub const fn uniform(r: f64) -> Self { + Self { + top_left: r, + top_right: r, + bottom_right: r, + bottom_left: r, + } + } + + #[must_use] + pub fn is_zero(&self) -> bool { + self.top_left == 0.0 + && self.top_right == 0.0 + && self.bottom_right == 0.0 + && self.bottom_left == 0.0 + } +} + +// ── paint ──────────────────────────────────────────────────────────────── + +/// Straight (non-premultiplied) RGBA, each channel 0..=1. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Facet)] +pub struct Color { + pub r: f64, + pub g: f64, + pub b: f64, + pub a: f64, +} + +impl Color { + #[must_use] + pub const fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self { + Self { r, g, b, a } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum BlendMode { + #[default] + Normal, + PassThrough, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, + LinearBurn, + LinearDodge, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct GradientStop { + pub position: f64, + pub color: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum ImageScaleMode { + #[default] + Fill, + Fit, + Crop, + Tile, + Stretch, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum PaintKind { + Solid { + color: Color, + }, + LinearGradient { + stops: Vec, + transform: Option, + }, + RadialGradient { + stops: Vec, + transform: Option, + }, + AngularGradient { + stops: Vec, + transform: Option, + }, + Image { + /// The image bytes, in the scene's asset table. + asset: AssetId, + /// The source tool's own reference (Figma `imageHash`), for re-import. + source_ref: Option, + scale_mode: ImageScaleMode, + transform: Option, + }, +} + +/// One entry of a fill or stroke paint stack. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Paint { + pub kind: PaintKind, + /// Paint opacity, multiplied into the paint's colors when drawn. + pub opacity: f64, + pub blend: BlendMode, + pub visible: bool, +} + +impl Paint { + #[must_use] + pub const fn solid(color: Color) -> Self { + Self { + kind: PaintKind::Solid { color }, + opacity: 1.0, + blend: BlendMode::Normal, + visible: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum StrokeCap { + #[default] + None, + Round, + Square, + LineArrow, + TriangleArrow, + DiamondFilled, + CircleFilled, + TriangleFilled, + WashiTape1, + WashiTape2, + WashiTape3, + WashiTape4, + WashiTape5, + WashiTape6, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum StrokeJoin { + #[default] + Miter, + Bevel, + Round, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum StrokeAlign { + Inside, + Outside, + #[default] + Center, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Stroke { + pub paints: Vec, + pub weight: f64, + pub cap: StrokeCap, + pub join: StrokeJoin, + pub align: StrokeAlign, + pub miter_limit: f64, + pub dash_pattern: Vec, + pub dash_offset: f64, +} + +impl Default for Stroke { + fn default() -> Self { + Self { + paints: Vec::new(), + weight: 1.0, + cap: StrokeCap::None, + join: StrokeJoin::Miter, + align: StrokeAlign::Center, + miter_limit: 4.0, + dash_pattern: Vec::new(), + dash_offset: 0.0, + } + } +} + +// ── effects ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum EffectKind { + DropShadow, + InnerShadow, + LayerBlur, + BackgroundBlur, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Effect { + pub kind: EffectKind, + pub visible: bool, + pub color: Color, + pub offset_x: f64, + pub offset_y: f64, + pub radius: f64, + pub spread: f64, +} + +// ── text ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum TextCase { + #[default] + Original, + Upper, + Lower, + Title, + SmallCaps, + SmallCapsForced, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum TextAlign { + #[default] + Left, + Center, + Right, + Justified, +} + +/// A styled span of a text node. Unset fields inherit the node's style. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct TextRun { + pub text: String, + pub fills: Vec, + pub font_family: Option, + pub font_size: Option, + pub font_weight: Option, + /// A style name: `"italic"`, Figma's `"Semi Bold Italic"`, + /// `"condensed"`. Weight and width words apply where `font_weight` / + /// `font_width` are unset. + pub font_style: Option, + /// Width, percent of normal (CSS `font-stretch`): 75 condensed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub font_width: Option, + pub letter_spacing: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct TextData { + pub characters: String, + /// A family name (`"Oswald"`, `"DSEG7 Classic"`) or a generic one + /// (`"monospace"`); `None` = the host's UI face. Missing families and + /// glyphs fall back through the host's font book. + pub font_family: Option, + pub font_size: f64, + /// 1–1000 (400 regular, 700 bold); `None` = regular. + pub font_weight: Option, + /// A style name: `"italic"`, `"Bold Italic"`, `"condensed"` (see + /// [`TextRun::font_style`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub font_style: Option, + /// Width, percent of normal (CSS `font-stretch`): 75 condensed, 125 + /// expanded. Variable fonts with a `wdth` axis draw it exactly. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub font_width: Option, + /// Line height in px; `None` = the font's natural line height. + pub line_height: Option, + /// Letter spacing in px. + pub letter_spacing: f64, + pub text_case: TextCase, + pub align: TextAlign, + pub runs: Vec, +} + +impl Default for TextData { + fn default() -> Self { + Self { + characters: String::new(), + font_family: None, + font_size: 14.0, + font_weight: None, + font_style: None, + font_width: None, + line_height: None, + letter_spacing: 0.0, + text_case: TextCase::Original, + align: TextAlign::Left, + runs: Vec::new(), + } + } +} + +// ── layout ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum LayoutDirection { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum MainAxisAlign { + #[default] + Start, + Center, + End, + SpaceBetween, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum CrossAxisAlign { + #[default] + Start, + Center, + End, + Baseline, +} + +/// Flex-style auto layout on a container. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct AutoLayout { + pub direction: LayoutDirection, + pub gap: f64, + pub padding_left: f64, + pub padding_right: f64, + pub padding_top: f64, + pub padding_bottom: f64, + pub wrap: bool, + pub main_align: Option, + pub cross_align: Option, + /// Size to content along the layout direction ("hug contents"). An + /// authoring rule: editors recompute the size when children change; + /// the renderer uses the stored size. + #[serde(default)] + #[facet(default)] + pub hug_main: bool, + /// Size to content across the layout direction. + #[serde(default)] + #[facet(default)] + pub hug_cross: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum AlignSelf { + Start, + Center, + End, + Stretch, +} + +/// How a node follows its parent's width when the parent is laid out +/// wider or narrower than it was drawn — a responsive host fitting a +/// surface to a box of other proportions ([`Scene::resize_width`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum ResizeX { + /// Keeps its distance from the parent's left edge. + Left, + /// Keeps its distance from the parent's right edge. + Right, + /// Keeps its offset from the parent's centre. + Center, + /// Keeps both distances: its width takes the change. + Stretch, + /// Drawn in proportion: its position scaled and the node itself + /// squeezed or stretched with the change (its transform, so its + /// outlines and children scale with it) — for a drawing that must hold + /// together, not for a control. + Scale, +} + +/// How a node follows its parent's height changing — [`ResizeX`] on the +/// other axis. A mixer strip is laid out for a height and lives at many: +/// what hangs from the top stays, what sits on the floor follows it down, +/// and the fader's travel takes the difference. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum ResizeY { + /// Keeps its distance from the parent's top edge. + Top, + /// Keeps its distance from the parent's bottom edge. + Bottom, + /// Keeps its offset from the parent's centre. + Center, + /// Keeps both distances: its height takes the change. + Stretch, + /// Drawn in proportion (see [`ResizeX::Scale`]). + Scale, +} + +/// How a node sits inside an auto-layout parent. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct LayoutChild { + pub grow: Option, + pub align_self: Option, + /// Taken out of flow and placed at its own transform. + pub absolute: bool, + pub min_width: Option, + pub min_height: Option, + pub max_width: Option, + pub max_height: Option, + /// How it follows its parent's width changing ([`ResizeX`]). `None`: + /// it stretches when it spans the parent, else keeps its size and + /// moves in proportion (a knob, a label never distorts). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub resize_x: Option, + /// How it follows its parent's height changing ([`ResizeY`]). `None`: + /// the same default as `resize_x`, on the vertical axis. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub resize_y: Option, +} + +// ── nodes ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum BooleanOp { + Union, + Subtract, + Intersect, + Exclude, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum NodeKind { + /// The scene root. Its children are pages. + Document, + Page, + Frame, + Group, + Component, + ComponentSet, + Instance { + /// The main component, when it is part of this scene. + component: Option, + }, + Rectangle, + Ellipse, + Line, + Polygon, + Star, + Vector, + BooleanOperation { + op: Option, + }, + Text(TextData), + /// Non-rendering helpers (slices, embeds, widgets). + Utility, + /// A node type this model has no dedicated kind for yet; `tag` names it. + Other { + tag: String, + }, +} + +/// Light a node emits (see [`Node::emission`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Emission { + pub color: Color, + /// Brightness multiplier of the bloom (1 = the node's own brightness). + pub intensity: f64, + /// How far the light spreads, px. + pub radius: f64, +} + +/// A shader post-effect (see [`Node::shader`]). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, Facet)] +pub struct ShaderFx { + /// A built-in effect id (`glass`, `crt`, `lighting`, `flicker`, …) or + /// `asset:` for custom WGSL stored in the document's asset table. + pub shader: String, + /// Named numeric uniforms (`strength`, `tint_r`, …). Unknown names are + /// ignored by the effect. + #[serde(default)] + #[facet(default)] + pub params: BTreeMap, + /// Live data the shader reads each frame (feeds, params, curves), + /// by name — `IN_` in WGSL. See [`ShaderInput`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[facet(default)] + pub inputs: Vec, +} + +/// Where a node came from, so re-imports can update in place. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct SourceRef { + /// e.g. `"figma"`. + pub tool: String, + /// The source tool's node id (Figma `"12:34"`). + pub id: String, + /// The source tool's node type (Figma `"FRAME"`). + pub node_type: String, + /// The node's absolute, axis-aligned bounds as the source tool computed + /// them at import (Figma `absoluteBoundingBox`). A snapshot for exporters + /// that reproduce the source's own output; edits don't maintain it. + pub bounds: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum MaskKind { + Alpha, + Vector, + Luminance, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Node { + pub id: NodeId, + pub name: String, + pub kind: NodeKind, + pub parent: Option, + pub children: Vec, + pub visible: bool, + pub locked: bool, + + /// Placement relative to the parent. + pub transform: Transform, + pub size: Size, + pub opacity: f64, + pub blend: BlendMode, + pub corner_radii: CornerRadii, + pub clips_content: bool, + /// When set, this node masks the siblings painted after it. + pub mask: Option, + + pub fills: Vec, + pub stroke: Stroke, + pub effects: Vec, + + /// Vector outlines as SVG path data, in node-local coordinates. + pub fill_geometry: Vec, + pub stroke_geometry: Vec, + /// A pre-rendered SVG of the whole node (an exporter's snapshot), used + /// where native geometry can't reproduce it (booleans, detached instances). + pub svg_snapshot: Option, + + pub layout: Option, + pub layout_child: LayoutChild, + + /// Makes this node a control (knob, fader, footswitch…): its binding to + /// an engine and the descendant nodes it drives. + pub control: Option, + + /// Light this node gives off (VU lamps, jewels, lit caps, LEDs). The GPU + /// effects stage blooms it onto its surroundings; renderers without one + /// fall back to a soft blurred glow. Scales with the node's opacity, so + /// an LED slot dimming a lamp dims its light too. + #[serde(default)] + #[facet(default)] + pub emission: Option, + /// A post-effect on this node's subtree, run by the GPU effects stage + /// (glass, CRT, lighting, …). Optional by design: renderers without it + /// draw the subtree plainly, so a surface must read without it. + #[serde(default)] + #[facet(default)] + pub shader: Option, + /// Animations on this node: each drives one property from params, + /// feeds, time or gestures through physical dynamics (springs, + /// struck resonators, LFOs, noise…). Run by the surface runtime every + /// frame on the render copy; the authored node is what shows at rest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[facet(default)] + pub motion: Vec, + /// Properties computed from expressions instead of stored: a theme's + /// tokens (`$strip.ground`), the box the node is laid out in (`w`, + /// `h`), and the host's data (`track.name`, `track.volume`). See + /// [`Bind`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[facet(default)] + pub bind: Vec, + + /// On an instance: the component it places, by name — resolved by + /// whatever expands instances (the theme runtime, across a theme's + /// files), not by id, so a theme can swap the component it names. + /// `None` for an instance that tracks its component by id alone. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub of: Option, + + /// A component's arguments: on a component, each one it takes and its + /// default; on an instance, the value it is given. Expressions in the + /// theme language, read inside the component as `arg.`. + /// See [`Arg`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[facet(default)] + pub args: Vec, + + pub source: Option, +} + +/// One argument of a component (`arg:=""` in markup). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Facet)] +pub struct Arg { + pub name: String, + pub expr: String, +} + +/// One property of a node computed from an expression. +/// +/// The expression language and what each property accepts belong to the +/// theme runtime (`theme-expr`); the document only carries the source, so +/// a document with bindings still loads, prints and diffs everywhere. The +/// stored property is what shows where nothing evaluates the binding (an +/// editor, an SVG export): author it as the binding's typical value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Facet)] +pub struct Bind { + /// The property, by its markup attribute name: `fill`, `stroke`, + /// `opacity`, `visible`, `text`, `x`, `y`, `w`, `h`, `radius`, + /// `rotation` (degrees clockwise, about the node's origin), `dash` + /// (how much of the stroke is drawn, px from its start), `inset-top` + /// `-bottom` `-left` `-right` (the node's own box shrunk, its children + /// left where they are: a meter's lit part revealing a fixed + /// gradient), `size` (a text's font size). + pub prop: String, + /// The expression's source. + pub expr: String, +} + +// ── variables ──────────────────────────────────────────────────────────── + +/// A document's design tokens: named values a binding reads as `$name`, +/// each with a value per mode. Switching mode is switching theme without +/// touching a node. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Facet)] +pub struct Variables { + /// The modes, in order; the first is the default. + pub modes: Vec, + /// Each variable, by name (`strip.ground`, `meter.hot`). + pub vars: BTreeMap, +} + +impl Variables { + #[must_use] + pub fn is_empty(&self) -> bool { + self.modes.is_empty() && self.vars.is_empty() + } +} + +/// One variable: an expression per mode, and one for modes that don't say. +/// Values are expressions in the binding language, so one variable can +/// alias another (`$accent`) or derive from it (`mix($ground, $ink, 0.2)`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Facet)] +pub struct Variable { + pub value: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[facet(default)] + pub modes: BTreeMap, +} + +impl Variable { + /// The expression this variable has in `mode`. + #[must_use] + pub fn in_mode(&self, mode: &str) -> &str { + self.modes + .get(mode) + .map_or(self.value.as_str(), String::as_str) + } +} + +impl Node { + /// A fresh node of `kind` with neutral defaults. + #[must_use] + pub fn new(kind: NodeKind, name: impl Into) -> Self { + Self { + id: NodeId::new(), + name: name.into(), + kind, + parent: None, + children: Vec::new(), + visible: true, + locked: false, + transform: Transform::IDENTITY, + size: Size::default(), + opacity: 1.0, + blend: BlendMode::Normal, + corner_radii: CornerRadii::default(), + clips_content: false, + mask: None, + fills: Vec::new(), + stroke: Stroke::default(), + effects: Vec::new(), + fill_geometry: Vec::new(), + stroke_geometry: Vec::new(), + svg_snapshot: None, + layout: None, + layout_child: LayoutChild::default(), + control: None, + emission: None, + shader: None, + motion: Vec::new(), + bind: Vec::new(), + of: None, + args: Vec::new(), + source: None, + } + } + + #[must_use] + pub fn text(&self) -> Option<&TextData> { + match &self.kind { + NodeKind::Text(t) => Some(t), + _ => None, + } + } +} + +// ── assets + scene ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Facet)] +pub struct Asset { + pub id: AssetId, + /// e.g. `image/png`, `image/svg+xml`, `font/ttf`. + pub mime: String, + pub bytes: Vec, +} + +impl Asset { + #[must_use] + pub fn new(mime: impl Into, bytes: Vec) -> Self { + Self { + id: AssetId::of(&bytes), + mime: mime.into(), + bytes, + } + } +} + +/// A whole document: the node tree and the assets it references. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Scene { + pub root: NodeId, + pub nodes: BTreeMap, + pub assets: BTreeMap, + /// The document's design tokens. + #[serde(default, skip_serializing_if = "Variables::is_empty")] + #[facet(default)] + pub variables: Variables, +} + +impl Scene { + /// Lay `node` out `width` wide instead of what it was drawn, its + /// descendants following by their [`ResizeX`]: what a responsive host + /// does to fit a surface to a box of other proportions without + /// distorting it. + pub fn resize_width(&mut self, node: NodeId, width: f64) { + let Some(n) = self.nodes.get_mut(&node) else { + return; + }; + let old = n.size.width; + if old <= 0.0 || (width - old).abs() < 1e-6 { + return; + } + n.size.width = width; + self.follow_width(node, old, width); + } + + fn follow_width(&mut self, parent: NodeId, old: f64, new: f64) { + let kids = self + .nodes + .get(&parent) + .map(|p| p.children.clone()) + .unwrap_or_default(); + let (dw, ratio) = (new - old, new / old); + for id in kids { + let Some(n) = self.nodes.get_mut(&id) else { + continue; + }; + let (x, w) = (n.transform.tx, n.size.width); + let spans = w >= old * 0.95 && x <= old * 0.03; + let rule = n.layout_child.resize_x.unwrap_or(if spans { + ResizeX::Stretch + } else { + ResizeX::Center + }); + let proportional = n.layout_child.resize_x.is_none() && !spans; + let (nx, nw) = match rule { + ResizeX::Left => (x, w), + ResizeX::Right => (x + dw, w), + // The default: its centre where the change puts it. + ResizeX::Center if proportional => ((x + w / 2.0) * ratio - w / 2.0, w), + ResizeX::Center => (x + dw / 2.0, w), + ResizeX::Stretch => (x, (w + dw).max(0.0)), + ResizeX::Scale => { + // Squeezed whole: its drawing scales, its size stays. + n.transform.a *= ratio; + n.transform.b *= ratio; + (x * ratio, w) + } + }; + n.transform.tx = nx; + let resized = (nw - w).abs() > 1e-6; + n.size.width = nw; + if resized && w > 0.0 { + self.follow_width(id, w, nw); + } + } + } +} + +impl Scene { + /// An empty document with a root and one page. + #[must_use] + pub fn new(page_name: &str) -> Self { + let mut root = Node::new(NodeKind::Document, "Document"); + let mut page = Node::new(NodeKind::Page, page_name); + page.parent = Some(root.id); + root.children.push(page.id); + let root_id = root.id; + let mut nodes = BTreeMap::new(); + nodes.insert(root.id, root); + nodes.insert(page.id, page); + Self { + root: root_id, + nodes, + assets: BTreeMap::new(), + variables: Variables::default(), + } + } + + #[must_use] + pub fn node(&self, id: NodeId) -> Option<&Node> { + self.nodes.get(&id) + } + + pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + self.nodes.get_mut(&id) + } + + #[must_use] + pub fn pages(&self) -> &[NodeId] { + self.node(self.root).map_or(&[], |r| r.children.as_slice()) + } + + #[must_use] + pub fn asset(&self, id: &AssetId) -> Option<&Asset> { + self.assets.get(id) + } + + /// Store `bytes` (deduplicated by content) and return its id. + pub fn add_asset(&mut self, mime: impl Into, bytes: Vec) -> AssetId { + let asset = Asset::new(mime, bytes); + let id = asset.id.clone(); + self.assets.entry(id.clone()).or_insert(asset); + id + } + + /// Pre-order walk of `id` and its descendants. + #[must_use] + pub fn walk(&self, id: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![id]; + while let Some(n) = stack.pop() { + let Some(node) = self.node(n) else { continue }; + out.push(n); + stack.extend(node.children.iter().rev().copied()); + } + out + } + + /// Find a node by its import source id (e.g. a Figma node id). + #[must_use] + pub fn by_source_id(&self, source_id: &str) -> Option<&Node> { + self.nodes + .values() + .find(|n| n.source.as_ref().is_some_and(|s| s.id == source_id)) + } +} diff --git a/crates/frame/src/document/proto/ops.rs b/crates/frame/src/document/proto/ops.rs new file mode 100644 index 00000000..9c544fdc --- /dev/null +++ b/crates/frame/src/document/proto/ops.rs @@ -0,0 +1,340 @@ +//! Typed document edits. +//! +//! Every edit to a [`Scene`] is an [`Op`]. Applying an op returns the ops +//! that undo it, so undo/redo, AI tool transactions and rollback-on-error +//! all run on one mechanism ([`Scene::apply`], [`Scene::transact`]). + +use facet::Facet; +use serde::{Deserialize, Serialize}; + +use crate::document::proto::model::{ + Asset, AssetId, AutoLayout, BlendMode, CornerRadii, Effect, LayoutChild, MaskKind, Node, + NodeId, NodeKind, Paint, Scene, Size, Stroke, Transform, +}; + +/// One property assignment on one node. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[repr(u8)] +// Ops are built and applied once per edit; boxing the large variants +// is a performance call for the runtime work (frame#18), not a lint fix. +#[allow(clippy::large_enum_variant)] +pub enum Prop { + Name(String), + Kind(NodeKind), + Visible(bool), + Locked(bool), + Transform(Transform), + Size(Size), + Opacity(f64), + Blend(BlendMode), + CornerRadii(CornerRadii), + ClipsContent(bool), + Mask(Option), + Fills(Vec), + Stroke(Stroke), + Effects(Vec), + FillGeometry(Vec), + StrokeGeometry(Vec), + SvgSnapshot(Option), + Layout(Option), + LayoutChild(LayoutChild), + Control(Option), + Emission(Option), + Shader(Option), + Motion(Vec), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[repr(u8)] +// Ops are built and applied once per edit; boxing the large variants +// is a performance call for the runtime work (frame#18), not a lint fix. +#[allow(clippy::large_enum_variant)] +pub enum Op { + /// Insert `node` (its `children` are ignored) under `parent` at `index` + /// (clamped; `u32::MAX` = append). + Insert { + node: Node, + parent: NodeId, + index: u32, + }, + /// Remove a node and its whole subtree. + Remove { + id: NodeId, + }, + /// Reparent / reorder. + Move { + id: NodeId, + parent: NodeId, + index: u32, + }, + Set { + id: NodeId, + prop: Prop, + }, + PutAsset { + asset: Asset, + }, + DropAsset { + id: AssetId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Facet, thiserror::Error)] +#[repr(u8)] +pub enum OpError { + #[error("no node {0}")] + NoSuchNode(NodeId), + #[error("node {0} already exists")] + DuplicateNode(NodeId), + #[error("the root can't be removed or moved")] + Root, + #[error("moving {0} under its own descendant")] + Cycle(NodeId), +} + +impl Scene { + /// Apply one op; returns the ops that revert it, in apply order. + /// + /// # Errors + /// Leaves the scene untouched and returns why, when the op doesn't fit. + pub fn apply(&mut self, op: Op) -> Result, OpError> { + match op { + Op::Insert { + mut node, + parent, + index, + } => { + if self.nodes.contains_key(&node.id) { + return Err(OpError::DuplicateNode(node.id)); + } + let id = node.id; + let siblings = &mut self + .nodes + .get_mut(&parent) + .ok_or(OpError::NoSuchNode(parent))? + .children; + let at = (index as usize).min(siblings.len()); + siblings.insert(at, id); + node.parent = Some(parent); + node.children.clear(); + self.nodes.insert(id, node); + Ok(vec![Op::Remove { id }]) + } + Op::Remove { id } => { + if id == self.root { + return Err(OpError::Root); + } + let node = self.node(id).ok_or(OpError::NoSuchNode(id))?; + let parent = node.parent.ok_or(OpError::Root)?; + let index = self.index_in_parent(id).unwrap_or(0); + // Inverse: re-insert the subtree top-down, children appended in order. + let mut inverse = Vec::new(); + for (n, (p, i)) in self.subtree_with_slots(id, parent, index) { + inverse.push(Op::Insert { + node: n, + parent: p, + index: i, + }); + } + for n in self.walk(id) { + self.nodes.remove(&n); + } + if let Some(p) = self.nodes.get_mut(&parent) { + p.children.retain(|c| *c != id); + } + Ok(inverse) + } + Op::Move { id, parent, index } => { + if id == self.root { + return Err(OpError::Root); + } + if !self.nodes.contains_key(&parent) { + return Err(OpError::NoSuchNode(parent)); + } + if self.walk(id).contains(&parent) { + return Err(OpError::Cycle(id)); + } + let old_parent = self + .node(id) + .ok_or(OpError::NoSuchNode(id))? + .parent + .ok_or(OpError::Root)?; + let old_index = self.index_in_parent(id).unwrap_or(0); + if let Some(p) = self.nodes.get_mut(&old_parent) { + p.children.retain(|c| *c != id); + } + let siblings = &mut self + .nodes + .get_mut(&parent) + .ok_or(OpError::NoSuchNode(parent))? + .children; + let at = (index as usize).min(siblings.len()); + siblings.insert(at, id); + if let Some(n) = self.nodes.get_mut(&id) { + n.parent = Some(parent); + } + Ok(vec![Op::Move { + id, + parent: old_parent, + index: u32::try_from(old_index).unwrap_or(u32::MAX), + }]) + } + Op::Set { id, prop } => { + let node = self.nodes.get_mut(&id).ok_or(OpError::NoSuchNode(id))?; + let old = set_prop(node, prop); + Ok(vec![Op::Set { id, prop: old }]) + } + Op::PutAsset { asset } => { + let id = asset.id.clone(); + match self.assets.insert(id.clone(), asset) { + Some(previous) => Ok(vec![Op::PutAsset { asset: previous }]), + None => Ok(vec![Op::DropAsset { id }]), + } + } + Op::DropAsset { id } => Ok(self + .assets + .remove(&id) + .map(|asset| vec![Op::PutAsset { asset }]) + .unwrap_or_default()), + } + } + + /// Apply ops atomically: all of them, or (on the first error) none. + /// Returns the inverse transaction (apply it to undo). + /// + /// # Errors + /// The first failing op's error; the scene is rolled back. + pub fn transact(&mut self, ops: impl IntoIterator) -> Result, OpError> { + let mut undo: Vec> = Vec::new(); + for op in ops { + match self.apply(op) { + Ok(inv) => undo.push(inv), + Err(e) => { + for inv in undo.into_iter().rev() { + for op in inv { + let _ = self.apply(op); + } + } + return Err(e); + } + } + } + Ok(undo.into_iter().rev().flatten().collect()) + } + + fn index_in_parent(&self, id: NodeId) -> Option { + let parent = self.node(id)?.parent?; + self.node(parent)?.children.iter().position(|c| *c == id) + } + + /// `(node without children, (parent, index))` for `id`'s subtree in pre-order. + fn subtree_with_slots( + &self, + id: NodeId, + parent: NodeId, + index: usize, + ) -> Vec<(Node, (NodeId, u32))> { + let mut out = Vec::new(); + let mut stack = vec![(id, parent, index)]; + while let Some((n, p, i)) = stack.pop() { + let Some(node) = self.node(n) else { continue }; + let mut copy = node.clone(); + copy.children.clear(); + out.push((copy, (p, u32::try_from(i).unwrap_or(u32::MAX)))); + for (ci, c) in node.children.iter().enumerate().rev() { + stack.push((*c, n, ci)); + } + } + out + } +} + +/// Assign `prop`, returning the previous value as a `Prop`. +fn set_prop(node: &mut Node, prop: Prop) -> Prop { + use std::mem::replace; + match prop { + Prop::Name(v) => Prop::Name(replace(&mut node.name, v)), + Prop::Kind(v) => Prop::Kind(replace(&mut node.kind, v)), + Prop::Visible(v) => Prop::Visible(replace(&mut node.visible, v)), + Prop::Locked(v) => Prop::Locked(replace(&mut node.locked, v)), + Prop::Transform(v) => Prop::Transform(replace(&mut node.transform, v)), + Prop::Size(v) => Prop::Size(replace(&mut node.size, v)), + Prop::Opacity(v) => Prop::Opacity(replace(&mut node.opacity, v)), + Prop::Blend(v) => Prop::Blend(replace(&mut node.blend, v)), + Prop::CornerRadii(v) => Prop::CornerRadii(replace(&mut node.corner_radii, v)), + Prop::ClipsContent(v) => Prop::ClipsContent(replace(&mut node.clips_content, v)), + Prop::Mask(v) => Prop::Mask(replace(&mut node.mask, v)), + Prop::Fills(v) => Prop::Fills(replace(&mut node.fills, v)), + Prop::Stroke(v) => Prop::Stroke(replace(&mut node.stroke, v)), + Prop::Effects(v) => Prop::Effects(replace(&mut node.effects, v)), + Prop::FillGeometry(v) => Prop::FillGeometry(replace(&mut node.fill_geometry, v)), + Prop::StrokeGeometry(v) => Prop::StrokeGeometry(replace(&mut node.stroke_geometry, v)), + Prop::SvgSnapshot(v) => Prop::SvgSnapshot(replace(&mut node.svg_snapshot, v)), + Prop::Layout(v) => Prop::Layout(replace(&mut node.layout, v)), + Prop::LayoutChild(v) => Prop::LayoutChild(replace(&mut node.layout_child, v)), + Prop::Control(v) => Prop::Control(replace(&mut node.control, v)), + Prop::Emission(v) => Prop::Emission(replace(&mut node.emission, v)), + Prop::Shader(v) => Prop::Shader(replace(&mut node.shader, v)), + Prop::Motion(v) => Prop::Motion(replace(&mut node.motion, v)), + } +} + +/// Linear undo/redo over transactions. +#[derive(Debug, Default, Clone)] +pub struct History { + undo: Vec>, + redo: Vec>, +} + +impl History { + /// Run `ops` as one undoable step. + /// + /// # Errors + /// See [`Scene::transact`]; nothing is recorded on error. + pub fn commit( + &mut self, + scene: &mut Scene, + ops: impl IntoIterator, + ) -> Result<(), OpError> { + let inverse = scene.transact(ops)?; + self.undo.push(inverse); + self.redo.clear(); + Ok(()) + } + + /// Returns `false` when there was nothing to undo. + /// + /// # Errors + /// Only if the scene was edited outside this history in a conflicting way. + pub fn undo(&mut self, scene: &mut Scene) -> Result { + let Some(tx) = self.undo.pop() else { + return Ok(false); + }; + let redo = scene.transact(tx)?; + self.redo.push(redo); + Ok(true) + } + + /// Returns `false` when there was nothing to redo. + /// + /// # Errors + /// Only if the scene was edited outside this history in a conflicting way. + pub fn redo(&mut self, scene: &mut Scene) -> Result { + let Some(tx) = self.redo.pop() else { + return Ok(false); + }; + let undo = scene.transact(tx)?; + self.undo.push(undo); + Ok(true) + } + + #[must_use] + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + #[must_use] + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } +} diff --git a/crates/frame/src/document/proto/shader_input.rs b/crates/frame/src/document/proto/shader_input.rs new file mode 100644 index 00000000..d0a203d4 --- /dev/null +++ b/crates/frame/src/document/proto/shader_input.rs @@ -0,0 +1,223 @@ +//! Live data a shader reads: [`ShaderInput`]s on a [`ShaderFx`](crate::document::proto::ShaderFx). +//! +//! A shader node names the data it needs — a feed's latest frame (spectrum +//! bins, a gain-reduction history, a scope), a param's value, a curve +//! provider's series (delay taps worked out from time and feedback), or +//! fixed numbers — and the host resolves each to an array of floats every +//! frame. The effects stage uploads them and the WGSL reads them by name +//! (`IN_`, see `render/src/fx/CONTRACT.md`). Renderers without the +//! stage ignore them: the node's subtree (its vector fallback) draws as is. + +use crate::surface::proto::{CurveInput, EntryId, ParamAddr}; +use facet::Facet; +use serde::{Deserialize, Serialize}; + +/// The most values one input carries (after resampling). +pub const MAX_INPUT_LEN: usize = 1024; +/// The most inputs one shader reads. +pub const MAX_INPUTS: usize = 16; + +/// One named array of live data for a shader. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct ShaderInput { + /// The WGSL reads it as `IN_` (upper-cased): `gr_trace` → `IN_GR_TRACE`. + pub name: String, + pub source: ShaderInputSource, + /// Resample to exactly `len` values (linear, both ends kept), like a + /// fixed-length uniform array; `None` = as the source gives them (up to + /// [`MAX_INPUT_LEN`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub len: Option, + /// Map every value `lo..hi → 0..1` (clamped unless `unclamped`), before + /// resampling. `hi < lo` runs the other way (gain reduction in dB: `0 → + /// 0`, `−60 → 1`). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub map: Option, +} + +/// Where a [`ShaderInput`]'s values come from. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum ShaderInputSource { + /// A param's value in engineering units (a choice's option index, a + /// toggle's 0/1): one value. + Param { addr: ParamAddr }, + /// A feed's latest frame — all of it (spectrum bins, a history, a + /// scope's samples, envelope voices), or one `channel`'s value. + Stream { id: EntryId, channel: Option }, + /// A curve provider's series (the runtime's curve registry), flattened + /// `x0, y0, x1, y1, …` in engineering units; `series` picks one (else + /// the first). Evaluated like a `Curve` control's, so the vector + /// fallback and the shader agree. + Curve { + provider: String, + inputs: Vec, + series: Option, + }, + /// Fixed values. + Const { values: Vec }, +} + +/// A linear map onto `0..1`. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Facet)] +pub struct InputMap { + pub lo: f64, + pub hi: f64, + /// Keep values outside `0..1` instead of clamping. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + #[facet(default)] + pub unclamped: bool, +} + +impl InputMap { + #[must_use] + pub fn apply(&self, v: f64) -> f64 { + let span = self.hi - self.lo; + let u = if span.abs() < 1e-12 { + 0.0 + } else { + (v - self.lo) / span + }; + if self.unclamped { u } else { u.clamp(0.0, 1.0) } + } +} + +impl ShaderInput { + fn new(name: impl Into, source: ShaderInputSource) -> Self { + Self { + name: name.into(), + source, + len: None, + map: None, + } + } + + /// A param's value. + #[must_use] + pub fn param(name: impl Into, addr: impl Into) -> Self { + Self::new( + name, + ShaderInputSource::Param { + addr: ParamAddr::new(addr), + }, + ) + } + + /// A feed's latest frame. + #[must_use] + pub fn stream(name: impl Into, id: impl Into) -> Self { + Self::new( + name, + ShaderInputSource::Stream { + id: EntryId::new(id), + channel: None, + }, + ) + } + + /// A curve provider's series, flattened `x, y` pairs. + #[must_use] + pub fn curve( + name: impl Into, + provider: impl Into, + inputs: Vec, + series: Option<&str>, + ) -> Self { + Self::new( + name, + ShaderInputSource::Curve { + provider: provider.into(), + inputs, + series: series.map(str::to_string), + }, + ) + } + + /// Fixed values. + #[must_use] + pub fn constant(name: impl Into, values: Vec) -> Self { + Self::new(name, ShaderInputSource::Const { values }) + } + + /// Resampled to `len` values. + #[must_use] + pub fn len(mut self, len: u32) -> Self { + self.len = Some(len); + self + } + + /// Mapped `lo..hi → 0..1`, clamped. + #[must_use] + pub fn map(mut self, lo: f64, hi: f64) -> Self { + self.map = Some(InputMap { + lo, + hi, + unclamped: false, + }); + self + } + + /// The WGSL constant naming this input's slot. + #[must_use] + pub fn constant_name(&self) -> String { + constant_name(&self.name) + } + + /// `raw` (the source's values) as this input delivers them: mapped, + /// resampled, non-finite values as 0, at most [`MAX_INPUT_LEN`]. + #[must_use] + pub fn shape(&self, raw: &[f64]) -> Vec { + let mapped: Vec = raw + .iter() + .map(|v| { + let v = self.map.map_or(*v, |m| m.apply(*v)); + if v.is_finite() { v } else { 0.0 } + }) + .collect(); + let out = match self.len { + Some(n) => resample(&mapped, (n as usize).min(MAX_INPUT_LEN)), + None => mapped.into_iter().take(MAX_INPUT_LEN).collect(), + }; + out.into_iter().map(|v| v as f32).collect() + } +} + +/// `IN_`: upper-cased, anything not `[A-Z0-9_]` as `_`. +#[must_use] +pub fn constant_name(name: &str) -> String { + let body: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + format!("IN_{body}") +} + +/// `src` linearly resampled to `n` values, first and last kept (an empty +/// source stays empty — "no data", not zeros). +#[must_use] +pub fn resample(src: &[f64], n: usize) -> Vec { + if src.is_empty() || n == 0 { + return Vec::new(); + } + if src.len() == 1 || n == 1 { + return vec![src[0]; n]; + } + let last = (src.len() - 1) as f64; + (0..n) + .map(|i| { + let pos = i as f64 / (n - 1) as f64 * last; + let lo = pos.floor() as usize; + let hi = (lo + 1).min(src.len() - 1); + let f = pos - lo as f64; + src[lo] * (1.0 - f) + src[hi] * f + }) + .collect() +} diff --git a/crates/frame/src/document/text/REFERENCE.md b/crates/frame/src/document/text/REFERENCE.md new file mode 100644 index 00000000..7dd2ed78 --- /dev/null +++ b/crates/frame/src/document/text/REFERENCE.md @@ -0,0 +1,303 @@ +# frame markup: authoring reference + +frame markup is a JSX-like text form of a frame design. Each element is one node, and child elements are nested in paint order: the first child is painted first, so it sits at the back. Write the whole tree in one go. + +## Syntax + +``` +children +``` + +- **Values** come in four forms: + - `"string"` uses JSON string escapes (`\"`, `\\`, `\n`). + - Bare numbers: `12`, `-4.5`, `1e3`. + - `true` / `false`. + - `{json}`: any JSON value in braces, e.g. `fill={["#ff0000"]}`. +- A bare attribute name means `true`, so `clip` is the same as `clip=true`. +- Comments are written ``. +- Only `` may contain text. Write `<`, `&` and `{` for `<`, `&` and `{`. + - If the body is on one line, it is kept verbatim. + - If the body spans several lines, each line is trimmed and the lines are joined with single spaces (JSX rules). + - For real line breaks, use `text="line 1\nline 2"`. +- **Colors** are `"#rrggbb"`, `"#rrggbbaa"`, `"#rgb"` or `"#rgba"` (straight alpha). +- **Units** are px. Angles are in degrees. +- **Coordinates:** `x`/`y` are relative to the parent's top-left, and y points down. + +A document has exactly one root, ``, whose children are ``s. A fragment is one or more elements without ``/``, and is what you send to insert into an existing page or frame. + +## Elements + +| Element | Use | +|---|---| +| `Document` | Root. Children are pages. | +| `Page` | A canvas. Children are top-level frames. | +| `Frame` | Container: background, clipping, auto layout. The main building block. | +| `Group` | Plain grouping, no own paint. | +| `Rectangle`, `Ellipse`, `Line`, `Polygon`, `Star` | Shapes sized by `w`/`h`. A `Line` is drawn by its stroke along its width. | +| `Vector` | Arbitrary outline given by `geometry` (SVG path data). | +| `BooleanOperation` | Combines its children's shapes. `op="union\|subtract\|intersect\|exclude"`. | +| `Text` | Text. The content goes in the body. | +| `Component`, `ComponentSet` | Reusable main component, or a set of variants. | +| `Instance` | A use of a component. `component=""`. | +| `Utility` | Non-rendering helper (slice, widget). | +| `Other` | Unknown imported type. `tag="…"` is required. | + +## Attributes (all elements) + +Every attribute is optional; if you leave one out, it takes the default shown. + +| Attribute | Value | Default | +|---|---|---| +| `name` | string | the element name (`"Frame"`, `"Text"`, …) | +| `id` | UUID string. Leave it out on new elements; one is generated. Keep existing ids when editing. | new | +| `visible`, `locked` | bool | `true`, `false` | +| `x`, `y` | position in the parent | `0` | +| `rotation` | degrees, clockwise, about the node's top-left | `0` | +| `transform` | `{{"a":…,"b":…,"c":…,"d":…,"tx":…,"ty":…}}`: full 2×3 matrix, `x' = a·x + b·y + tx`, `y' = c·x + d·y + ty`. Missing keys come from the identity. Use it instead of `rotation`, not together with it. `x`/`y` override `tx`/`ty`. | identity | +| `w`, `h` | size | `0` | +| `opacity` | 0–1 | `1` | +| `blend` | `normal pass-through multiply screen overlay darken lighten color-dodge color-burn hard-light soft-light difference exclusion hue saturation color luminosity linear-burn linear-dodge` | `normal` | +| `radius` | `8`, or `"tl tr br bl"` | `0` | +| `clip` | bool: clip children to the bounds | `false` | +| `mask` | `alpha`, `vector` or `luminance`: this node masks the siblings painted after it | none | +| `fill` | a color, or `{[paint, …]}` (see Paints). `"none"` means no fill. | no fill | +| `stroke` | same forms as `fill` | no stroke | +| `stroke-weight` | number | `1` | +| `stroke-align` | `inside`, `center` or `outside` | `center` | +| `stroke-cap` | `none round square line-arrow triangle-arrow diamond-filled circle-filled triangle-filled` | `none` | +| `stroke-join` | `miter`, `bevel` or `round` | `miter` | +| `stroke-miter` | number | `4` | +| `stroke-dash` | `"dash gap …"` | solid | +| `stroke-dash-offset` | number | `0` | +| `effects` | `{[effect, …]}` (see Effects) | none | +| `geometry` | SVG path data in node-local px: `"M0 0 L10 0 Z"`, or `{["…","…"]}` for several. Drawn exactly as written (it may reach outside `w`×`h`). A `stroke` strokes it; open subpaths (no `Z`) are stroked as lines and never filled. Shadows and glows follow the outline. | none | +| `stroke-geometry` | same, for pre-outlined strokes (filled with the stroke paint instead of stroking `geometry`) | none | +| `svg-snapshot` | asset id of a pre-rendered SVG | none | +| `source` | `"tool/TYPE/id"` (import provenance). Don't invent it. | none | + +### Auto layout (containers) + +With `layout` set, a container places its children like CSS flexbox and ignores their `x`/`y`. The other attributes in this group only work together with `layout`. + +| Attribute | Value | Default | +|---|---|---| +| `layout` | `row` or `column` | none (children use `x`/`y`) | +| `gap` | space between children | `0` | +| `padding` | `12`, `"v h"`, `"t h b"` or `"t r b l"` | `0` | +| `wrap` | bool | `false` | +| `justify` | main axis: `start`, `center`, `end` or `space-between` | unset (start) | +| `align` | cross axis: `start`, `center`, `end` or `baseline` | unset (start) | + +### Inside an auto-layout parent + +| Attribute | Value | Default | +|---|---|---| +| `grow` | share of the free main-axis space (`1` fills it) | unset | +| `align-self` | `start`, `center`, `end` or `stretch` | unset | +| `absolute` | bool: out of flow, placed at its own `x`/`y` | `false` | +| `min-w`, `min-h`, `max-w`, `max-h` | size limits | unset | + +## Text attributes (`` only) + +| Attribute | Value | Default | +|---|---|---| +| body, or `text` | the characters | `""` | +| `font` | family, e.g. `"Oswald"` (see [Fonts](#fonts)) | `"Inter"` | +| `size` | font size | `14` | +| `weight` | 100–900 (400 regular, 700 bold) | regular | +| `style` | `"italic"`, or a style name such as `"Bold Italic"` or `"Condensed"` | upright | +| `stretch` | width in percent: `75` condensed, `125` extended | `100` | +| `line-height` | px | natural | +| `letter-spacing` | px | `0` | +| `case` | `original upper lower title small-caps small-caps-forced` | `original` | +| `text-align` | `left`, `center`, `right` or `justified` | `left` | +| `runs` | `{[{"text":"Bold","font_weight":700,"fills":["#ff0000"]}, …]}`: styled spans in order. Their `text` values concatenate to the characters. Span keys: `text`, `fills`, `font_family`, `font_size`, `font_weight`, `font_style`, `font_width`, `letter_spacing`. A span inherits the keys it leaves out. | none | + +The text color is `fill`. + +### Fonts + +Every renderer bundles these families. Write `font`, `weight`, `style` and `stretch` rather than faking weight with overlapping copies or outlines: + +| Family | What it is — use it for | Weights | Italic / width | +|---|---|---|---| +| `Inter` | UI sans (the default): labels, values, legends | 100–900 | italic | +| `Oswald` | condensed display sans: model names, big legends, amp badges | 200–700 | slanted | +| `Saira` | technical sans with a width axis: condensed to extended rack lettering | 100–900 | `stretch` 50–125 | +| `Michroma` | wide engraved/geometric caps: brushed-metal panels, hi-fi faceplates | 400 | slanted | +| `Orbitron` | geometric tech caps: synth and modern-metal logos | 400–900 | slanted | +| `Dancing Script` | flowing script: signature logos, boutique and vintage badges | 400–700 | — | +| `Pacifico` | fat retro brush script: surf, funk and 50s logos | 400 | — | +| `Caveat Brush` | marker handwriting: scribbled labels, tape strips | 400 | — | +| `Knewave` | heavy brush marker: punk, funk and graffiti logos | 400 | — | +| `Playfair Display` | high-contrast serif: boutique, hi-fi, valve-era plates | 400–900 | slanted | +| `DSEG7 Classic` | 7-segment LCD/LED digits: tuners, BPM, time (digits and a few letters) | 300, 400, 700 | italic | +| `DSEG14 Classic` | 14-segment alphanumeric LCD: preset and patch names | 400, 700 | italic | +| `Doto` | dot matrix: VFD and LED-matrix displays | 100–900 | — | +| `VT323` | pixel terminal: CRT readouts, retro digital units | 400 | — | +| `JetBrains Mono` | monospace: readouts that must not jitter, tables | 100–800 | slanted | +| `UnifrakturCook` | blackletter: metal and gothic logos | 700 | — | +| `Pirata One` | condensed gothic display: metal logos | 400 | — | + +Generic names work too: `sans-serif`/`system-ui` (Inter), `serif` (Playfair Display), `monospace` (JetBrains Mono), `cursive` (Dancing Script), `fantasy` (Pirata One), and `DSEG7`, `DSEG14`, `lcd`, `dot-matrix`, `marker`, `blackletter`. Names ignore case, spaces and hyphens (`jetbrains-mono`). An unknown family, or a character a family lacks (DSEG has no accents), falls back to Inter. Weights a static family doesn't have are emboldened, and italics it doesn't have are slanted. A document can bring its own fonts as `font/ttf` or `font/otf` assets: their families can then be named like the bundled ones. + +```jsx +Blackout +Mk II +Funk Works +STEREO DELAY +440.0 +``` + +## Paints (`fill={[…]}` / `stroke={[…]}`) + +The list is painted bottom to top. Each entry is one of the following: + +- `"#rrggbb"`: a plain solid color. +- `{"kind":{"Solid":{"color":"#ff000080"}}, "opacity":0.5}` +- `{"kind":{"LinearGradient":{"stops":[{"position":0,"color":"#ffffff"},{"position":1,"color":"#000000"}]}}}`. `RadialGradient` and `AngularGradient` take the same form. An optional `"transform":{…}` (a 2×3 matrix in unit space) re-orients the gradient; leave it out for the default orientation. +- `{"kind":{"Image":{"asset":"","scale_mode":"Fit"}}}`. `scale_mode` is `Fill`, `Fit`, `Crop`, `Tile` or `Stretch`. Markup names assets by id only; a `.frame` file's image and font bytes live next to it in `.frame.assets/` (`.` plus `manifest.json`), so keep an id you copy and don't invent one. + +Optional paint keys: +- `"opacity"`: 0–1, default `1`. +- `"blend"`: PascalCase (for example `"Multiply"`), default `"Normal"`. +- `"visible"`: default `true`. + +## Effects (`effects={[…]}`) + +``` +{"kind":"DropShadow","color":"#00000040","offset_x":0,"offset_y":2,"radius":4,"spread":0,"visible":true} +``` + +- `kind` is `DropShadow`, `InnerShadow`, `LayerBlur` or `BackgroundBlur`. +- `kind`, `color` and `radius` are required. `offset_x`, `offset_y` and `spread` default to `0`, and `visible` defaults to `true`. + +## Components and instances (themes) + +A `` is a control drawn once; an `` places +it. Arguments are `arg:=""` — the defaults on the component, +the values on an instance — and the component's bindings read them as +`arg.`: + +``` + + + + + +``` + +`of` names the component, not its id, so a theme can swap what an +instance draws. The theme runtime replaces each instance by a copy of its +component when the theme compiles: the copy keeps the instance's name +(`@mute` stays a hit target), position, size and own `bind:*`. A page of +components alone draws nothing itself. Instances may sit inside +components; an argument's value is read where its instance is. + +## Errors + +Parse errors look like `line:col: message`, e.g. `3:16: unknown attribute `colour` on — did you mean `fill`?`. Fix the named spot and send the markup again. + +## Examples + +### 1. A card (auto layout) + +```jsx + + Master Bus + -3.2 dB · stereo + +``` + +### 2. An audio control panel (knobs, faders, labels) + +Each knob is a fixed-size frame with the pointer placed by `x`/`y` and `rotation`. Each fader is a track plus a thumb positioned inside a column cell. Labels are centred text. + +```jsx + + + + + + + + + GAIN + + + + + + + PAN + + + + + + + VOL + + + + +``` + +### 3. A fragment to insert: meter with a gradient and a mixed-style label + +A gradient without a `transform` runs left to right across the node, so this meter is horizontal. + +```jsx + + + +-6.0 dB +``` + +## Light and shader effects (optional) + +- `emission={{"color":"#ff5a2a","intensity":1.4,"radius":12}}` — the node gives + off light (VU lamps, jewels, lit buttons, LEDs). GPU renderers bloom it onto + what's around it; others draw a soft glow. It scales with the node's opacity, + so a control's `Led` slot dimming a lamp dims its light. +- `shader={{"shader":"glass","params":{"strength":0.6}}}` — a post-effect on the + node's subtree. Built-ins: `glass`, `crt`, `lighting`, `flicker`; + `"asset:"` runs custom WGSL from the document's assets (the contract: + `features/render/render/src/fx/CONTRACT.md`). Optional: a surface must read + correctly without it (non-GPU renderers skip shaders). + + +## Motion (animation) + +`motion={[…]}` (or one `motion={{…}}`) animates a node. Each motion writes +one **target** from an **input** expression through **dynamics**, output +0..1 mapped onto `from`..`to`: + +```jsx + + + + +``` + +- **target**: `none` (a named signal for `motion(name)`), `x`, `y` (px), + `rotate` (°), `scale`, `stretch_x`, `stretch_y`, `skew_x`, `skew_y`, + `opacity`, `emission`, `dash_offset`, `{"fill":{"colors":["#…","#…"]}}`, + `{"stroke":{"colors":[…]}}`, `{"morph":{"shapes":["M…","M…"]}}`, + `{"shape":{"provider":"coil|reverb.room|wave","part":"…","inputs":{…}}}`. + Rotate/scale/stretch/skew turn about `pivot` (fractions of the node's box, + default centre). Rotating containers must be `Frame`s. +- **input**: `norm(addr)`, `param(addr)`, `level(feed[,ch])`, `gr(feed)`, + `scalar(feed)`, `band(feed,lo,hi)`, `feed(feed,i)`, `motion(name)`, `time`, + `hover`, `press`, numbers, `+ - * / ( )`, `clamp min max abs lerp smoothstep + step pow sqrt sin cos floor fract sign curve or`. A feed with no data is + missing and makes the sum missing — wrap optional ones: `or(level(x/out), 0)`. +- **dynamics**: `"follow"` (default), `{"spring":{stiffness,damping,mass}}`, + `{"oscillator":{freq,decay|damping,excite,gain}}` (rings when `excite` + rises), `{"lfo":{rate,shape,phase}}`, `{"noise":{rate,amount}}`, + `{"ballistic":{attack,release}}`, `{"keyframes":{times,values,easing,loop}}`, + `"spin"` (input in turns/s). Any number may be an expression. +- `lag: true` adds only the dynamics' overshoot to what a control already + shows (a spring on a needle). `name` lets others read it with `motion(name)`. + +The `animate` tool edits motions; `lint` checks their sources. diff --git a/crates/frame/src/document/text/error.rs b/crates/frame/src/document/text/error.rs new file mode 100644 index 00000000..ee15d297 --- /dev/null +++ b/crates/frame/src/document/text/error.rs @@ -0,0 +1,38 @@ +/// A markup error, positioned for a human (or a model) to fix. +/// +/// `line` and `col` are 1-based; `col` counts characters, not bytes. +/// `Display` renders `line:col: message`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError { + pub line: usize, + pub col: usize, + pub message: String, +} + +impl ParseError { + pub(crate) fn at(src: &str, offset: usize, message: impl Into) -> Self { + let (line, col) = line_col(src, offset); + Self { + line, + col, + message: message.into(), + } + } +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}: {}", self.line, self.col, self.message) + } +} + +impl std::error::Error for ParseError {} + +pub(crate) fn line_col(src: &str, offset: usize) -> (usize, usize) { + let offset = offset.min(src.len()); + let before = src.get(..offset).unwrap_or(src); + let line = before.matches('\n').count() + 1; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + let col = before[line_start..].chars().count() + 1; + (line, col) +} diff --git a/crates/frame/src/document/text/mod.rs b/crates/frame/src/document/text/mod.rs new file mode 100644 index 00000000..ad37137f --- /dev/null +++ b/crates/frame/src/document/text/mod.rs @@ -0,0 +1,38 @@ +//! frame markup — the JSX-like text form of a [`Scene`]. +//! +//! The binary Loro bundle is the canonical file; this is the form people +//! diff and models read and write. One element per node, nested in child +//! order, attributes for properties: +//! +//! ```text +//! +//! +//! +//! +//! Gain +//! +//! +//! +//! ``` +//! +//! The law: for every scene `s`, `parse(&print(&s), s.assets.clone()) == Ok(s)`. +//! The printer omits properties equal to [`Node::new`]'s defaults and uses a +//! compact attribute form wherever that form is lossless, falling back to the +//! property's serde JSON in braces (`fill={[…]}`) otherwise. +//! +//! `REFERENCE.md` next to this module is the authoring reference fed to models. +//! +//! [`Node::new`]: crate::document::proto::Node::new + +mod error; +mod parse; +mod print; +mod schema; +mod values; + +pub use error::ParseError; +pub use parse::{parse, parse_fragment}; +pub use print::{print, print_subtree}; + +#[doc(no_inline)] +pub use crate::document::proto::Scene; diff --git a/crates/frame/src/document/text/parse.rs b/crates/frame/src/document/text/parse.rs new file mode 100644 index 00000000..b08990ff --- /dev/null +++ b/crates/frame/src/document/text/parse.rs @@ -0,0 +1,1200 @@ +//! Markup → scene. Hand-written: a small lexer into an element tree, then +//! a builder that turns each element into a `Node`. + +use std::collections::{BTreeMap, HashMap}; + +use crate::document::proto::{ + AlignSelf, Asset, AssetId, AutoLayout, Bind, BlendMode, BooleanOp, CornerRadii, CrossAxisAlign, + LayoutDirection, MainAxisAlign, MaskKind, Node, NodeId, NodeKind, ResizeX, ResizeY, Scene, + SourceRef, StrokeAlign, StrokeCap, StrokeJoin, TextAlign, TextCase, TextData, Variable, + Variables, +}; +use serde_json::Value; + +use crate::document::text::error::ParseError; +use crate::document::text::schema::{ + BIND_ATTRS, LAYOUT_ATTRS, TAGS, VARIABLES_TAG, attrs_for, default_name, owner_of, suggest_attr, + suggest_tag, +}; +use crate::document::text::values::{ + Keyword, effects_from_json, from_json, paints_from_json, parse_hex, rotation_matrix, + runs_from_json, transform_from_json, +}; + +type Result = std::result::Result; + +/// Parse a whole document: exactly one top-level element (normally +/// ``), which becomes `scene.root`. `assets` become the scene's +/// asset table (markup references assets by id, it never embeds them). +/// +/// Elements without `id` get a fresh one. +/// +/// # Errors +/// The first problem found, with its line and column. +pub fn parse(src: &str, assets: BTreeMap) -> Result { + let els = Lexer::new(src).document()?; + let el = match els.as_slice() { + [one] => one, + [] => { + return Err(ParseError::at( + src, + 0, + "empty document — expected one root element like …", + )); + } + [_, second, ..] => { + return Err(ParseError::at( + src, + second.pos, + "a document has exactly one root element; wrap everything in one …", + )); + } + }; + let mut b = Builder::new(src, false); + let root = b.build(el, None)?; + let nodes = b.nodes.into_iter().map(|n| (n.id, n)).collect(); + Ok(Scene { + root, + nodes, + assets, + variables: b.variables, + }) +} + +/// Parse a fragment: one or more elements (no ``/``), for +/// inserting into an existing scene. Returns every node in pre-order with +/// `parent`/`children` wired among themselves; top-level elements come +/// back with `parent: None`. +/// +/// # Errors +/// The first problem found, with its line and column. +pub fn parse_fragment(src: &str) -> Result> { + let els = Lexer::new(src).document()?; + if els.is_empty() { + return Err(ParseError::at( + src, + 0, + "empty fragment — expected one or more elements like …", + )); + } + let mut b = Builder::new(src, true); + for el in &els { + b.build(el, None)?; + } + Ok(b.nodes) +} + +// ── lexer ──────────────────────────────────────────────────────────────── + +#[derive(Debug)] +enum AttrValue { + /// `locked` (no `=`): true. + Flag, + Bare(String), + Quoted(String), + Json(Value), +} + +#[derive(Debug)] +struct Attr { + name: String, + pos: usize, + value: AttrValue, + value_pos: usize, +} + +#[derive(Debug)] +struct El { + tag: String, + pos: usize, + attrs: Vec, + children: Vec, + /// Text content (already unescaped) and where it started. + body: Option<(String, usize)>, +} + +struct Lexer<'a> { + src: &'a str, + pos: usize, +} + +impl<'a> Lexer<'a> { + fn new(src: &'a str) -> Self { + Self { src, pos: 0 } + } + + fn err(&self, at: usize, msg: impl Into) -> Result { + Err(ParseError::at(self.src, at, msg)) + } + + fn rest(&self) -> &'a str { + &self.src[self.pos..] + } + + fn peek(&self) -> Option { + self.rest().chars().next() + } + + fn starts(&self, s: &str) -> bool { + self.rest().starts_with(s) + } + + fn bump(&mut self, n: usize) { + self.pos += n; + } + + fn skip_ws(&mut self) { + let trimmed = self.rest().trim_start(); + self.pos = self.src.len() - trimmed.len(); + } + + /// Whitespace and `` comments. + fn skip_trivia(&mut self) -> Result<()> { + loop { + self.skip_ws(); + if !self.skip_comment()? { + return Ok(()); + } + } + } + + fn skip_comment(&mut self) -> Result { + if !self.starts("") { + Some(i) => { + self.bump(i + 3); + Ok(true) + } + None => self.err(start, "unterminated comment — close it with `-->`"), + } + } + + fn document(&mut self) -> Result> { + let mut out = Vec::new(); + loop { + self.skip_trivia()?; + match self.peek() { + None => return Ok(out), + Some('<') if self.starts(" { + let name = self.rest()[2..] + .split(|c: char| !c.is_alphanumeric()) + .next() + .unwrap_or(""); + return self.err( + self.pos, + format!("closing tag has no matching opening tag"), + ); + } + Some('<') => out.push(self.element()?), + Some(c) => { + return self.err( + self.pos, + format!("unexpected `{c}` — expected an element like "), + ); + } + } + } + } + + fn ident(&mut self, attr: bool) -> &'a str { + let rest = self.rest(); + let len = rest + .char_indices() + .find(|(i, c)| { + let ok = c.is_ascii_alphanumeric() + || *c == '_' + || (attr && *i > 0 && (*c == '-' || *c == ':')); + !ok + }) + .map_or(rest.len(), |(i, _)| i); + self.bump(len); + &rest[..len] + } + + fn element(&mut self) -> Result { + let pos = self.pos; + self.bump(1); // < + let tag = self.ident(false).to_owned(); + if tag.is_empty() { + let found = self + .peek() + .map_or("end of input".to_owned(), |c| format!("`{c}`")); + return self.err( + self.pos, + format!("expected an element name after `<`, found {found}"), + ); + } + let mut el = El { + tag, + pos, + attrs: Vec::new(), + children: Vec::new(), + body: None, + }; + + // Attributes. + loop { + self.skip_ws(); + if self.starts("/>") { + self.bump(2); + return Ok(el); + } + if self.starts(">") { + self.bump(1); + break; + } + let apos = self.pos; + let name = self.ident(true).to_owned(); + if name.is_empty() { + return match self.peek() { + None => self.err( + pos, + format!("<{}> is never closed — end it with `>` or `/>`", el.tag), + ), + Some(c) => self.err( + apos, + format!( + "unexpected `{c}` in <{}> — expected an attribute name, `/>` or `>`", + el.tag + ), + ), + }; + } + self.skip_ws(); + let (value, value_pos) = if self.starts("=") { + self.bump(1); + self.skip_ws(); + let vpos = self.pos; + (self.attr_value(&name)?, vpos) + } else { + (AttrValue::Flag, apos) + }; + el.attrs.push(Attr { + name, + pos: apos, + value, + value_pos, + }); + } + + // Content, up to the closing tag. + let mut text = String::new(); + let mut text_pos = None; + let mut has_text = false; + loop { + let seg_start = self.pos; + let seg_len = self.rest().find('<').unwrap_or(self.rest().len()); + if seg_len > 0 { + text_pos.get_or_insert(seg_start); + let seg = &self.rest()[..seg_len]; + has_text |= !seg.trim().is_empty(); + text.push_str(seg); + self.bump(seg_len); + } + if self.peek().is_none() { + return self.err( + pos, + format!("<{}> is never closed — add ", el.tag, el.tag), + ); + } + if self.skip_comment()? { + continue; + } + if self.starts("") { + return self.err(self.pos, format!("expected `>` to finish ")); + } + self.bump(1); + if name != el.tag { + let (l, c) = crate::document::text::error::line_col(self.src, pos); + return self.err( + cpos, + format!( + " doesn't match <{}> opened at {l}:{c} — close it with ", + el.tag, el.tag + ), + ); + } + break; + } + el.children.push(self.element()?); + } + + if has_text { + let at = text_pos.unwrap_or(pos); + if el.tag != "Text" { + let snippet: String = text.trim().chars().take(24).collect(); + return self.err( + at, + format!( + "text `{snippet}` inside <{}> — only holds text; wrap it: {snippet}", + el.tag + ), + ); + } + if !el.children.is_empty() { + return self.err( + at, + "a with child elements can't also have body text — use text=\"…\"", + ); + } + } + if el.tag == "Text" && el.children.is_empty() && !text.is_empty() { + el.body = Some((unescape(&jsx_whitespace(&text)), text_pos.unwrap_or(pos))); + } + Ok(el) + } + + fn attr_value(&mut self, name: &str) -> Result { + match self.peek() { + Some('"') => { + let start = self.pos; + let bytes = self.src.as_bytes(); + let mut i = start + 1; + loop { + match bytes.get(i) { + None => { + return self.err( + start, + format!("unterminated string in `{name}=` — add the closing `\"`"), + ); + } + Some(b'\\') => i += 2, + Some(b'"') => break, + Some(_) => i += 1, + } + } + let lit = &self.src[start..=i]; + let s: String = serde_json::from_str(lit).map_err(|e| { + ParseError::at( + self.src, + start, + format!("bad string in `{name}=`: {}", strip_serde_pos(&e)), + ) + })?; + self.pos = i + 1; + Ok(AttrValue::Quoted(s)) + } + Some('{') => { + let open = self.pos; + let body_start = open + 1; + let mut stream = serde_json::Deserializer::from_str(&self.src[body_start..]) + .into_iter::(); + match stream.next() { + Some(Ok(v)) => { + self.pos = body_start + stream.byte_offset(); + self.skip_ws(); + if !self.starts("}") { + return self.err( + self.pos, + format!("expected `}}` to close the `{{…}}` value of `{name}`"), + ); + } + self.bump(1); + Ok(AttrValue::Json(v)) + } + Some(Err(e)) => { + let at = + offset_of(&self.src[body_start..], e.line(), e.column()) + body_start; + self.err( + at, + format!("invalid JSON in `{name}={{…}}`: {}", strip_serde_pos(&e)), + ) + } + None => self.err(open, format!("expected a JSON value inside `{name}={{…}}`")), + } + } + _ => { + let rest = self.rest(); + let len = rest + .char_indices() + .find(|(i, c)| { + c.is_whitespace() + || *c == '>' + || (*c == '/' && rest[*i..].starts_with("/>")) + }) + .map_or(rest.len(), |(i, _)| i); + if len == 0 { + return self.err(self.pos, format!("missing value after `{name}=`")); + } + let v = rest[..len].to_owned(); + self.bump(len); + Ok(AttrValue::Bare(v)) + } + } + } +} + +fn strip_serde_pos(e: &serde_json::Error) -> String { + let s = e.to_string(); + match s.rfind(" at line ") { + Some(i) => s[..i].to_owned(), + None => s, + } +} + +/// Byte offset of 1-based (line, column) in `s`. +fn offset_of(s: &str, line: usize, col: usize) -> usize { + let mut off = 0; + for (i, l) in s.split_inclusive('\n').enumerate() { + if i + 1 == line { + return off + col.saturating_sub(1).min(l.len()); + } + off += l.len(); + } + s.len() +} + +/// JSX's rule: body text spanning lines is trimmed per line, blank lines +/// dropped, and the rest joined with single spaces. Single-line text is +/// kept verbatim. +fn jsx_whitespace(raw: &str) -> String { + if !raw.contains('\n') { + return raw.to_owned(); + } + raw.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" ") +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let decoded = rest.find(';').filter(|&j| j <= 10).and_then(|j| { + let ent = &rest[1..j]; + let ch = match ent { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "lbrace" => Some('{'), + "rbrace" => Some('}'), + _ => ent + .strip_prefix("#x") + .or_else(|| ent.strip_prefix("#X")) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + ch.map(|c| (c, j)) + }); + match decoded { + Some((c, j)) => { + out.push(c); + rest = &rest[j + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +// ── builder ────────────────────────────────────────────────────────────── + +struct Builder<'a> { + src: &'a str, + fragment: bool, + nodes: Vec, + seen: HashMap, + variables: Variables, +} + +/// One element's attributes, taken one by one; the conversion helpers +/// position their errors at the offending value. +struct Attrs<'e> { + src: &'e str, + tag: &'e str, + map: HashMap<&'e str, &'e Attr>, +} + +impl<'e> Attrs<'e> { + fn err(&self, a: &Attr, msg: impl Into) -> Result { + Err(ParseError::at( + self.src, + a.value_pos, + format!("`{}` on <{}>: {}", a.name, self.tag, msg.into()), + )) + } + + fn get(&self, name: &str) -> Option<&'e Attr> { + self.map.get(name).copied() + } + + fn num(&self, name: &str) -> Result> { + let Some(a) = self.get(name) else { + return Ok(None); + }; + let parsed = match &a.value { + AttrValue::Bare(s) | AttrValue::Quoted(s) => s.trim().parse::().ok(), + AttrValue::Json(Value::Number(n)) => n.as_f64(), + _ => None, + }; + match parsed { + Some(v) => Ok(Some(v)), + None => self.err( + a, + format!("expected a number like {name}=12, got {}", show(&a.value)), + ), + } + } + + fn bool(&self, name: &str) -> Result> { + let Some(a) = self.get(name) else { + return Ok(None); + }; + match &a.value { + AttrValue::Flag | AttrValue::Json(Value::Bool(true)) => Ok(Some(true)), + AttrValue::Json(Value::Bool(false)) => Ok(Some(false)), + AttrValue::Bare(s) | AttrValue::Quoted(s) if s == "true" => Ok(Some(true)), + AttrValue::Bare(s) | AttrValue::Quoted(s) if s == "false" => Ok(Some(false)), + v => self.err(a, format!("expected true or false, got {}", show(v))), + } + } + + fn str(&self, name: &str) -> Result> { + let Some(a) = self.get(name) else { + return Ok(None); + }; + match &a.value { + AttrValue::Bare(s) | AttrValue::Quoted(s) | AttrValue::Json(Value::String(s)) => { + Ok(Some(s.clone())) + } + v => self.err( + a, + format!("expected a string like {name}=\"…\", got {}", show(v)), + ), + } + } + + fn keyword(&self, name: &str) -> Result> { + let Some(s) = self.str(name)? else { + return Ok(None); + }; + match K::from_keyword(&s) { + Some(k) => Ok(Some(k)), + None => self.err( + self.map[name], + format!("unknown value \"{s}\" — expected one of {}", K::expected()), + ), + } + } + + fn id(&self, name: &str) -> Result> { + let Some(s) = self.str(name)? else { + return Ok(None); + }; + match uuid::Uuid::parse_str(s.trim()) { + Ok(u) => Ok(Some(NodeId(u))), + Err(_) => self.err( + self.map[name], + format!("\"{s}\" is not a UUID (omit `{name}` to generate one)"), + ), + } + } + + /// Space- or comma-separated numbers (or a JSON array / single number). + fn nums(&self, name: &str, allowed: &[usize], example: &str) -> Result>> { + let Some(a) = self.get(name) else { + return Ok(None); + }; + let parsed: Option> = match &a.value { + AttrValue::Bare(s) | AttrValue::Quoted(s) => s + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|p| !p.is_empty()) + .map(|p| p.parse().ok()) + .collect(), + AttrValue::Json(Value::Number(n)) => n.as_f64().map(|v| vec![v]), + AttrValue::Json(Value::Array(xs)) => xs.iter().map(Value::as_f64).collect(), + AttrValue::Flag | AttrValue::Json(_) => None, + }; + match parsed { + Some(v) if allowed.is_empty() || allowed.contains(&v.len()) => Ok(Some(v)), + _ => self.err(a, format!("expected {example}, got {}", show(&a.value))), + } + } + + /// A `{json}` value (or a quoted/bare string where `strings` allows). + fn json(&self, name: &str) -> Option<(&'e Attr, Value)> { + let a = self.get(name)?; + let v = match &a.value { + AttrValue::Json(v) => v.clone(), + AttrValue::Bare(s) | AttrValue::Quoted(s) => Value::String(s.clone()), + AttrValue::Flag => Value::Bool(true), + }; + Some((a, v)) + } + + fn convert( + &self, + name: &str, + f: impl FnOnce(Value) -> std::result::Result, + ) -> Result> { + let Some((a, v)) = self.json(name) else { + return Ok(None); + }; + match f(v) { + Ok(t) => Ok(Some(t)), + Err(e) => self.err(a, e), + } + } +} + +fn show(v: &AttrValue) -> String { + match v { + AttrValue::Flag => "no value".into(), + AttrValue::Bare(s) => format!("`{s}`"), + AttrValue::Quoted(s) => format!("\"{s}\""), + AttrValue::Json(j) => format!("{{{j}}}"), + } +} + +impl<'a> Builder<'a> { + fn new(src: &'a str, fragment: bool) -> Self { + Self { + src, + fragment, + nodes: Vec::new(), + seen: HashMap::new(), + variables: Variables::default(), + } + } + + fn err(&self, at: usize, msg: impl Into) -> Result { + Err(ParseError::at(self.src, at, msg)) + } + + fn build(&mut self, el: &El, parent: Option) -> Result { + let tag = el.tag.as_str(); + if !TAGS.contains(&tag) { + let hint = + suggest_tag(tag).map_or(String::new(), |t| format!(" — did you mean <{t}>?")); + return self.err( + el.pos, + format!( + "unknown element <{tag}>{hint} (elements: {})", + TAGS.join(", ") + ), + ); + } + if self.fragment && (tag == "Document" || tag == "Page") { + return self.err( + el.pos, + format!("<{tag}> can't appear in a fragment — send only the elements to insert (e.g. …)"), + ); + } + + // Validate attribute names. + let valid = attrs_for(tag); + let mut map: HashMap<&str, &Attr> = HashMap::new(); + for a in &el.attrs { + let name = a.name.as_str(); + let arg = name.strip_prefix(crate::document::text::schema::ARG_PREFIX); + if arg.is_some() && !crate::document::text::schema::takes_args(tag) { + return self.err( + a.pos, + format!("`{name}`: only a or an takes arguments"), + ); + } + if arg.is_none() && !valid.contains(&name) { + let msg = if let Some(owner) = owner_of(name) { + format!("attribute `{name}` is only valid on <{owner}>, not <{tag}>") + } else { + let hint = suggest_attr(name, &valid) + .map_or(String::new(), |s| format!(" — did you mean `{s}`?")); + format!("unknown attribute `{name}` on <{tag}>{hint}") + }; + return self.err(a.pos, msg); + } + if map.insert(name, a).is_some() { + return self.err(a.pos, format!("attribute `{name}` given twice on <{tag}>")); + } + } + let at = Attrs { + src: self.src, + tag, + map, + }; + + let kind = self.kind(el, &at)?; + let id = at.id("id")?.unwrap_or_default(); + if let Some(prev) = self.seen.get(&id) { + let (l, c) = crate::document::text::error::line_col(self.src, *prev); + let pos = at.get("id").map_or(el.pos, |a| a.value_pos); + return self.err(pos, format!("duplicate id \"{id}\" (first used at {l}:{c}) — ids must be unique; omit `id` on new elements")); + } + self.seen.insert(id, el.pos); + + let mut node = Node::new(kind, ""); + node.id = id; + node.parent = parent; + node.name = at + .str("name")? + .unwrap_or_else(|| default_name(&node.kind).to_owned()); + apply_common(&at, &mut node)?; + + let index = self.nodes.len(); + self.nodes.push(node); + let mut children = Vec::with_capacity(el.children.len()); + for c in &el.children { + if c.tag == VARIABLES_TAG { + if tag != "Document" { + return self.err( + c.pos, + format!( + "<{VARIABLES_TAG}> belongs directly inside , not <{tag}>" + ), + ); + } + self.variables(c)?; + continue; + } + children.push(self.build(c, Some(id))?); + } + self.nodes[index].children = children; + Ok(id) + } + + /// `` and its `` + /// children: each attribute other than `name`/`value` is a mode's value. + fn variables(&mut self, el: &El) -> Result<()> { + if !self.variables.is_empty() { + return self.err( + el.pos, + format!("a document has one <{VARIABLES_TAG}> block"), + ); + } + for a in &el.attrs { + match (a.name.as_str(), &a.value) { + ("modes", AttrValue::Bare(v) | AttrValue::Quoted(v)) => { + self.variables.modes = v + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|m| !m.is_empty()) + .map(str::to_owned) + .collect(); + } + (name, _) => { + return self.err( + a.pos, + format!( + "unknown attribute `{name}` on <{VARIABLES_TAG}> (it takes modes=\"…\")" + ), + ); + } + } + } + for v in &el.children { + if v.tag != "Var" { + return self.err( + v.pos, + format!( + "<{VARIABLES_TAG}> holds elements, not <{}>", + v.tag + ), + ); + } + let mut name = None; + let mut var = Variable::default(); + for a in &v.attrs { + let text = match &a.value { + AttrValue::Bare(s) | AttrValue::Quoted(s) => s.clone(), + AttrValue::Json(j) => j.to_string(), + AttrValue::Flag => "true".to_owned(), + }; + match a.name.as_str() { + "name" => name = Some(text), + "value" => var.value = text, + mode if self.variables.modes.iter().any(|m| m == mode) => { + var.modes.insert(mode.to_owned(), text); + } + other => { + return self.err( + a.pos, + format!( + "`{other}` on is not a mode (modes: {})", + self.variables.modes.join(", ") + ), + ); + } + } + } + let Some(name) = name else { + return self.err(v.pos, " needs name=\"…\""); + }; + if var.value.is_empty() { + // The first mode's value stands in for modes that don't say. + if let Some(first) = self.variables.modes.first().and_then(|m| var.modes.get(m)) { + var.value = first.clone(); + } else { + return self.err( + v.pos, + format!(" needs value=… or a value for each mode"), + ); + } + } + if self.variables.vars.insert(name.clone(), var).is_some() { + return self.err(v.pos, format!("variable `{name}` defined twice")); + } + } + Ok(()) + } + + fn kind(&self, el: &El, at: &Attrs<'_>) -> Result { + Ok(match el.tag.as_str() { + "Document" => NodeKind::Document, + "Page" => NodeKind::Page, + "Frame" => NodeKind::Frame, + "Group" => NodeKind::Group, + "Component" => NodeKind::Component, + "ComponentSet" => NodeKind::ComponentSet, + "Instance" => NodeKind::Instance { + component: at.id("component")?, + }, + "Rectangle" => NodeKind::Rectangle, + "Ellipse" => NodeKind::Ellipse, + "Line" => NodeKind::Line, + "Polygon" => NodeKind::Polygon, + "Star" => NodeKind::Star, + "Vector" => NodeKind::Vector, + "BooleanOperation" => NodeKind::BooleanOperation { + op: at.keyword::("op")?, + }, + "Utility" => NodeKind::Utility, + "Other" => match at.str("tag")? { + Some(tag) => NodeKind::Other { tag }, + None => { + return self.err( + el.pos, + " needs tag=\"…\" naming the source node type", + ); + } + }, + "Text" => NodeKind::Text(text_data(el, at)?), + _ => unreachable!("tag validated against TAGS"), + }) + } +} + +fn text_data(el: &El, at: &Attrs<'_>) -> Result { + let characters = match (at.str("text")?, &el.body) { + (Some(_), Some((_, pos))) => { + return Err(ParseError::at( + at.src, + *pos, + " has both text=\"…\" and body text — use one", + )); + } + (Some(s), None) => s, + (None, Some((s, _))) => s.clone(), + (None, None) => String::new(), + }; + let mut t = TextData { + characters, + ..TextData::default() + }; + t.font_family = at.str("font")?; + if let Some(v) = at.num("size")? { + t.font_size = v; + } + t.font_weight = at.num("weight")?; + t.font_style = at.str("style")?; + t.font_width = at.num("stretch")?; + t.line_height = at.num("line-height")?; + if let Some(v) = at.num("letter-spacing")? { + t.letter_spacing = v; + } + if let Some(v) = at.keyword::("case")? { + t.text_case = v; + } + if let Some(v) = at.keyword::("text-align")? { + t.align = v; + } + if let Some(v) = at.convert("runs", runs_from_json)? { + t.runs = v; + } + Ok(t) +} + +fn apply_common(at: &Attrs<'_>, n: &mut Node) -> Result<()> { + if let Some(v) = at.bool("visible")? { + n.visible = v; + } + if let Some(v) = at.bool("locked")? { + n.locked = v; + } + + // Transform: `transform` (full matrix) or `rotation`, then x/y. + if let (Some(_), Some(r)) = (at.get("transform"), at.get("rotation")) { + return at.err(r, "use either `rotation` or `transform`, not both"); + } + if let Some(t) = at.convert("transform", transform_from_json)? { + n.transform = t; + } + if let Some(deg) = at.num("rotation")? { + (n.transform.a, n.transform.b, n.transform.c, n.transform.d) = rotation_matrix(deg); + } + if let Some(v) = at.num("x")? { + n.transform.tx = v; + } + if let Some(v) = at.num("y")? { + n.transform.ty = v; + } + + if let Some(v) = at.num("w")? { + n.size.width = v; + } + if let Some(v) = at.num("h")? { + n.size.height = v; + } + if let Some(v) = at.num("opacity")? { + n.opacity = v; + } + if let Some(v) = at.keyword::("blend")? { + n.blend = v; + } + if let Some(r) = at.nums("radius", &[1, 4], "radius=8 or radius=\"tl tr br bl\"")? { + n.corner_radii = match r.as_slice() { + [u] => CornerRadii::uniform(*u), + [tl, tr, br, bl] => CornerRadii { + top_left: *tl, + top_right: *tr, + bottom_right: *br, + bottom_left: *bl, + }, + _ => unreachable!("arity checked"), + }; + } + if let Some(v) = at.bool("clip")? { + n.clips_content = v; + } + n.mask = at.keyword::("mask")?; + if let Some(v) = at.convert("fill", paints_value)? { + n.fills = v; + } + + if let Some(v) = at.convert("stroke", paints_value)? { + n.stroke.paints = v; + } + if let Some(v) = at.num("stroke-weight")? { + n.stroke.weight = v; + } + if let Some(v) = at.keyword::("stroke-align")? { + n.stroke.align = v; + } + if let Some(v) = at.keyword::("stroke-cap")? { + n.stroke.cap = v; + } + if let Some(v) = at.keyword::("stroke-join")? { + n.stroke.join = v; + } + if let Some(v) = at.num("stroke-miter")? { + n.stroke.miter_limit = v; + } + if let Some(v) = at.nums("stroke-dash", &[], "stroke-dash=\"4 2\" (dash gap …)")? { + n.stroke.dash_pattern = v; + } + if let Some(v) = at.num("stroke-dash-offset")? { + n.stroke.dash_offset = v; + } + if let Some(v) = at.convert("effects", effects_from_json)? { + n.effects = v; + } + + // Auto layout. + match at.str("layout")? { + Some(dir) => { + let direction = match dir.to_ascii_lowercase().as_str() { + "row" | "horizontal" | "h" => LayoutDirection::Horizontal, + "column" | "col" | "vertical" | "v" => LayoutDirection::Vertical, + "none" => { + return at.err( + at.map["layout"], + "omit `layout` for no auto layout (\"none\" isn't a value)", + ); + } + _ => { + return at.err( + at.map["layout"], + format!("unknown value \"{dir}\" — expected \"row\" or \"column\""), + ); + } + }; + let mut l = AutoLayout { + direction, + gap: at.num("gap")?.unwrap_or(0.0), + padding_left: 0.0, + padding_right: 0.0, + padding_top: 0.0, + padding_bottom: 0.0, + wrap: at.bool("wrap")?.unwrap_or(false), + main_align: at.keyword::("justify")?, + cross_align: at.keyword::("align")?, + hug_main: false, + hug_cross: false, + }; + let hug = match at.bool("hug") { + Ok(Some(true)) => Some("both".to_string()), + Ok(Some(false)) => None, + _ => at.str("hug")?, + }; + match hug.as_deref() { + None => {} + Some("both") => (l.hug_main, l.hug_cross) = (true, true), + Some("main") => l.hug_main = true, + Some("cross") => l.hug_cross = true, + Some(other) => { + return at.err(at.map["hug"], format!("unknown value \"{other}\" — expected `hug` (both axes), \"main\" or \"cross\"")); + } + } + if let Some(p) = at.nums( + "padding", + &[1, 2, 3, 4], + "padding=12 or padding=\"top right bottom left\"", + )? { + ( + l.padding_top, + l.padding_right, + l.padding_bottom, + l.padding_left, + ) = match p.as_slice() { + [u] => (*u, *u, *u, *u), + [v, h] => (*v, *h, *v, *h), + [t, h, b] => (*t, *h, *b, *h), + [t, r, b, l] => (*t, *r, *b, *l), + _ => unreachable!("arity checked"), + }; + } + n.layout = Some(l); + } + None => { + if let Some(name) = LAYOUT_ATTRS.iter().find(|a| at.get(a).is_some()) { + return at.err( + at.map[name], + "only applies to auto-layout containers — add layout=\"row\" or layout=\"column\" to this element", + ); + } + } + } + + let lc = &mut n.layout_child; + lc.grow = at.num("grow")?; + lc.align_self = at.keyword::("align-self")?; + lc.absolute = at.bool("absolute")?.unwrap_or(false); + lc.min_width = at.num("min-w")?; + lc.min_height = at.num("min-h")?; + lc.max_width = at.num("max-w")?; + lc.max_height = at.num("max-h")?; + lc.resize_x = at.keyword::("resize-x")?; + lc.resize_y = at.keyword::("resize-y")?; + + for name in BIND_ATTRS { + if let Some(expr) = at.str(name)? { + n.bind.push(Bind { + prop: name.trim_start_matches("bind:").to_owned(), + expr, + }); + } + } + n.of = at.str("of")?; + // In the order written: a component's arguments read best as declared. + let mut args: Vec<(&str, usize)> = at + .map + .iter() + .filter_map(|(k, a)| { + k.strip_prefix(crate::document::text::schema::ARG_PREFIX) + .map(|name| (name, a.pos)) + }) + .collect(); + args.sort_by_key(|(_, pos)| *pos); + for (name, _) in args { + if let Some(expr) = at.str(&format!( + "{}{name}", + crate::document::text::schema::ARG_PREFIX + ))? { + n.args.push(crate::document::proto::Arg { + name: name.to_owned(), + expr, + }); + } + } + + if let Some(v) = at.convert("geometry", geometry_value)? { + n.fill_geometry = v; + } + if let Some(v) = at.convert("stroke-geometry", geometry_value)? { + n.stroke_geometry = v; + } + n.svg_snapshot = at.str("svg-snapshot")?.map(AssetId); + n.control = at.convert("control", from_json)?; + n.emission = at.convert("emission", from_json)?; + n.shader = at.convert("shader", from_json)?; + n.motion = at.convert("motion", motion_value)?.unwrap_or_default(); + n.source = at.convert("source", source_value)?; + Ok(()) +} + +fn paints_value(v: Value) -> std::result::Result, String> { + match v { + Value::String(s) if s == "none" || s.is_empty() => Ok(Vec::new()), + Value::String(s) if parse_hex(&s).is_none() => Err(format!( + "\"{s}\" is not a color — write \"#rrggbb\" / \"#rrggbbaa\", or a paint list {{[…]}}" + )), + v => paints_from_json(v), + } +} + +/// `motion={[…]}` (a list) or `motion={{…}}` (one motion). Expressions +/// are checked by lint (`motion` rule), not here, so any scene prints and +/// parses back. +fn motion_value( + v: Value, +) -> std::result::Result, String> { + match v { + Value::Array(_) => from_json(v), + v => Ok(vec![from_json(v)?]), + } +} + +fn geometry_value(v: Value) -> std::result::Result, String> { + match v { + Value::String(s) => Ok(vec![s]), + v => serde_json::from_value(v) + .map_err(|_| "expected SVG path data \"M0 0 L…\" or a list {[\"…\", …]}".to_owned()), + } +} + +fn source_value(v: Value) -> std::result::Result { + match v { + Value::String(s) => { + let mut parts = s.splitn(3, '/'); + match (parts.next(), parts.next(), parts.next()) { + (Some(tool), Some(node_type), Some(id)) => Ok(SourceRef { + tool: tool.to_owned(), + node_type: node_type.to_owned(), + id: id.to_owned(), + bounds: None, + }), + _ => Err(format!( + "expected \"tool/TYPE/id\" (e.g. \"figma/FRAME/1:2\"), got \"{s}\"" + )), + } + } + v => from_json(v), + } +} diff --git a/crates/frame/src/document/text/print.rs b/crates/frame/src/document/text/print.rs new file mode 100644 index 00000000..6ba88690 --- /dev/null +++ b/crates/frame/src/document/text/print.rs @@ -0,0 +1,400 @@ +//! Scene → markup. Deterministic: same scene, same bytes. + +use crate::document::proto::{ + BlendMode, CornerRadii, Node, NodeId, NodeKind, Scene, SourceRef, StrokeAlign, StrokeCap, + StrokeJoin, TextAlign, TextCase, Transform, +}; +use serde_json::Value; + +use crate::document::text::schema::{default_name, tag_of}; +use crate::document::text::values::{ + Keyword, effects_json, num, num_list, paints_json, plain_solid_hex, quote, rotation_of, + runs_json, to_json, +}; + +/// Print the whole scene, rooted at `scene.root`. +#[must_use] +pub fn print(scene: &Scene) -> String { + let mut out = String::new(); + print_node(scene, scene.root, 0, &mut out); + if !scene.variables.is_empty() { + // The block is the first child of : after its opening + // line, before the pages. + let block = variables(&scene.variables); + match out.find(">\n") { + Some(i) if !out[..i].ends_with(" /") => out.insert_str(i + 2, &block), + _ => { + // A document with no pages printed self-closing. + let close = out.rfind(" />").unwrap_or(out.len()); + out.replace_range(close.., &format!(">\n{block}\n")); + } + } + } + out +} + +fn variables(v: &crate::document::proto::Variables) -> String { + let mut out = format!(" \n", quote(&v.modes.join(" "))); + for (name, var) in &v.vars { + out.push_str(&format!( + " \n"); + } + out.push_str(" \n"); + out +} + +/// Print one node and its descendants (a fragment `parse_fragment` reads +/// back). `None` when `id` isn't in the scene. +#[must_use] +pub fn print_subtree(scene: &Scene, id: NodeId) -> Option { + scene.node(id)?; + let mut out = String::new(); + print_node(scene, id, 0, &mut out); + Some(out) +} + +fn print_node(scene: &Scene, id: NodeId, depth: usize, out: &mut String) { + let Some(node) = scene.node(id) else { return }; + let indent = " ".repeat(depth); + let tag = tag_of(&node.kind); + let children: Vec = node + .children + .iter() + .copied() + .filter(|c| scene.nodes.contains_key(c)) + .collect(); + let (attrs, body) = attributes(node, !children.is_empty()); + + out.push_str(&indent); + out.push('<'); + out.push_str(tag); + for (k, v) in &attrs { + out.push(' '); + out.push_str(k); + out.push('='); + out.push_str(v); + } + if let Some(of) = &node.of { + out.push_str(" of="); + out.push_str("e(of)); + } + for arg in &node.args { + out.push(' '); + out.push_str(crate::document::text::schema::ARG_PREFIX); + out.push_str(&arg.name); + out.push('='); + out.push_str("e(&arg.expr)); + } + if let Some(body) = body { + out.push('>'); + out.push_str(&body); + out.push_str("\n"); + } else if children.is_empty() { + out.push_str(" />\n"); + } else { + out.push_str(">\n"); + for c in children { + print_node(scene, c, depth + 1, out); + } + out.push_str(&indent); + out.push_str("\n"); + } +} + +fn braced(v: &Value) -> String { + format!("{{{}}}", serde_json::to_string(v).unwrap_or_default()) +} + +/// `(attribute, rendered value)` pairs in canonical order, plus the element +/// body (a text node's characters, when they fit in a body losslessly). +fn attributes(n: &Node, has_children: bool) -> (Vec<(&'static str, String)>, Option) { + let mut a: Vec<(&'static str, String)> = Vec::new(); + let mut body = None; + + if n.name != default_name(&n.kind) { + a.push(("name", quote(&n.name))); + } + a.push(("id", quote(&n.id.to_string()))); + match &n.kind { + NodeKind::Instance { component: Some(c) } => a.push(("component", quote(&c.to_string()))), + NodeKind::BooleanOperation { op: Some(op) } => a.push(("op", quote(&op.keyword()))), + NodeKind::Other { tag } => a.push(("tag", quote(tag))), + _ => {} + } + if !n.visible { + a.push(("visible", "false".into())); + } + if n.locked { + a.push(("locked", "true".into())); + } + transform(&n.transform, &mut a); + if n.size.width != 0.0 { + a.push(("w", num(n.size.width))); + } + if n.size.height != 0.0 { + a.push(("h", num(n.size.height))); + } + if n.opacity != 1.0 { + a.push(("opacity", num(n.opacity))); + } + if n.blend != BlendMode::Normal { + a.push(("blend", quote(&n.blend.keyword()))); + } + if let Some(r) = radius(&n.corner_radii) { + a.push(("radius", r)); + } + if n.clips_content { + a.push(("clip", "true".into())); + } + if let Some(m) = n.mask { + a.push(("mask", quote(&m.keyword()))); + } + if let Some(f) = paints_attr(&n.fills) { + a.push(("fill", f)); + } + + let s = &n.stroke; + if let Some(f) = paints_attr(&s.paints) { + a.push(("stroke", f)); + } + if s.weight != 1.0 { + a.push(("stroke-weight", num(s.weight))); + } + if s.align != StrokeAlign::Center { + a.push(("stroke-align", quote(&s.align.keyword()))); + } + if s.cap != StrokeCap::None { + a.push(("stroke-cap", quote(&s.cap.keyword()))); + } + if s.join != StrokeJoin::Miter { + a.push(("stroke-join", quote(&s.join.keyword()))); + } + if s.miter_limit != 4.0 { + a.push(("stroke-miter", num(s.miter_limit))); + } + if !s.dash_pattern.is_empty() { + a.push(("stroke-dash", quote(&num_list(&s.dash_pattern)))); + } + if s.dash_offset != 0.0 { + a.push(("stroke-dash-offset", num(s.dash_offset))); + } + if !n.effects.is_empty() { + a.push(("effects", braced(&effects_json(&n.effects)))); + } + + if let Some(l) = &n.layout { + let dir = match l.direction { + crate::document::proto::LayoutDirection::Horizontal => "row", + crate::document::proto::LayoutDirection::Vertical => "column", + }; + a.push(("layout", quote(dir))); + if l.gap != 0.0 { + a.push(("gap", num(l.gap))); + } + let (t, r, b, lft) = ( + l.padding_top, + l.padding_right, + l.padding_bottom, + l.padding_left, + ); + if !(t == 0.0 && r == 0.0 && b == 0.0 && lft == 0.0) { + let v = if t == r && r == b && b == lft { + num(t) + } else if t == b && r == lft { + quote(&num_list(&[t, r])) + } else { + quote(&num_list(&[t, r, b, lft])) + }; + a.push(("padding", v)); + } + if l.wrap { + a.push(("wrap", "true".into())); + } + match (l.hug_main, l.hug_cross) { + (true, true) => a.push(("hug", "true".into())), + (true, false) => a.push(("hug", quote("main"))), + (false, true) => a.push(("hug", quote("cross"))), + (false, false) => {} + } + if let Some(m) = l.main_align { + a.push(("justify", quote(&m.keyword()))); + } + if let Some(c) = l.cross_align { + a.push(("align", quote(&c.keyword()))); + } + } + + let lc = &n.layout_child; + if let Some(g) = lc.grow { + a.push(("grow", num(g))); + } + if let Some(s) = lc.align_self { + a.push(("align-self", quote(&s.keyword()))); + } + if lc.absolute { + a.push(("absolute", "true".into())); + } + if let Some(r) = lc.resize_x { + a.push(("resize-x", quote(&r.keyword()))); + } + if let Some(r) = lc.resize_y { + a.push(("resize-y", quote(&r.keyword()))); + } + for (k, v) in [ + ("min-w", lc.min_width), + ("min-h", lc.min_height), + ("max-w", lc.max_width), + ("max-h", lc.max_height), + ] { + if let Some(v) = v { + a.push((k, num(v))); + } + } + + if let NodeKind::Text(t) = &n.kind { + if let Some(f) = &t.font_family { + a.push(("font", quote(f))); + } + if t.font_size != 14.0 { + a.push(("size", num(t.font_size))); + } + if let Some(w) = t.font_weight { + a.push(("weight", num(w))); + } + if let Some(st) = &t.font_style { + a.push(("style", quote(st))); + } + if let Some(w) = t.font_width { + a.push(("stretch", num(w))); + } + if let Some(lh) = t.line_height { + a.push(("line-height", num(lh))); + } + if t.letter_spacing != 0.0 { + a.push(("letter-spacing", num(t.letter_spacing))); + } + if t.text_case != TextCase::Original { + a.push(("case", quote(&t.text_case.keyword()))); + } + if t.align != TextAlign::Left { + a.push(("text-align", quote(&t.align.keyword()))); + } + if !t.runs.is_empty() { + a.push(("runs", braced(&runs_json(&t.runs)))); + } + if !t.characters.is_empty() { + if has_children || t.characters.contains('\n') { + a.push(("text", quote(&t.characters))); + } else { + body = Some(escape_body(&t.characters)); + } + } + } + + geometry("geometry", &n.fill_geometry, &mut a); + geometry("stroke-geometry", &n.stroke_geometry, &mut a); + if let Some(s) = &n.svg_snapshot { + a.push(("svg-snapshot", quote(&s.0))); + } + if let Some(c) = &n.control { + a.push(("control", braced(&to_json(c)))); + } + if let Some(e) = &n.emission { + a.push(("emission", braced(&to_json(e)))); + } + if let Some(s) = &n.shader { + a.push(("shader", braced(&to_json(s)))); + } + if !n.motion.is_empty() { + a.push(("motion", braced(&to_json(&n.motion)))); + } + if let Some(s) = &n.source { + a.push(("source", source(s))); + } + for name in crate::document::text::schema::BIND_ATTRS { + let prop = name.trim_start_matches("bind:"); + for b in n.bind.iter().filter(|b| b.prop == prop) { + a.push((name, quote(&b.expr))); + } + } + (a, body) +} + +fn transform(t: &Transform, a: &mut Vec<(&'static str, String)>) { + let translate_only = t.a == 1.0 && t.b == 0.0 && t.c == 0.0 && t.d == 1.0; + let rotation = if translate_only { None } else { rotation_of(t) }; + if translate_only || rotation.is_some() { + if t.tx != 0.0 { + a.push(("x", num(t.tx))); + } + if t.ty != 0.0 { + a.push(("y", num(t.ty))); + } + if let Some(deg) = rotation { + a.push(("rotation", num(deg))); + } + } else { + a.push(("transform", braced(&to_json(t)))); + } +} + +fn radius(r: &CornerRadii) -> Option { + if r.is_zero() { + return None; + } + let all = [r.top_left, r.top_right, r.bottom_right, r.bottom_left]; + Some(if all.iter().all(|v| *v == all[0]) { + num(all[0]) + } else { + quote(&num_list(&all)) + }) +} + +fn paints_attr(ps: &[crate::document::proto::Paint]) -> Option { + match ps { + [] => None, + [p] if plain_solid_hex(p).is_some() => plain_solid_hex(p).map(|h| quote(&h)), + _ => Some(braced(&paints_json(ps))), + } +} + +fn geometry(key: &'static str, paths: &[String], a: &mut Vec<(&'static str, String)>) { + match paths { + [] => {} + [one] => a.push((key, quote(one))), + many => a.push((key, braced(&Value::from(many.to_vec())))), + } +} + +fn source(s: &SourceRef) -> String { + // The compact form can't carry import bounds; fall back to JSON then. + if s.tool.contains('/') || s.node_type.contains('/') || s.bounds.is_some() { + braced(&to_json(s)) + } else { + quote(&format!("{}/{}/{}", s.tool, s.node_type, s.id)) + } +} + +fn escape_body(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '{' => out.push_str("{"), + c => out.push(c), + } + } + out +} diff --git a/crates/frame/src/document/text/schema.rs b/crates/frame/src/document/text/schema.rs new file mode 100644 index 00000000..15db4c03 --- /dev/null +++ b/crates/frame/src/document/text/schema.rs @@ -0,0 +1,361 @@ +//! Element and attribute vocabulary, plus "did you mean" suggestions. + +use crate::document::proto::NodeKind; + +/// Element tags, in `NodeKind` order. +/// Not a node: a document's design tokens, the first child of +/// `` (see [`crate::document::proto::Variables`]). +pub(crate) const VARIABLES_TAG: &str = "Variables"; + +pub(crate) const TAGS: &[&str] = &[ + "Document", + "Page", + "Frame", + "Group", + "Component", + "ComponentSet", + "Instance", + "Rectangle", + "Ellipse", + "Line", + "Polygon", + "Star", + "Vector", + "BooleanOperation", + "Text", + "Utility", + "Other", +]; + +pub(crate) fn tag_of(kind: &NodeKind) -> &'static str { + match kind { + NodeKind::Document => "Document", + NodeKind::Page => "Page", + NodeKind::Frame => "Frame", + NodeKind::Group => "Group", + NodeKind::Component => "Component", + NodeKind::ComponentSet => "ComponentSet", + NodeKind::Instance { .. } => "Instance", + NodeKind::Rectangle => "Rectangle", + NodeKind::Ellipse => "Ellipse", + NodeKind::Line => "Line", + NodeKind::Polygon => "Polygon", + NodeKind::Star => "Star", + NodeKind::Vector => "Vector", + NodeKind::BooleanOperation { .. } => "BooleanOperation", + NodeKind::Text(_) => "Text", + NodeKind::Utility => "Utility", + NodeKind::Other { .. } => "Other", + } +} + +/// The name a node gets when `name=` is omitted. +pub(crate) fn default_name(kind: &NodeKind) -> &str { + match kind { + NodeKind::Other { tag } => tag, + k => tag_of(k), + } +} + +/// Attributes valid on every element, in print order. +pub(crate) const COMMON_ATTRS: &[&str] = &[ + "name", + "id", + "visible", + "locked", + "x", + "y", + "rotation", + "transform", + "w", + "h", + "opacity", + "blend", + "radius", + "clip", + "mask", + "fill", + "stroke", + "stroke-weight", + "stroke-align", + "stroke-cap", + "stroke-join", + "stroke-miter", + "stroke-dash", + "stroke-dash-offset", + "effects", + "layout", + "gap", + "padding", + "wrap", + "hug", + "justify", + "align", + "grow", + "resize-x", + "resize-y", + "align-self", + "absolute", + "min-w", + "min-h", + "max-w", + "max-h", + "geometry", + "stroke-geometry", + "svg-snapshot", + "control", + "emission", + "shader", + "motion", + "source", +]; + +/// Bound properties, as attributes (`bind:fill="$strip.ground"`): the +/// property's own attribute name behind `bind:`. Valid on every element, +/// printed last, in this order. +pub(crate) const BIND_ATTRS: &[&str] = &[ + "bind:visible", + "bind:x", + "bind:y", + "bind:w", + "bind:h", + "bind:rotation", + "bind:scale", + "bind:scale-x", + "bind:scale-y", + "bind:opacity", + "bind:radius", + "bind:radius-tl", + "bind:radius-tr", + "bind:radius-br", + "bind:radius-bl", + "bind:fill", + "bind:stroke", + "bind:dash", + "bind:inset-top", + "bind:inset-bottom", + "bind:inset-left", + "bind:inset-right", + "bind:size", + "bind:geometry", + "bind:text", +]; + +pub(crate) const TEXT_ATTRS: &[&str] = &[ + "text", + "font", + "size", + "weight", + "style", + "stretch", + "line-height", + "letter-spacing", + "case", + "text-align", + "runs", +]; + +/// Attributes only one element accepts: `(attr, tag)`. +pub(crate) const KIND_ATTRS: &[(&str, &str)] = &[ + ("component", "Instance"), + ("of", "Instance"), + ("op", "BooleanOperation"), + ("tag", "Other"), +]; + +/// A component's arguments, `arg:=""`: the defaults on a +/// , the values on an . +pub(crate) const ARG_PREFIX: &str = "arg:"; + +/// Whether `tag` takes `arg:*` attributes. +pub(crate) fn takes_args(tag: &str) -> bool { + matches!(tag, "Component" | "Instance") +} + +/// Attributes that only mean something inside an auto-layout container. +pub(crate) const LAYOUT_ATTRS: &[&str] = &["gap", "padding", "wrap", "justify", "align", "hug"]; + +pub(crate) fn attrs_for(tag: &str) -> Vec<&'static str> { + let mut v: Vec<&'static str> = COMMON_ATTRS.to_vec(); + v.extend_from_slice(BIND_ATTRS); + if tag == "Text" { + v.extend_from_slice(TEXT_ATTRS); + } + v.extend( + KIND_ATTRS + .iter() + .filter(|(_, t)| *t == tag) + .map(|(a, _)| *a), + ); + v +} + +/// Which element owns `attr`, when it's element-specific. +pub(crate) fn owner_of(attr: &str) -> Option<&'static str> { + if TEXT_ATTRS.contains(&attr) { + return Some("Text"); + } + KIND_ATTRS.iter().find(|(a, _)| *a == attr).map(|(_, t)| *t) +} + +const ATTR_SYNONYMS: &[(&str, &str)] = &[ + ("colour", "fill"), + ("color", "fill"), + ("background", "fill"), + ("background-color", "fill"), + ("bg", "fill"), + ("fill-color", "fill"), + ("fills", "fill"), + ("width", "w"), + ("height", "h"), + ("left", "x"), + ("top", "y"), + ("border-radius", "radius"), + ("rounded", "radius"), + ("corner-radius", "radius"), + ("cornerradius", "radius"), + ("stroke-width", "stroke-weight"), + ("strokewidth", "stroke-weight"), + ("strokeweight", "stroke-weight"), + ("border-width", "stroke-weight"), + ("border", "stroke"), + ("border-color", "stroke"), + ("stroke-color", "stroke"), + ("strokes", "stroke"), + ("rotate", "rotation"), + ("angle", "rotation"), + ("direction", "layout"), + ("flex", "layout"), + ("flex-direction", "layout"), + ("auto-layout", "layout"), + ("spacing", "gap"), + ("item-spacing", "gap"), + ("font-size", "size"), + ("fontsize", "size"), + ("font-weight", "weight"), + ("fontweight", "weight"), + ("font-family", "font"), + ("fontfamily", "font"), + ("font-style", "style"), + ("fontstyle", "style"), + ("font-stretch", "stretch"), + ("font-width", "stretch"), + ("width-percent", "stretch"), + ("content", "text"), + ("characters", "text"), + ("label", "text"), + ("value", "text"), + ("overflow", "clip"), + ("clips-content", "clip"), + ("clip-content", "clip"), + ("hidden", "visible"), + ("alpha", "opacity"), + ("blend-mode", "blend"), + ("blendmode", "blend"), + ("mix-blend-mode", "blend"), + ("justify-content", "justify"), + ("align-items", "align"), + ("items", "align"), + ("flex-grow", "grow"), + ("textalign", "text-align"), + ("lineheight", "line-height"), + ("letterspacing", "letter-spacing"), + ("text-case", "case"), + ("text-transform", "case"), + ("key", "id"), + ("shadow", "effects"), + ("shadows", "effects"), + ("effect", "effects"), + ("path", "geometry"), + ("paths", "geometry"), + ("d", "geometry"), + ("min-width", "min-w"), + ("min-height", "min-h"), + ("max-width", "max-w"), + ("max-height", "max-h"), + ("position", "absolute"), + ("dash", "stroke-dash"), + ("stroke-dasharray", "stroke-dash"), +]; + +const TAG_SYNONYMS: &[(&str, &str)] = &[ + ("rect", "Rectangle"), + ("circle", "Ellipse"), + ("oval", "Ellipse"), + ("div", "Frame"), + ("view", "Frame"), + ("box", "Frame"), + ("container", "Frame"), + ("stack", "Frame"), + ("row", "Frame"), + ("column", "Frame"), + ("col", "Frame"), + ("card", "Frame"), + ("autolayout", "Frame"), + ("path", "Vector"), + ("svg", "Vector"), + ("icon", "Vector"), + ("span", "Text"), + ("label", "Text"), + ("p", "Text"), + ("h1", "Text"), + ("image", "Rectangle"), + ("img", "Rectangle"), + ("boolean", "BooleanOperation"), + ("union", "BooleanOperation"), + ("doc", "Document"), + ("canvas", "Page"), + ("symbol", "Component"), + ("componentinstance", "Instance"), +]; + +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut cur = vec![i + 1]; + for (j, cb) in b.iter().enumerate() { + let sub = prev[j] + usize::from(ca != cb); + cur.push(sub.min(prev[j + 1] + 1).min(cur[j] + 1)); + } + prev = cur; + } + prev[b.len()] +} + +fn closest<'a>(word: &str, candidates: &[&'a str]) -> Option<&'a str> { + let w = word.to_ascii_lowercase(); + candidates + .iter() + .map(|c| (levenshtein(&w, &c.to_ascii_lowercase()), *c)) + .filter(|(d, c)| *d <= 2.max(c.len() / 3) && *d < c.len().max(w.len())) + .min_by_key(|(d, _)| *d) + .map(|(_, c)| c) +} + +pub(crate) fn suggest_attr(word: &str, valid: &[&'static str]) -> Option<&'static str> { + let w = word.to_ascii_lowercase().replace('_', "-"); + if valid.contains(&w.as_str()) { + return valid.iter().find(|v| **v == w).copied(); + } + let squashed = w.replace('-', ""); + let synonym = |s: &str| ATTR_SYNONYMS.iter().find(|(k, _)| *k == s).map(|(_, a)| *a); + // Exact synonym, else the closest spelling among attributes and synonyms + // (so `widht` finds `width` → `w`). + let mut candidates: Vec<&'static str> = valid.to_vec(); + candidates.extend(ATTR_SYNONYMS.iter().map(|(k, _)| *k)); + synonym(&w) + .or_else(|| synonym(&squashed)) + .or_else(|| closest(&w, &candidates).map(|c| synonym(c).unwrap_or(c))) + .filter(|a| valid.contains(a)) +} + +pub(crate) fn suggest_tag(word: &str) -> Option<&'static str> { + let w = word.to_ascii_lowercase(); + TAGS.iter() + .find(|t| t.to_ascii_lowercase() == w) + .copied() + .or_else(|| TAG_SYNONYMS.iter().find(|(s, _)| *s == w).map(|(_, t)| *t)) + .or_else(|| closest(word, TAGS)) +} diff --git a/crates/frame/src/document/text/values.rs b/crates/frame/src/document/text/values.rs new file mode 100644 index 00000000..72b5d74c --- /dev/null +++ b/crates/frame/src/document/text/values.rs @@ -0,0 +1,443 @@ +//! Value codecs shared by the printer and the parser: numbers, colors, +//! keyword enums, and the compact JSON forms of paints / effects / runs. + +use crate::document::proto::{ + AlignSelf, BlendMode, BooleanOp, Color, CrossAxisAlign, Effect, MainAxisAlign, MaskKind, Paint, + PaintKind, ResizeX, ResizeY, StrokeAlign, StrokeCap, StrokeJoin, TextAlign, TextCase, TextRun, + Transform, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value, json}; + +// ── numbers ────────────────────────────────────────────────────────────── + +/// Shortest decimal that parses back to exactly `v` (Rust's `Display`). +pub(crate) fn num(v: f64) -> String { + format!("{v}") +} + +pub(crate) fn num_list(vs: &[f64]) -> String { + vs.iter().map(|v| num(*v)).collect::>().join(" ") +} + +/// A JSON string literal (the attribute string syntax). +pub(crate) fn quote(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| String::from("\"\"")) +} + +// ── colors ─────────────────────────────────────────────────────────────── + +fn channel_byte(v: f64) -> Option { + if !(0.0..=1.0).contains(&v) { + return None; + } + let n = (v * 255.0).round(); + // In range by the check above. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let b = n as u8; + (f64::from(b) / 255.0 == v).then_some(b) +} + +/// `#rrggbb` / `#rrggbbaa`, only when every channel is exactly `n/255`. +pub(crate) fn hex(c: Color) -> Option { + let (r, g, b, a) = ( + channel_byte(c.r)?, + channel_byte(c.g)?, + channel_byte(c.b)?, + channel_byte(c.a)?, + ); + Some(if a == 255 { + format!("#{r:02x}{g:02x}{b:02x}") + } else { + format!("#{r:02x}{g:02x}{b:02x}{a:02x}") + }) +} + +/// `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` → straight RGBA (channel = n/255). +pub(crate) fn parse_hex(s: &str) -> Option { + let h = s.strip_prefix('#')?; + if !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok(); + let nib = |i: usize| u8::from_str_radix(&h[i..=i], 16).ok().map(|n| n * 17); + let (r, g, b, a) = match h.len() { + 3 => (nib(0)?, nib(1)?, nib(2)?, 255), + 4 => (nib(0)?, nib(1)?, nib(2)?, nib(3)?), + 6 => (byte(0)?, byte(2)?, byte(4)?, 255), + 8 => (byte(0)?, byte(2)?, byte(4)?, byte(6)?), + _ => return None, + }; + let f = |n: u8| f64::from(n) / 255.0; + Some(Color::rgba(f(r), f(g), f(b), f(a))) +} + +// ── keyword enums ──────────────────────────────────────────────────────── + +/// A unit enum written as a kebab-case keyword (`BlendMode::PassThrough` → +/// `pass-through`). Parsing ignores case, `-`, `_` and spaces. +pub(crate) trait Keyword: Sized + Copy + Serialize + 'static { + const ALL: &'static [Self]; + + fn keyword(self) -> String { + let pascal = serde_json::to_value(self) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_default(); + kebab(&pascal) + } + + fn from_keyword(s: &str) -> Option { + let want = squash(s); + Self::ALL + .iter() + .copied() + .find(|v| squash(&v.keyword()) == want) + } + + fn expected() -> String { + Self::ALL + .iter() + .map(|v| format!("\"{}\"", v.keyword())) + .collect::>() + .join(", ") + } +} + +fn kebab(pascal: &str) -> String { + let mut out = String::new(); + for (i, ch) in pascal.chars().enumerate() { + if ch.is_ascii_uppercase() && i > 0 { + out.push('-'); + } + out.push(ch.to_ascii_lowercase()); + } + out +} + +fn squash(s: &str) -> String { + s.chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +macro_rules! keywords { + ($($ty:ident: [$($v:ident),* $(,)?];)*) => {$( + impl Keyword for $ty { + const ALL: &'static [Self] = &[$($ty::$v),*]; + } + )*}; +} + +keywords! { + BlendMode: [Normal, PassThrough, Multiply, Screen, Overlay, Darken, Lighten, ColorDodge, ColorBurn, HardLight, + SoftLight, Difference, Exclusion, Hue, Saturation, Color, Luminosity, LinearBurn, LinearDodge]; + StrokeCap: [None, Round, Square, LineArrow, TriangleArrow, DiamondFilled, CircleFilled, TriangleFilled, + WashiTape1, WashiTape2, WashiTape3, WashiTape4, WashiTape5, WashiTape6]; + StrokeJoin: [Miter, Bevel, Round]; + StrokeAlign: [Inside, Outside, Center]; + MaskKind: [Alpha, Vector, Luminance]; + TextCase: [Original, Upper, Lower, Title, SmallCaps, SmallCapsForced]; + TextAlign: [Left, Center, Right, Justified]; + MainAxisAlign: [Start, Center, End, SpaceBetween]; + CrossAxisAlign: [Start, Center, End, Baseline]; + AlignSelf: [Start, Center, End, Stretch]; + ResizeX: [Left, Right, Center, Stretch, Scale]; + ResizeY: [Top, Bottom, Center, Stretch, Scale]; + BooleanOp: [Union, Subtract, Intersect, Exclude]; +} + +// ── transforms ─────────────────────────────────────────────────────────── + +/// `(a, b, c, d)` of a rotation by `deg` degrees (clockwise on screen, y +/// down): `a = cos, b = -sin, c = sin, d = cos`. Multiples of 90° are exact. +pub(crate) fn rotation_matrix(deg: f64) -> (f64, f64, f64, f64) { + let (s, c) = if deg % 90.0 == 0.0 { + // Finite multiple of 90 ⇒ quarter turns are small integers mod 4. + #[allow(clippy::cast_possible_truncation)] + match (deg / 90.0).rem_euclid(4.0) as i64 { + 0 => (0.0, 1.0), + 1 => (1.0, 0.0), + 2 => (0.0, -1.0), + _ => (-1.0, 0.0), + } + } else { + deg.to_radians().sin_cos() + }; + (c, -s, s, c) +} + +/// The shortest `rotation=` value that rebuilds `t`'s linear part exactly. +pub(crate) fn rotation_of(t: &Transform) -> Option { + let base = t.c.atan2(t.a).to_degrees(); + if !base.is_finite() { + return None; + } + let exact = |deg: f64| rotation_matrix(deg) == (t.a, t.b, t.c, t.d); + (0..=12) + .map(|p| { + let k = 10f64.powi(p); + (base * k).round() / k + }) + .chain(std::iter::once(base)) + .find(|d| exact(*d)) +} + +// ── JSON forms ─────────────────────────────────────────────────────────── + +/// Serialize for a `{…}` attribute: nulls (unset `Option`s) dropped, and any +/// `"color": {r,g,b,a}` written as a hex string when lossless. +pub(crate) fn to_json(v: &T) -> Value { + let mut v = serde_json::to_value(v).unwrap_or(Value::Null); + compact(&mut v); + v +} + +fn compact(v: &mut Value) { + match v { + Value::Object(m) => { + m.retain(|_, x| !x.is_null()); + for (k, x) in m.iter_mut() { + if k == "color" + && let Some(h) = color_hex_of(x) + { + *x = Value::String(h); + continue; + } + compact(x); + } + } + Value::Array(a) => a.iter_mut().for_each(compact), + _ => {} + } +} + +fn color_hex_of(v: &Value) -> Option { + let m = v.as_object()?; + if m.len() != 4 { + return None; + } + let c = serde_json::from_value::(v.clone()).ok()?; + hex(c) +} + +/// The inverse of [`compact`]'s color rule: `"color": "#hex"` → object. +fn expand_colors(v: &mut Value) -> Result<(), String> { + match v { + Value::Object(m) => { + for (k, x) in m.iter_mut() { + if k == "color" + && let Value::String(s) = x + { + let c = parse_hex(s).ok_or_else(|| { + format!("invalid color `{s}` (expected #rrggbb or #rrggbbaa)") + })?; + *x = serde_json::to_value(c).map_err(|e| e.to_string())?; + continue; + } + expand_colors(x)?; + } + } + Value::Array(a) => { + for x in a { + expand_colors(x)?; + } + } + _ => {} + } + Ok(()) +} + +/// Deserialize a `{…}` attribute value (after [`expand_colors`]). +pub(crate) fn from_json(mut v: Value) -> Result { + expand_colors(&mut v)?; + serde_json::from_value(v).map_err(|e| e.to_string()) +} + +fn strip(m: &mut Map, key: &str, default: &Value) { + if m.get(key) == Some(default) { + m.remove(key); + } +} + +fn fill_default(m: &mut Map, key: &str, default: Value) { + m.entry(key.to_owned()).or_insert(default); +} + +/// A plain solid paint (opacity 1, normal, visible) with a hex-exact color. +pub(crate) fn plain_solid_hex(p: &Paint) -> Option { + match &p.kind { + PaintKind::Solid { color } + if p.opacity == 1.0 && p.blend == BlendMode::Normal && p.visible => + { + hex(*color) + } + _ => None, + } +} + +fn paint_defaults() -> [(&'static str, Value); 3] { + [ + ("opacity", json!(1.0)), + ("blend", json!("Normal")), + ("visible", json!(true)), + ] +} + +pub(crate) fn paint_json(p: &Paint) -> Value { + if let Some(h) = plain_solid_hex(p) { + return Value::String(h); + } + let mut v = to_json(p); + if let Value::Object(m) = &mut v { + for (k, d) in paint_defaults() { + strip(m, k, &d); + } + if let Some(Value::Object(img)) = m.get_mut("kind").and_then(|k| k.get_mut("Image")) { + strip(img, "scale_mode", &json!("Fill")); + } + } + v +} + +pub(crate) fn paints_json(ps: &[Paint]) -> Value { + Value::Array(ps.iter().map(paint_json).collect()) +} + +pub(crate) fn paint_from_json(v: Value) -> Result { + match v { + Value::String(s) => parse_hex(&s) + .map(Paint::solid) + .ok_or_else(|| format!("invalid color `{s}` (expected #rrggbb or #rrggbbaa)")), + Value::Object(mut m) => { + for (k, d) in paint_defaults() { + fill_default(&mut m, k, d); + } + if let Some(Value::Object(img)) = m.get_mut("kind").and_then(|k| k.get_mut("Image")) { + fill_default(img, "scale_mode", json!("Fill")); + } + from_json(Value::Object(m)) + } + other => Err(format!( + "a paint is a color string or an object, got `{other}`" + )), + } +} + +/// A paint list: an array of paints, or a single paint. +pub(crate) fn paints_from_json(v: Value) -> Result, String> { + match v { + Value::Array(a) => a.into_iter().map(paint_from_json).collect(), + other => Ok(vec![paint_from_json(other)?]), + } +} + +fn effect_defaults() -> [(&'static str, Value); 4] { + [ + ("visible", json!(true)), + ("offset_x", json!(0.0)), + ("offset_y", json!(0.0)), + ("spread", json!(0.0)), + ] +} + +pub(crate) fn effects_json(es: &[Effect]) -> Value { + Value::Array( + es.iter() + .map(|e| { + let mut v = to_json(e); + if let Value::Object(m) = &mut v { + for (k, d) in effect_defaults() { + strip(m, k, &d); + } + } + v + }) + .collect(), + ) +} + +pub(crate) fn effects_from_json(v: Value) -> Result, String> { + let items = match v { + Value::Array(a) => a, + other => vec![other], + }; + items + .into_iter() + .map(|e| match e { + Value::Object(mut m) => { + for (k, d) in effect_defaults() { + fill_default(&mut m, k, d); + } + from_json(Value::Object(m)) + } + other => Err(format!( + "an effect is an object like {{\"kind\":\"DropShadow\",…}}, got `{other}`" + )), + }) + .collect() +} + +pub(crate) fn runs_json(rs: &[TextRun]) -> Value { + Value::Array( + rs.iter() + .map(|r| { + let mut v = to_json(r); + if let Value::Object(m) = &mut v { + if r.fills.is_empty() { + m.remove("fills"); + } else { + m.insert("fills".into(), paints_json(&r.fills)); + } + } + v + }) + .collect(), + ) +} + +pub(crate) fn runs_from_json(v: Value) -> Result, String> { + let Value::Array(items) = v else { + return Err("`runs` is an array of run objects: [{\"text\":\"…\"}, …]".into()); + }; + items + .into_iter() + .map(|r| { + let Value::Object(mut m) = r else { + return Err(format!( + "a run is an object like {{\"text\":\"…\"}}, got `{r}`" + )); + }; + let fills = m + .remove("fills") + .map(paints_from_json) + .transpose()? + .unwrap_or_default(); + m.insert("fills".into(), Value::Array(Vec::new())); + let mut run: TextRun = from_json(Value::Object(m))?; + run.fills = fills; + Ok(run) + }) + .collect() +} + +pub(crate) fn transform_from_json(v: Value) -> Result { + let Value::Object(mut m) = v else { + return Err( + "`transform` is an object {\"a\":…,\"b\":…,\"tx\":…,\"c\":…,\"d\":…,\"ty\":…}".into(), + ); + }; + for (k, d) in [ + ("a", 1.0), + ("b", 0.0), + ("tx", 0.0), + ("c", 0.0), + ("d", 1.0), + ("ty", 0.0), + ] { + fill_default(&mut m, k, json!(d)); + } + from_json(Value::Object(m)) +} diff --git a/crates/frame/src/embed/cpu.rs b/crates/frame/src/embed/cpu.rs new file mode 100644 index 00000000..d67b9ac3 --- /dev/null +++ b/crates/frame/src/embed/cpu.rs @@ -0,0 +1,295 @@ +//! The CPU path: rasterise an embed with `vello_cpu`. +//! +//! What REAPER's LICE bitmap and headless tests get. No device, no +//! readback: a strip-sized embed rasterises in well under a millisecond +//! (see `tests/harness.rs` for numbers), which is cheaper than a GPU round +//! trip at these sizes before the first byte is copied back. + +use kurbo::{Affine, BezPath, Rect, Stroke}; +use peniko::{Brush, Color, Fill}; +use vello_cpu::{RenderContext, RenderMode, Resources}; + +use crate::embed::canvas::Canvas; + +/// A [`Canvas`] over a `vello_cpu` render context. +pub struct CpuCanvas { + ctx: RenderContext, +} + +impl CpuCanvas { + #[must_use] + pub fn new(width: u16, height: u16) -> Self { + Self { + ctx: RenderContext::new(width.max(1), height.max(1)), + } + } + + fn brush(&mut self, brush: &Brush, brush_transform: Option) { + match brush { + Brush::Solid(c) => self.ctx.set_paint(*c), + Brush::Gradient(g) => self.ctx.set_paint(g.clone()), + Brush::Image(_) => self.ctx.set_paint(Color::TRANSPARENT), + } + match brush_transform { + Some(t) => self.ctx.set_paint_transform(t), + None => self.ctx.reset_paint_transform(), + } + } +} + +impl Canvas for CpuCanvas { + fn fill( + &mut self, + transform: Affine, + rule: Fill, + brush: &Brush, + brush_transform: Option, + shape: &BezPath, + ) { + self.ctx.set_transform(transform); + self.ctx.set_fill_rule(rule); + self.brush(brush, brush_transform); + self.ctx.fill_path(shape); + } + + fn stroke(&mut self, transform: Affine, style: &Stroke, brush: &Brush, shape: &BezPath) { + self.ctx.set_transform(transform); + self.brush(brush, None); + self.ctx.set_stroke(style.clone()); + self.ctx.stroke_path(shape); + } + + fn push_clip(&mut self, transform: Affine, shape: &BezPath) { + self.ctx.set_transform(transform); + self.ctx.set_fill_rule(Fill::NonZero); + self.ctx.push_clip_layer(shape); + } + + fn push_opacity(&mut self, transform: Affine, alpha: f32, shape: &BezPath) { + self.ctx.set_transform(transform); + self.ctx.set_fill_rule(Fill::NonZero); + self.ctx + .push_layer(Some(shape), None, Some(alpha), None, None); + } + + fn pop(&mut self) { + self.ctx.pop_layer(); + } +} + +/// Rasterised pixels: premultiplied RGBA8, row-major, tightly packed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pixels { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl Pixels { + /// Pixel `(x, y)` as `[r, g, b, a]`. + #[must_use] + pub fn at(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.width + x) * 4) as usize; + [ + self.rgba[i], + self.rgba[i + 1], + self.rgba[i + 2], + self.rgba[i + 3], + ] + } + + /// Copy into a LICE-style bitmap: `0xAARRGGBB` words (blue in the low + /// byte), `row_span` words per row, bottom-up when `flipped`. The + /// pixels are opaque when painted over an opaque background, where + /// premultiplied and straight alpha agree. + pub fn write_bgra(&self, bits: &mut [u32], row_span: usize, flipped: bool) { + let (w, h) = (self.width as usize, self.height as usize); + for y in 0..h { + let dy = if flipped { h - 1 - y } else { y }; + let Some(row) = bits.get_mut(dy * row_span..dy * row_span + w) else { + continue; + }; + let src = &self.rgba[y * w * 4..(y + 1) * w * 4]; + for (d, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { + *d = u32::from(s[2]) + | (u32::from(s[1]) << 8) + | (u32::from(s[0]) << 16) + | (u32::from(s[3]) << 24); + } + } + } + + /// Straight-alpha RGBA8 (for PNG). + #[must_use] + pub fn unpremultiplied(&self) -> Vec { + self.rgba + .as_chunks::<4>() + .0 + .iter() + .flat_map(|p| { + let a = u32::from(p[3]); + if a == 0 || a == 255 { + [p[0], p[1], p[2], p[3]] + } else { + let un = |c: u8| ((u32::from(c) * 255 + a / 2) / a).min(255) as u8; + [un(p[0]), un(p[1]), un(p[2]), p[3]] + } + }) + .collect() + } + + /// Write a PNG. + /// + /// # Errors + /// When the file cannot be written. + pub fn save_png(&self, path: impl AsRef) -> Result<(), String> { + let path = path.as_ref(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + image::save_buffer( + path, + &self.unpremultiplied(), + self.width, + self.height, + image::ExtendedColorType::Rgba8, + ) + .map_err(|e| e.to_string()) + } + + /// Several images side by side, top-aligned, `gap` px apart on `bg`. + #[must_use] + pub fn row(images: &[Self], gap: u32, bg: [u8; 4]) -> Self { + let width = images.iter().map(|i| i.width).sum::() + + gap * images.len().saturating_sub(1) as u32; + let height = images.iter().map(|i| i.height).max().unwrap_or(0); + let mut rgba: Vec = bg + .iter() + .copied() + .cycle() + .take((width * height * 4) as usize) + .collect(); + let mut x0 = 0; + for img in images { + for y in 0..img.height { + let src = + &img.rgba[(y * img.width * 4) as usize..((y + 1) * img.width * 4) as usize]; + let at = ((y * width + x0) * 4) as usize; + rgba[at..at + src.len()].copy_from_slice(src); + } + x0 += img.width + gap; + } + Self { + width, + height, + rgba, + } + } + + /// Stack images top to bottom, left-aligned. + #[must_use] + pub fn column(images: &[Self], gap: u32, bg: [u8; 4]) -> Self { + let width = images.iter().map(|i| i.width).max().unwrap_or(0); + let height = images.iter().map(|i| i.height).sum::() + + gap * images.len().saturating_sub(1) as u32; + let mut rgba: Vec = bg + .iter() + .copied() + .cycle() + .take((width * height * 4) as usize) + .collect(); + let mut y0 = 0; + for img in images { + for y in 0..img.height { + let src = + &img.rgba[(y * img.width * 4) as usize..((y + 1) * img.width * 4) as usize]; + let at = (((y0 + y) * width) * 4) as usize; + rgba[at..at + src.len()].copy_from_slice(src); + } + y0 += img.height + gap; + } + Self { + width, + height, + rgba, + } + } +} + +/// The strip ground behind an embed in the harness (session's strip). +pub const STRIP_GROUND: Color = Color::from_rgb8(0x18, 0x18, 0x1b); + +/// Render `embed` (both passes) at its size × `scale`, at `t` ms. +pub fn snapshot( + raster: &mut Rasterizer, + embed: &mut crate::embed::Embed, + scale: f64, + t: f64, +) -> Pixels { + let (w, h) = embed.size(); + raster.render( + (w * scale).round() as u32, + (h * scale).round() as u32, + scale, + STRIP_GROUND, + |c, at| embed.paint(c, at, t), + ) +} + +/// A reusable CPU rasteriser (keeps its resources between frames). +#[derive(Default)] +pub struct Rasterizer { + resources: Resources, +} + +impl std::fmt::Debug for Rasterizer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Rasterizer") + } +} + +impl Rasterizer { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Rasterise `draw` into a `width × height` device-pixel image, the + /// drawing scaled by `scale` (HiDPI: logical → device px), over `bg`. + pub fn render( + &mut self, + width: u32, + height: u32, + scale: f64, + bg: Color, + draw: impl FnOnce(&mut CpuCanvas, Affine), + ) -> Pixels { + let (w, h) = ( + width.clamp(1, u32::from(u16::MAX)) as u16, + height.clamp(1, u32::from(u16::MAX)) as u16, + ); + let mut canvas = CpuCanvas::new(w, h); + if bg.components[3] > 0.0 { + canvas.ctx.set_transform(Affine::IDENTITY); + canvas.ctx.set_paint(bg); + canvas + .ctx + .fill_rect(&Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + } + draw(&mut canvas, Affine::scale(scale)); + canvas.ctx.flush(); + let mut rgba = vec![0u8; usize::from(w) * usize::from(h) * 4]; + canvas.ctx.render_to_buffer( + &mut self.resources, + &mut rgba, + w, + h, + RenderMode::OptimizeSpeed, + ); + Pixels { + width: u32::from(w), + height: u32::from(h), + rgba, + } + } +} diff --git a/crates/frame/src/embed/curves.rs b/crates/frame/src/embed/curves.rs new file mode 100644 index 00000000..dfabcb35 --- /dev/null +++ b/crates/frame/src/embed/curves.rs @@ -0,0 +1,282 @@ +//! Curve providers for the Tone rack's panels. +//! +//! frame's builtins (`eq.response`, `comp.transfer`, …) plus the ones the +//! rack needs that frame did not have: +//! +//! | provider | inputs | series | +//! |---|---|---| +//! | `sat.transfer` | `drive`, `bias`, `circuit`, `bits`, `reach` (a live peak: only \|x\| ≤ reach) | `transfer` (x, y in −1..1), `unity` | +//! | `sat.harmonics` | `drive`, `bias`, `circuit`, `level` | `rungs`, `evens`, `odds`: (k, share) for H2…H8, k = 0…6 | +//! | `echo.taps` | `time` (ms), `feedback`, `family` | `taps`: stems (x in delay times, y level 0..1), `dry` | +//! | `room.tail` | `decay` (s), `predelay` (ms), `mix`, `family` | `tail`: (x 0..1 of the decay window, y 0..1) | +//! +//! These are frame's own stand-ins so a surface works standalone. **The +//! host overrides any of them with the plugin's own maths** — the whole +//! point of the rack is that its curve and the plugin window's cannot +//! disagree — by registering the same name +//! ([`crate::embed::Embed::register_curve`], or on a [`CurveRegistry`]): +//! +//! ```ignore +//! embed.register_curve("eq.response", |i| session_eq_response(i)); // eq_ui::calculate_combined_response +//! embed.register_curve("comp.transfer", |i| session_comp_transfer(i)); // comp_ui::compress_transfer +//! embed.register_curve("sat.transfer", |i| session_sat_transfer(i)); // saturate_dsp::…::transfer_curve +//! embed.register_curve("room.tail", |i| session_room_tail(i)); // session live::tail(room.key()) +//! ``` + +use std::f64::consts::PI; + +use crate::surface::runtime::{CurveInputs, CurveRegistry, Series}; + +/// frame's builtins plus the Tone rack's. +#[must_use] +pub fn registry() -> CurveRegistry { + let mut r = CurveRegistry::builtin(); + register(&mut r); + r +} + +/// Add the Tone rack's providers to `r` (overriding nothing a host already +/// registered under the same name would want kept — call this first). +pub fn register(r: &mut CurveRegistry) { + r.register("sat.transfer", sat_transfer); + r.register("sat.harmonics", sat_harmonics); + r.register("echo.taps", echo_taps); + r.register("room.tail", room_tail); +} + +/// The saturator circuits, in the plugin's rail order. +pub const CIRCUITS: [&str; 5] = ["Valve", "Tape", "Core", "Solid", "Steps"]; + +/// One sample of a circuit's static curve: `x` in −1..1 → out, normalised +/// so full scale in lands at ±1 out. +#[must_use] +pub fn sat_sample(x: f64, drive: f64, bias: f64, circuit: usize) -> f64 { + let d = drive.max(0.05); + let shape = |v: f64| match circuit { + // Valve: an asymmetric soft knee — the bias pushes one side into + // the bend first, which is where the even harmonics come from. + 0 => (d * (v + bias)).tanh() - (d * bias).tanh(), + // Tape: softer, rounder, arctan. + 1 => (d * 1.2 * (v + bias * 0.5)).atan() - (d * 1.2 * bias * 0.5).atan(), + // Core (transformer): cubic soft clip. + 2 => { + let u = (d * (v + bias * 0.3)).clamp(-1.5, 1.5); + u - u * u * u / 6.75 - { + let b = (d * bias * 0.3).clamp(-1.5, 1.5); + b - b * b * b / 6.75 + } + } + // Solid state: nearly linear, then a hard shoulder. + 3 => { + let u = d * (v + bias * 0.2); + u / (1.0 + u.abs().powi(6)).powf(1.0 / 6.0) - { + let b = d * bias * 0.2; + b / (1.0 + b.abs().powi(6)).powf(1.0 / 6.0) + } + } + // Steps: a rail clip; the quantiser is `bits`. + _ => (d * v).clamp(-1.0, 1.0), + }; + let top = shape(1.0).abs().max(shape(-1.0).abs()).max(1e-6); + shape(x) / top +} + +fn sat_transfer(i: &CurveInputs) -> Vec { + let drive = i.or("drive", 2.0); + let bias = i.or("bias", 0.15); + let circuit = i.or("circuit", 0.0).round().max(0.0) as usize; + let bits = i.or("bits", 0.0); + // `reach` (a live peak, 0..1): only the part of the curve the signal + // is on — the saturator's "is it doing anything" stroke. + let reach = i.get("reach").map(|r| r.clamp(0.0, 1.0)); + let n = i.samples.max(16); + let pts = (0..n) + .map(|k| { + let x = -1.0 + 2.0 * k as f64 / (n - 1) as f64; + let mut y = sat_sample(x, drive, bias, circuit); + if bits >= 1.0 { + let levels = (bits.clamp(1.0, 16.0) - 1.0).exp2(); + y = (y * levels).round() / levels; + } + (x, y) + }) + .filter(|(x, _)| reach.is_none_or(|r| x.abs() <= r)) + .collect(); + vec![ + Series::new("transfer", pts), + Series::new("unity", vec![(-1.0, -1.0), (1.0, 1.0)]), + ] +} + +/// H2…H8 of the circuit at `level` (0..1 peak), by DFT of one cycle +/// through [`sat_sample`] — measured, like the plugin's probe, not guessed. +#[must_use] +pub fn harmonics(drive: f64, bias: f64, circuit: usize, level: f64) -> [f64; 7] { + const N: usize = 256; + let wave: Vec = (0..N) + .map(|k| { + sat_sample( + level * (2.0 * PI * k as f64 / N as f64).sin(), + drive, + bias, + circuit, + ) + }) + .collect(); + let amp = |h: usize| { + let (mut re, mut im) = (0.0, 0.0); + for (k, v) in wave.iter().enumerate() { + let a = 2.0 * PI * (h * k) as f64 / N as f64; + re += v * a.cos(); + im += v * a.sin(); + } + 2.0 * (re * re + im * im).sqrt() / N as f64 + }; + let fundamental = amp(1).max(1e-9); + let mut out = [0.0; 7]; + for (k, o) in out.iter_mut().enumerate() { + *o = (amp(k + 2) / fundamental).clamp(0.0, 1.0); + } + out +} + +fn sat_harmonics(i: &CurveInputs) -> Vec { + let rungs = harmonics( + i.or("drive", 2.0), + i.or("bias", 0.15), + i.or("circuit", 0.0).round().max(0.0) as usize, + i.or("level", 1.0).clamp(0.0, 1.0), + ); + // A share on a square root so the fourth and sixth show beside a second + // that dwarfs them (session draws the ladder the same way). + let pts: Vec<(f64, f64)> = rungs + .iter() + .enumerate() + .map(|(k, r)| (k as f64, (r * 4.0).clamp(0.0, 1.0).sqrt())) + .collect(); + // Rung k is harmonic k + 2: the evens are the even k. + let only = |even: bool| { + pts.iter() + .map(|&(k, v)| { + ( + k, + if (k as usize).is_multiple_of(2) == even { + v + } else { + f64::NAN + }, + ) + }) + .collect() + }; + vec![ + Series::new("rungs", pts.clone()), + Series::new("evens", only(true)), + Series::new("odds", only(false)), + ] +} + +/// The even share of what the circuit adds (the number an engineer reads +/// a saturator by). +#[must_use] +pub fn even_share(rungs: &[f64; 7]) -> f64 { + let total: f64 = rungs.iter().sum(); + if total <= 1e-12 { + 0.0 + } else { + rungs.iter().step_by(2).sum::() / total + } +} + +/// Where a rhythmic delay's taps land, in delay times (session's `PATTERN`). +const PATTERN: [f64; 5] = [1.0, 0.5, 0.75, 0.5, 1.25]; + +/// The delay families, in the plugin's order. +pub const DELAY_FAMILIES: [&str; 6] = ["Digital", "Tape", "Analog", "Rhythmic", "Pitch", "Special"]; + +/// Taps of a delay: `(when, level)`, when in delay times, the dry hit first. +#[must_use] +pub fn taps(feedback: f64, family: usize) -> Vec<(f64, f64)> { + let feedback = feedback.clamp(0.0, 0.99); + let mut out = Vec::new(); + let (mut when, mut level) = (0.0, 1.0); + for tap in 0..16 { + if when > 4.5 || level < 0.03 { + break; + } + out.push((when, level)); + when += if family == 3 { + PATTERN[tap % PATTERN.len()] + } else { + 1.0 + }; + level *= feedback; + if tap == 0 { + level = level.max(0.0); + } + } + out +} + +fn echo_taps(i: &CurveInputs) -> Vec { + let family = i.or("family", 0.0).round().max(0.0) as usize; + let all = taps(i.or("feedback", 0.4), family); + let mut stems = Vec::new(); + for (k, (x, level)) in all.iter().enumerate().skip(1) { + // Tape loses top each pass; a chip smears; pitch rises — the + // family's shape is drawn by the panel, the level is the taps'. + let dull = if family == 1 { + (1.0 - 0.08 * k as f64).max(0.4) + } else { + 1.0 + }; + stems.extend([(*x, 0.0), (*x, level * dull), (*x, f64::NAN)]); + } + vec![ + Series::new("taps", stems), + Series::new("dry", vec![(0.0, 0.0), (0.0, 1.0)]), + ] +} + +/// The reverb families, in the plugin's order. +pub const ROOM_FAMILIES: [&str; 5] = ["Room", "Hall", "Plate", "Spring", "Bloom"]; + +/// A reverb's envelope at `t` (0..1 of its decay window), 0..1. +#[must_use] +pub fn tail_at(t: f64, family: usize) -> f64 { + // −60 dB across the window, as a level on a 60 dB scale: a straight + // line falling to the floor, shaped per family. + let base = (1.0 - t).clamp(0.0, 1.0); + match family { + // A room: early reflections, then a quick fall. + 0 => base.powf(1.6) * (1.0 + 0.12 * (t * 60.0).sin().abs() * (1.0 - t)), + // A plate: dense from the first millisecond, a long even fall. + 2 => base.powf(0.9), + // A spring: drips — a ringing ripple on the fall. + 3 => base * (0.8 + 0.2 * (t * 34.0).cos()), + // Bloom: rises before it falls. + 4 => (t * 5.0).min(1.0) * base.powf(0.8), + // A hall. + _ => base, + } +} + +fn room_tail(i: &CurveInputs) -> Vec { + let family = i.or("family", 1.0).round().max(0.0) as usize; + let mix = i.or("mix", 0.3).clamp(0.0, 1.0); + // The mix scales the tail's height, never below a quarter (session). + let scale = mix.mul_add(0.75, 0.25); + let window = (i.or("decay", 2.0) * 1000.0).max(1.0); + let start = (i.or("predelay", 20.0) / window).clamp(0.0, 0.5); + let n = i.samples.clamp(16, 128); + let pts = (0..n) + .map(|k| { + let t = k as f64 / (n - 1) as f64; + ( + start + t * (1.0 - start), + tail_at(t, family).clamp(0.0, 1.0) * scale * 0.92, + ) + }) + .collect(); + vec![Series::new("tail", pts)] +} diff --git a/crates/frame/src/embed/embed.rs b/crates/frame/src/embed/embed.rs new file mode 100644 index 00000000..605caf31 --- /dev/null +++ b/crates/frame/src/embed/embed.rs @@ -0,0 +1,863 @@ +//! One frame surface living in a host's strip. +//! +//! The host owns the box; the embed owns what is in it: +//! +//! 1. **Size.** [`Embed::set_size`] hands over the strip's box (logical +//! px). The view lays itself out for it — reflow, not letterbox — at the +//! [`Tier`] the width falls in. Values, feeds and pointer capture survive +//! the relayout. +//! 2. **Paint, in two passes.** [`Embed::record`] paints everything that +//! depends only on params (curves, handles, labels, the ground) — a host +//! records it once into its own scene and replays it under the strip's +//! transform until [`Embed::needs_record`] says otherwise. [`Embed::live`] +//! paints what moves with the audio or with time (plots bound to feeds, +//! meters, motion) over the top, every frame while +//! [`Embed::animating`]. This is session's mixer model: record once, +//! replay under a transform, meters in a live pass. +//! 3. **Pointer.** [`Embed::pointer`] takes strip-local coordinates and +//! returns the engine calls the gesture made, plus whether the host +//! should redraw or handle the event itself (a right click passes +//! through to the host's context menu). + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use crate::document::proto::{Node, NodeId, Scene, Transform}; +use crate::surface::proto::{ + Binding, Catalog, ControlKind, EntryId, InputSource, ParamAddr, ParamValue, Source, StreamFrame, +}; +use crate::surface::runtime::{ + Bounds, CurveRegistry, EngineCall, Input, Modifiers, Runtime, Surface, +}; +use kurbo::Affine; + +use crate::embed::canvas::{Canvas, Recording}; +use crate::embed::form::{EmbedHost, SizeHints, Tier}; +use crate::embed::paint::paint_filtered; +use crate::embed::text::Font; + +/// Param values in engineering units (a choice's option index, a +/// toggle's 0/1), by address. +pub type Values = BTreeMap; + +/// What a view is built for. +#[derive(Debug, Clone)] +pub struct BuildCtx { + /// The box, logical px. + pub size: (f64, f64), + pub tier: Tier, + /// Current param values (a view may lay out by them: which machine a + /// delay is, which phases are folded). + pub values: Values, +} + +impl BuildCtx { + #[must_use] + pub fn new(size: (f64, f64), values: Values) -> Self { + Self { + size, + tier: Tier::at(size.0), + values, + } + } + + /// A value, or `default` when the catalog has no such param. + #[must_use] + pub fn value(&self, addr: &str, default: f64) -> f64 { + self.values + .get(addr) + .copied() + .filter(|v| v.is_finite()) + .unwrap_or(default) + } +} + +/// Mutates one node of the frame being painted from the current values. +pub type DeriveFn = Arc; + +/// A node property that is a function of several params — a header's +/// summary ("4 · 5.5dB"), a band handle's hue (its frequency), the shaded +/// band between a gate's two lines. Controls bind one param to one slot; +/// this is the rest, applied to the render copy after the controls, so it +/// can never drift from them. +#[derive(Clone)] +pub struct Derived { + pub node: NodeId, + pub apply: DeriveFn, +} + +impl Derived { + pub fn new(node: NodeId, f: impl Fn(&Values, &mut Node) + Send + Sync + 'static) -> Self { + Self { + node, + apply: Arc::new(f), + } + } +} + +impl std::fmt::Debug for Derived { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Derived") + .field("node", &self.node) + .finish_non_exhaustive() + } +} + +/// A view built for one box. +#[derive(Debug, Clone)] +pub struct Built { + pub surface: Surface, + /// The page the view is drawn from (its first child is the root frame, + /// at the origin, the box's size). + pub page: NodeId, + /// Properties computed from values each frame. + pub derived: Vec, + /// Params the layout itself depends on: when one changes (and no + /// gesture is in flight) the view is built again. + pub rebuild_on: Vec, + /// Page px per box px: a page laid out at another size, drawn (and hit) + /// scaled to the box. 1 for views built at the box's size. + pub scale: f64, +} + +impl Built { + #[must_use] + pub fn new(surface: Surface, page: NodeId) -> Self { + Self { + surface, + page, + derived: Vec::new(), + rebuild_on: Vec::new(), + scale: 1.0, + } + } +} + +/// Something that lays itself out for a host's box. +pub trait EmbedView: Send { + /// The params, feeds and actions every build binds against. + fn catalog(&self) -> Catalog; + + /// Build the surface for `ctx.size` (logical px) at `ctx.tier`. + fn build(&self, ctx: &BuildCtx) -> Built; + + /// The size hints the host should use. + fn hints(&self, host: EmbedHost) -> SizeHints { + host.hints() + } + + /// The height this view wants at `width`, when the host lets it + /// choose (a strip column does; REAPER does not). + fn height_for(&self, width: f64, values: &Values) -> Option { + let _ = (width, values); + None + } + + /// Curve providers to add to the runtime's builtins. + fn curves(&self, registry: &mut CurveRegistry) { + let _ = registry; + } +} + +/// Which mouse button. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Button { + Left, + Right, + Middle, +} + +/// Pointer input in strip-local logical px. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Pointer { + Move { + x: f64, + y: f64, + }, + Down { + x: f64, + y: f64, + button: Button, + mods: Modifiers, + }, + Up { + x: f64, + y: f64, + button: Button, + }, + DoubleClick { + x: f64, + y: f64, + button: Button, + }, + /// Notches, positive = up/away. REAPER's 120 per step is one notch. + Wheel { + x: f64, + y: f64, + notches: f64, + mods: Modifiers, + }, + /// The pointer left the box (or capture was lost). + Leave, +} + +/// What a pointer event did. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Handled { + /// Calls to send to the engine. + pub calls: Vec, + /// A control took the event. + pub handled: bool, + /// The event is the host's (a right click: its context menu). + pub passthrough: bool, + /// The embed should be repainted now (the record pass too). + pub redraw: bool, +} + +/// A surface in a host's strip. See the module docs. +pub struct Embed { + view: Box, + host: EmbedHost, + catalog: Catalog, + curves: CurveRegistry, + runtime: Runtime, + page: NodeId, + derived: Vec, + rebuild_on: Vec<(ParamAddr, f64)>, + /// Page px per box px (see [`Built::scale`]). + scale: f64, + size: (f64, f64), + tier: Tier, + /// Nodes drawn in the live pass. + live: HashSet, + /// The record pass needs repainting. + dirty: bool, + /// A button is down (no relayout mid-gesture). + pressed: bool, + /// Last frame of each feed, replayed into a rebuilt runtime. + frames: HashMap, + font: Font, + builds: u64, +} + +impl std::fmt::Debug for Embed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Embed") + .field("host", &self.host) + .field("size", &self.size) + .field("tier", &self.tier) + .finish_non_exhaustive() + } +} + +impl Embed { + /// An embed of `view` for `host`, laid out for a strip-wide box until + /// [`Embed::set_size`] says otherwise. + #[must_use] + pub fn new(view: impl EmbedView + 'static, host: EmbedHost) -> Self { + let catalog = view.catalog(); + let mut curves = crate::embed::curves::registry(); + view.curves(&mut curves); + let hints = view.hints(host); + let size = hints.clamp(crate::embed::form::STRIP_W, 160.0); + let values = defaults(&catalog); + let built = view.build(&BuildCtx::new(size, values)); + let runtime = Runtime::new(built.surface, catalog.clone()).with_curves(curves.clone()); + let mut e = Self { + view: Box::new(view), + host, + catalog, + curves, + runtime, + page: built.page, + derived: built.derived, + scale: built.scale, + rebuild_on: Vec::new(), + size, + tier: Tier::at(size.0), + live: HashSet::new(), + dirty: true, + pressed: false, + frames: HashMap::new(), + font: Font::default(), + builds: 1, + }; + e.rebuild_on = built.rebuild_on.into_iter().map(|a| (a, 0.0)).collect(); + e.after_build(); + e + } + + /// Use a different face for labels. + #[must_use] + pub fn with_font(mut self, font: Font) -> Self { + self.font = font; + self.dirty = true; + self + } + + /// Register (or override) a curve provider — session's + /// `eq_ui::calculate_combined_response`, say, under `eq.response`. + pub fn register_curve( + &mut self, + name: &str, + f: impl Fn(&crate::surface::runtime::CurveInputs) -> Vec + + Send + + Sync + + 'static, + ) { + self.curves.register(name, f); + *self.runtime.curves_mut() = self.curves.clone(); + self.dirty = true; + } + + #[must_use] + pub fn host(&self) -> EmbedHost { + self.host + } + + #[must_use] + pub fn size(&self) -> (f64, f64) { + self.size + } + + #[must_use] + pub fn tier(&self) -> Tier { + self.tier + } + + #[must_use] + pub fn hints(&self) -> SizeHints { + self.view.hints(self.host) + } + + /// The view's hints for another host (REAPER asks per context). + #[must_use] + pub fn hints_for(&self, host: EmbedHost) -> SizeHints { + self.view.hints(host) + } + + /// The height the view wants at `width`, if it has a preference. + #[must_use] + pub fn height_for(&self, width: f64) -> Option { + self.view.height_for(width, &self.values()) + } + + /// How many times the view has been laid out (tests: a resize to the + /// same box does not relayout). + #[must_use] + pub fn builds(&self) -> u64 { + self.builds + } + + #[must_use] + pub fn runtime(&self) -> &Runtime { + &self.runtime + } + + /// The runtime, for anything the embed does not wrap. Marks the record + /// pass dirty. + pub fn runtime_mut(&mut self) -> &mut Runtime { + self.dirty = true; + &mut self.runtime + } + + #[must_use] + pub fn catalog(&self) -> &Catalog { + &self.catalog + } + + #[must_use] + pub fn page(&self) -> NodeId { + self.page + } + + /// Every param's current value, engineering units. + #[must_use] + pub fn values(&self) -> Values { + self.catalog + .params + .iter() + .filter_map(|p| Some((p.addr.0.clone(), self.runtime.real(&p.addr)?))) + .collect() + } + + /// One param's value, engineering units. + #[must_use] + pub fn value(&self, addr: &str) -> Option { + self.runtime.real(&ParamAddr::new(addr)) + } + + /// Lay out for a `w × h` box (logical px). Returns whether the view + /// was rebuilt. + pub fn set_size(&mut self, w: f64, h: f64) -> bool { + let (w, h) = (w.max(1.0).round(), h.max(1.0).round()); + if (w, h) == self.size { + return false; + } + self.size = (w, h); + self.tier = Tier::at(w); + self.rebuild(); + true + } + + fn rebuild(&mut self) { + let values = self.runtime.values(); + let built = self.view.build(&BuildCtx::new(self.size, self.values())); + let mut runtime = + Runtime::new(built.surface, self.catalog.clone()).with_curves(self.curves.clone()); + runtime.apply_values(values); + for f in self.frames.values() { + runtime.apply_frame(f.clone(), 0.0); + } + self.runtime = runtime; + self.page = built.page; + self.derived = built.derived; + self.scale = built.scale; + self.rebuild_on = built.rebuild_on.into_iter().map(|a| (a, 0.0)).collect(); + self.builds += 1; + self.after_build(); + } + + fn after_build(&mut self) { + let scene = &self.runtime.surface().scene; + let boxes: Vec<(NodeId, Bounds)> = + crate::render::build_scene_layout_boxes(scene, self.page) + .into_iter() + .map(|(id, b)| { + ( + id, + Bounds { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + }, + ) + }) + .collect(); + self.live = live_nodes(self.runtime.surface()); + self.runtime.set_boxes(boxes); + for (addr, v) in &mut self.rebuild_on { + *v = self.runtime.real(addr).unwrap_or(f64::NAN); + } + self.dirty = true; + } + + /// Relayout if a param the layout depends on moved (never mid-gesture). + fn settle(&mut self) { + if self.pressed { + return; + } + let moved = self.rebuild_on.iter().any(|(a, v)| { + self.runtime + .real(a) + .is_some_and(|now| (now - *v).abs() > 1e-9) + }); + if moved { + self.rebuild(); + } + } + + // ── engine → embed ──────────────────────────────────────────────────── + + pub fn apply_values(&mut self, values: impl IntoIterator) { + self.runtime.apply_values(values); + self.dirty = true; + self.settle(); + } + + /// A feed frame at `t` ms. Feeds only touch the live pass — unless a + /// curve reads the feed, which moves the record pass too. + pub fn apply_frame(&mut self, frame: StreamFrame, t: f64) { + if self.curve_reads(&frame.id) { + self.dirty = true; + } + self.frames.insert(frame.id.clone(), frame.clone()); + self.runtime.apply_frame(frame, t); + } + + fn curve_reads(&self, id: &EntryId) -> bool { + self.runtime + .surface() + .controls + .iter() + .any(|(n, c)| match &c.kind { + ControlKind::Curve { inputs, .. } => { + !self.live.contains(n) + && inputs.iter().any( + |i| matches!(&i.source, InputSource::Stream { id: s, .. } if s == id), + ) + } + _ => false, + }) + } + + /// Flush coalesced edits (call once a frame while a gesture is live). + pub fn poll(&mut self, t: f64) -> Vec { + let calls = self.runtime.poll(t); + if !calls.is_empty() { + self.dirty = true; + } + calls + } + + // ── paint ───────────────────────────────────────────────────────────── + + /// Whether the record pass is stale (a param moved, the box changed). + #[must_use] + pub fn needs_record(&self) -> bool { + self.dirty + } + + /// Whether the live pass should run again soon (a feed is playing, a + /// meter is falling, a gesture or an animation is in flight). A host + /// that is not animating can sleep. + #[must_use] + pub fn animating(&self) -> bool { + self.runtime.animating() + } + + /// Whether this view has a live pass at all. + #[must_use] + pub fn has_live(&self) -> bool { + !self.live.is_empty() + } + + fn frame(&self, t: f64) -> Scene { + let mut scene = self.runtime.frame(t); + if !self.derived.is_empty() { + let values = self.values(); + for d in &self.derived { + if let Some(n) = scene.node_mut(d.node) { + (d.apply)(&values, n); + } + } + } + scene + } + + /// Paint the params-only part into `canvas` under `at` (canvas ← strip + /// space), and mark it clean. + pub fn record(&mut self, canvas: &mut (impl Canvas + ?Sized), at: Affine) { + self.settle(); + let scene = self.frame(0.0); + let prims = crate::render::build_scene_primitives(&scene, self.page); + let live = &self.live; + let font = self.font.for_scene(&scene); + paint_filtered( + canvas, + &prims, + at * Affine::scale(self.scale), + &font, + &|id| !live.contains(&id), + ); + self.dirty = false; + } + + /// The record pass as a [`Recording`] in strip space (replay it under + /// the strip's transform). + pub fn recording(&mut self) -> Recording { + let mut rec = Recording::new(); + self.record(&mut rec, Affine::IDENTITY); + rec + } + + /// Paint what moves at `t` ms over the record pass. + pub fn live(&self, canvas: &mut (impl Canvas + ?Sized), at: Affine, t: f64) { + if self.live.is_empty() { + return; + } + let scene = self.frame(t); + let prims = crate::render::build_scene_primitives(&scene, self.page); + let live = &self.live; + let font = self.font.for_scene(&scene); + paint_filtered( + canvas, + &prims, + at * Affine::scale(self.scale), + &font, + &|id| live.contains(&id), + ); + } + + /// Both passes in one walk, for hosts that repaint everything every + /// frame (a LICE bitmap). + pub fn paint(&mut self, canvas: &mut (impl Canvas + ?Sized), at: Affine, t: f64) { + self.settle(); + let scene = self.frame(t); + let prims = crate::render::build_scene_primitives(&scene, self.page); + let font = self.font.for_scene(&scene); + crate::embed::paint::paint_primitives( + canvas, + &prims, + at * Affine::scale(self.scale), + &font, + ); + self.dirty = false; + } + + // ── pointer ─────────────────────────────────────────────────────────── + + /// The control under a strip-local point. + #[must_use] + pub fn hit(&self, x: f64, y: f64) -> Option { + self.runtime.hit(x / self.scale, y / self.scale) + } + + /// Route pointer input (strip-local logical px) at `t` ms. + pub fn pointer(&mut self, event: Pointer, t: f64) -> Handled { + let k = 1.0 / self.scale; + let event = match event { + Pointer::Move { x, y } => Pointer::Move { x: x * k, y: y * k }, + Pointer::Down { x, y, button, mods } => Pointer::Down { + x: x * k, + y: y * k, + button, + mods, + }, + Pointer::Up { x, y, button } => Pointer::Up { + x: x * k, + y: y * k, + button, + }, + Pointer::DoubleClick { x, y, button } => Pointer::DoubleClick { + x: x * k, + y: y * k, + button, + }, + Pointer::Wheel { + x, + y, + notches, + mods, + } => Pointer::Wheel { + x: x * k, + y: y * k, + notches, + mods, + }, + Pointer::Leave => Pointer::Leave, + }; + let mut out = Handled::default(); + match event { + Pointer::Down { + button: Button::Right | Button::Middle, + .. + } + | Pointer::Up { + button: Button::Right | Button::Middle, + .. + } + | Pointer::DoubleClick { + button: Button::Right | Button::Middle, + .. + } => { + out.passthrough = true; + } + Pointer::Down { x, y, mods, .. } => { + let hit = self.runtime.hit(x, y); + out.handled = hit.is_some(); + self.pressed = true; + // A cmd/ctrl-click resets, like a double-click. + if mods.alt + && let Some(id) = hit + { + out.calls = self.runtime.feed(id, Input::DoubleClick, t); + } else { + out.calls = self.runtime.pointer(Input::Down { x, y, t, mods }, t); + } + } + Pointer::Move { x, y } => { + out.calls = self.runtime.pointer( + Input::Move { + x, + y, + t, + mods: Modifiers::default(), + }, + t, + ); + out.handled = !out.calls.is_empty(); + } + Pointer::Up { x, y, .. } => { + out.calls = self.runtime.pointer(Input::Up { x, y, t }, t); + out.handled = true; + self.pressed = false; + } + Pointer::DoubleClick { x, y, .. } => { + if let Some(id) = self.runtime.hit(x, y) { + out.calls = self.runtime.feed(id, Input::DoubleClick, t); + out.handled = true; + } + } + Pointer::Wheel { + x, + y, + notches, + mods, + } => { + if let Some(id) = self.runtime.hit(x, y) { + out.calls = self.runtime.feed(id, Input::Wheel { notches, mods }, t); + out.handled = true; + } + } + Pointer::Leave => { + if self.pressed { + out.calls = self.runtime.pointer(Input::Cancel { t }, t); + self.pressed = false; + } + } + } + if !out.calls.is_empty() { + out.redraw = true; + self.dirty = true; + } + if !self.pressed { + let before = self.builds; + self.settle(); + out.redraw |= self.builds != before; + } + out + } +} + +/// Every param at its default, engineering units. +fn defaults(catalog: &Catalog) -> Values { + let rt = Runtime::new( + Surface { + scene: Scene::new("defaults"), + controls: BTreeMap::new(), + }, + catalog.clone(), + ); + catalog + .params + .iter() + .filter_map(|p| Some((p.addr.0.clone(), rt.real(&p.addr)?))) + .collect() +} + +/// Nodes whose drawing moves with feeds or time: every control reading a +/// stream (plots, meters, gauges, stream-fed readouts and curves), and +/// every node with motion — each with its subtree. +fn live_nodes(surface: &Surface) -> HashSet { + let scene: &Scene = &surface.scene; + let mut roots: Vec = Vec::new(); + for (id, c) in &surface.controls { + let streamed = matches!( + &c.binding, + Some(Binding { + source: Source::Stream { .. }, + .. + }) + ) || matches!(&c.kind, ControlKind::Plot { .. }) + || matches!(&c.kind, ControlKind::Curve { inputs, .. } if inputs.iter().any(|i| matches!(i.source, InputSource::Stream { .. }))); + if streamed { + roots.push(*id); + roots.extend(c.slots.iter().map(|s| NodeId(s.node))); + } + } + roots.extend( + scene + .nodes + .values() + .filter(|n| !n.motion.is_empty()) + .map(|n| n.id), + ); + let mut out = HashSet::new(); + for r in roots { + out.extend(scene.walk(r)); + } + out +} + +// ── authored documents ─────────────────────────────────────────────────── + +/// An authored document shown as an embed. +/// +/// The view is a page named for the host (`embed:mcp`, `embed:tcp`, +/// `embed:strip`), optionally one per breakpoint (`embed:mcp@96` is shown +/// from 96 px wide up). The widest breakpoint that fits wins; without an +/// embed page for the host, the `strip` page, then the first page. The +/// page's root frame is scaled to the host's width (its height follows), +/// which is the reflow a document can do without a runtime layout solver. +#[derive(Debug, Clone)] +pub struct DocumentView { + pub surface: Surface, + pub catalog: Catalog, + pub host: EmbedHost, +} + +impl DocumentView { + #[must_use] + pub fn new(surface: Surface, catalog: Catalog, host: EmbedHost) -> Self { + Self { + surface, + catalog, + host, + } + } + + /// The page shown at `width`. + #[must_use] + pub fn page_at(&self, width: f64) -> Option { + let scene = &self.surface.scene; + let pages = scene.pages(); + let named = |host: EmbedHost| { + pages + .iter() + .filter_map(|p| { + let (h, bp) = crate::embed::form::parse_page_name(&scene.node(*p)?.name)?; + (h == host).then_some((*p, bp)) + }) + .filter(|(_, bp)| f64::from(*bp) <= width) + .max_by_key(|(_, bp)| *bp) + .map(|(p, _)| p) + }; + named(self.host) + .or_else(|| named(EmbedHost::Strip)) + .or_else(|| pages.first().copied()) + } + + fn root_size(&self, page: NodeId) -> (f64, f64) { + let scene = &self.surface.scene; + scene + .node(page) + .and_then(|p| p.children.first()) + .and_then(|r| scene.node(*r)) + .map_or((1.0, 1.0), |n| { + (n.size.width.max(1.0), n.size.height.max(1.0)) + }) + } +} + +impl EmbedView for DocumentView { + fn catalog(&self) -> Catalog { + self.catalog.clone() + } + + fn build(&self, ctx: &BuildCtx) -> Built { + let mut surface = self.surface.clone(); + let page = self.page_at(ctx.size.0).unwrap_or(surface.scene.root); + let (w0, _) = self.root_size(page); + // The root sits at the page origin; the embed scales the page. + if let Some(root) = surface + .scene + .node(page) + .and_then(|p| p.children.first().copied()) + && let Some(n) = surface.scene.node_mut(root) + { + n.transform = Transform::IDENTITY; + } + Built { + scale: ctx.size.0 / w0, + ..Built::new(surface, page) + } + } + + fn height_for(&self, width: f64, _values: &Values) -> Option { + let page = self.page_at(width)?; + let (w0, h0) = self.root_size(page); + Some(h0 * width / w0) + } +} diff --git a/crates/frame/src/embed/form.rs b/crates/frame/src/embed/form.rs new file mode 100644 index 00000000..a899d168 --- /dev/null +++ b/crates/frame/src/embed/form.rs @@ -0,0 +1,265 @@ +//! The `Embed` form factor: a module drawn *inside* a host's strip. +//! +//! Every other form (docs/form-factors.md §3.1) is a shape the module +//! chooses: a pedal is 156 × 286, a rack unit is 1140 × 103. An embed is a +//! shape the **host** chooses. A mixer strip gives it the strip's width and +//! whatever height its rack can spare; REAPER gives it a slot in the TCP or +//! MCP and resizes it with the track. So an embed never letterboxes: it +//! reflows, and what it shows is decided by its width, in tiers. +//! +//! The tiers are session's (`apps/session-daw/src/tone.rs`, `Rack::at`), +//! number for number, so a frame panel and the rack it replaces shed detail +//! at the same widths — a mixer of both reads as one mixer. +//! +//! These types are pure data with no dependency beyond serde, written to +//! move into `surface-proto::form` once the full form-factor system lands +//! (docs/form-factors.md §5.1): `FormFactor::Embed(EmbedForm)`. + +use serde::{Deserialize, Serialize}; + +/// Where the embed is drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum EmbedHost { + /// Our own mixer's channel strip (session's MCP): the Tone rack column, + /// one strip wide, a unit per row. + Strip, + /// REAPER's track control panel: a short, wide slot beside the track + /// name (22–80 px tall). + Tcp, + /// REAPER's mixer control panel: a slot in the strip's FX area, one + /// strip wide (60–200 px), height from the strip. + Mcp, +} + +impl EmbedHost { + pub const ALL: [Self; 3] = [Self::Strip, Self::Tcp, Self::Mcp]; + + /// The page an authored document keeps this host's view on (see + /// [`page_name`]). + #[must_use] + pub const fn page(self) -> &'static str { + match self { + Self::Strip => "embed:strip", + Self::Tcp => "embed:tcp", + Self::Mcp => "embed:mcp", + } + } + + /// The markup spelling, `form="embed:mcp"` (docs/form-factors.md §4.2). + #[must_use] + pub const fn markup(self) -> &'static str { + self.page() + } + + #[must_use] + pub fn parse(s: &str) -> Option { + Self::ALL + .into_iter() + .find(|h| h.page() == s || h.page().trim_start_matches("embed:") == s) + } + + /// What this host asks for when it sizes an embed: the numbers REAPER's + /// `REAPER_FXEMBED_WM_GETMINMAXINFO` wants, and what a strip lays out. + #[must_use] + pub const fn hints(self) -> SizeHints { + match self { + // A strip column: the width is the strip's, the height is the + // chain's, so only floors. + Self::Strip => SizeHints { + preferred_aspect: 0.0, + minimum_aspect: 0.0, + min: (MINIMAL, 24.0), + max: (600.0, 4000.0), + }, + // Wide and short, beside the name. + Self::Tcp => SizeHints { + preferred_aspect: 6.0, + minimum_aspect: 1.5, + min: (60.0, 22.0), + max: (1200.0, 160.0), + }, + // Tall and narrow, in the strip. + Self::Mcp => SizeHints { + preferred_aspect: 0.5, + minimum_aspect: 0.1, + min: (MINIMAL, 24.0), + max: (600.0, 800.0), + }, + } + } +} + +/// `FormFactor::Embed` — its only variant is the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EmbedForm { + pub host: EmbedHost, +} + +impl EmbedForm { + #[must_use] + pub const fn new(host: EmbedHost) -> Self { + Self { host } + } + + /// An embed always reflows: the host owns the size. + #[must_use] + pub const fn fit(self) -> EmbedFit { + EmbedFit::Reflow + } +} + +/// How an embed takes its host's box. Always [`EmbedFit::Reflow`]; the +/// enum exists so `Fit` in `surface-proto::form` can absorb it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EmbedFit { + Reflow, +} + +/// Size hints, in logical px. `0.0` means "no preference". +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SizeHints { + /// width / height. + pub preferred_aspect: f64, + pub minimum_aspect: f64, + pub min: (f64, f64), + pub max: (f64, f64), +} + +impl SizeHints { + /// Clamp a host's offer. + #[must_use] + pub fn clamp(&self, w: f64, h: f64) -> (f64, f64) { + ( + w.clamp(self.min.0, self.max.0.max(self.min.0)), + h.clamp(self.min.1, self.max.1.max(self.min.1)), + ) + } +} + +// ── tiers ──────────────────────────────────────────────────────────────── + +/// The narrowest width whose EQ still reads as a frequency axis +/// (session `tone::LEGIBLE`). +pub const LEGIBLE: f64 = 96.0; +/// The narrowest width that still draws a curve (session `tone::SHAPE`): +/// REAPER's 86 px strip, 82 once the insets come off, draws curves. +pub const SHAPE: f64 = 80.0; +/// The narrowest width that still draws indicators (session +/// `tone::MINIMAL`): a rail. +pub const MINIMAL: f64 = 16.0; +/// The narrowest width that hands the panel to the plugin's own editing +/// surface (session `tone::FOCUSED`). +pub const FOCUSED: f64 = 260.0; +/// What a selected strip opens to (session `tone::WORKING`). +pub const WORKING: f64 = 133.0; +/// REAPER's own strip, and session's default (`mcp::STRIP_W`). +pub const STRIP_W: f64 = 86.0; + +/// Level of detail at a width — session's `Rack`, most capable first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Tier { + /// The plugin's own editing surface: grid, labels, band nodes. + Focus, + /// Every panel with its grid and its label. + Full, + /// The curves alone: no grid, no labels. + Curves, + /// A rail: one live indicator per unit. + Minimal, + /// Nothing; the host keeps the room. + Off, +} + +impl Tier { + pub const ALL: [Self; 5] = [ + Self::Focus, + Self::Full, + Self::Curves, + Self::Minimal, + Self::Off, + ]; + + /// What fits in `width` (session's `Rack::at`, same thresholds). + #[must_use] + pub fn at(width: f64) -> Self { + if width >= FOCUSED { + Self::Focus + } else if width >= LEGIBLE { + Self::Full + } else if width >= SHAPE { + Self::Curves + } else if width >= MINIMAL { + Self::Minimal + } else { + Self::Off + } + } + + #[must_use] + pub const fn on(self) -> bool { + !matches!(self, Self::Off) + } + + /// The narrowest width still in this tier. + #[must_use] + pub const fn floor(self) -> Option { + match self { + Self::Focus => Some(FOCUSED), + Self::Full => Some(LEGIBLE), + Self::Curves => Some(SHAPE), + Self::Minimal => Some(MINIMAL), + Self::Off => None, + } + } + + /// Headers, markers, grips. + #[must_use] + pub const fn detailed(self) -> bool { + matches!(self, Self::Focus | Self::Full) + } + + /// The plugin's own calibration (grid labels, nodes). + #[must_use] + pub const fn editing(self) -> bool { + matches!(self, Self::Focus) + } + + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Focus => "focus", + Self::Full => "full", + Self::Curves => "curves", + Self::Minimal => "minimal", + Self::Off => "off", + } + } +} + +/// An authored embed page's name for `host` at a breakpoint: +/// `embed:mcp`, or `embed:mcp@96` for the view shown from 96 px wide up. +/// +/// Views are pages (docs/form-factors.md §4.2, decided); until `Scene.views` +/// exists the page *name* carries the form. A document may hold several +/// pages per host, one per breakpoint; [`crate::embed::DocumentView`] picks the +/// widest breakpoint that fits and scales the page's width to the host's. +#[must_use] +pub fn page_name(host: EmbedHost, min_width: Option) -> String { + match min_width { + Some(w) => format!("{}@{w}", host.page()), + None => host.page().to_string(), + } +} + +/// Parse a page name back: `(host, breakpoint)`. +#[must_use] +pub fn parse_page_name(name: &str) -> Option<(EmbedHost, u32)> { + let (base, bp) = match name.split_once('@') { + Some((b, w)) => (b, w.trim().parse().ok()?), + None => (name, 0), + }; + EmbedHost::ALL + .into_iter() + .find(|h| h.page() == base) + .map(|h| (h, bp)) +} diff --git a/crates/frame/src/embed/fx_slot.rs b/crates/frame/src/embed/fx_slot.rs new file mode 100644 index 00000000..13f16861 --- /dev/null +++ b/crates/frame/src/embed/fx_slot.rs @@ -0,0 +1,154 @@ +//! The shape of an embedded-FX provider, for hosts with a slot seam. +//! +//! session's `daw-ui` expands an FX slot into its plugin's embedded GUI +//! through a registry in context — `EmbeddedFxGuis`, keyed by a case-blind +//! fragment of the plugin name, each provider a +//! `fn(EmbeddedFx { fx, width, height }) -> Element`. frame-embed does not +//! link Dioxus; this is the provider's framework-free half, so the Dioxus +//! glue in session is a few lines (see the crate docs): +//! +//! - [`FxProviders`] maps a plugin-name fragment to a [`FxProvider`] (a +//! function building the frame view for that FX), first match wins, +//! specific before general — `EmbeddedFxGuis`' own rule. +//! - [`FxSlot`] is one expanded slot's state: its [`crate::embed::Embed`], sized +//! to the cell, with a cached record pass the component replays. + +use std::sync::Arc; + +use crate::embed::canvas::Recording; +use crate::embed::embed::{Embed, EmbedView}; +use crate::embed::form::EmbedHost; + +/// Builds the view for one FX (its catalog comes with it). +pub type FxProvider = Arc Box + Send + Sync>; + +/// Plugin-name fragment → provider. +#[derive(Clone, Default)] +pub struct FxProviders { + providers: Vec<(String, FxProvider)>, +} + +impl std::fmt::Debug for FxProviders { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list() + .entries(self.providers.iter().map(|(k, _)| k)) + .finish() + } +} + +impl FxProviders { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Register a provider for plugins whose name contains `fragment`. + pub fn register( + &mut self, + fragment: impl Into, + provider: impl Fn(&str) -> Box + Send + Sync + 'static, + ) { + self.providers.push((fragment.into(), Arc::new(provider))); + } + + /// The provider for `plugin_name` (case-blind substring, first wins). + #[must_use] + pub fn provider_for(&self, plugin_name: &str) -> Option { + let name = plugin_name.to_ascii_lowercase(); + self.providers + .iter() + .find(|(k, _)| name.contains(&k.to_ascii_lowercase())) + .map(|(_, p)| p.clone()) + } + + /// The Tone rack's units as providers: "FTS EQ" → the EQ panel, and so + /// on — what session registers for its own plugins. + #[must_use] + pub fn tone() -> Self { + use crate::embed::tone::{TonePanel, Which}; + let mut r = Self::new(); + for (frag, which) in [ + ("FTS EQ", Which::Eq), + ("FTS Comp", Which::Comp), + ("FTS Gate", Which::Gate), + ("FTS Saturat", Which::Sat), + ("FTS De-Ess", Which::DeEss), + ("FTS Delay", Which::Delay), + ("FTS Reverb", Which::Reverb), + ] { + r.register(frag, move |_| Box::new(TonePanel::new(which))); + } + r + } +} + +/// Lets a boxed view be an embed's view. +struct Boxed(Box); + +impl EmbedView for Boxed { + fn catalog(&self) -> crate::surface::proto::Catalog { + self.0.catalog() + } + fn build(&self, ctx: &crate::embed::embed::BuildCtx) -> crate::embed::embed::Built { + self.0.build(ctx) + } + fn hints(&self, host: EmbedHost) -> crate::embed::form::SizeHints { + self.0.hints(host) + } + fn height_for(&self, width: f64, values: &crate::embed::embed::Values) -> Option { + self.0.height_for(width, values) + } + fn curves(&self, registry: &mut crate::surface::runtime::CurveRegistry) { + self.0.curves(registry); + } +} + +/// One expanded FX slot: an embed in a `width × height` cell, and its +/// record pass cached until a param moves. +pub struct FxSlot { + pub embed: Embed, + cached: Option, +} + +impl std::fmt::Debug for FxSlot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FxSlot") + .field("embed", &self.embed) + .finish_non_exhaustive() + } +} + +impl FxSlot { + /// The slot for `plugin_name` in a `width × height` cell, if a provider + /// claims it. + #[must_use] + pub fn open( + providers: &FxProviders, + plugin_name: &str, + width: f64, + height: f64, + ) -> Option { + let view = providers.provider_for(plugin_name)?(plugin_name); + let mut embed = Embed::new(Boxed(view), EmbedHost::Strip); + embed.set_size(width, height); + Some(Self { + embed, + cached: None, + }) + } + + /// Resize to the cell. + pub fn resize(&mut self, width: f64, height: f64) { + if self.embed.set_size(width, height) { + self.cached = None; + } + } + + /// The record pass, re-recorded only when stale. + pub fn recording(&mut self) -> &Recording { + if self.cached.is_none() || self.embed.needs_record() { + self.cached = Some(self.embed.recording()); + } + self.cached.get_or_insert_with(Recording::new) + } +} diff --git a/crates/frame/src/embed/generic.rs b/crates/frame/src/embed/generic.rs new file mode 100644 index 00000000..3e8dc9c6 --- /dev/null +++ b/crates/frame/src/embed/generic.rs @@ -0,0 +1,279 @@ +//! An embed derived from any catalog: its featured params and meters. +//! +//! Every module gets an embed for free, the way it gets a generated +//! surface (`crate::surface::widgets::surface_for_catalog`): the catalog's +//! featured params (or its first continuous ones) as small arc knobs with +//! their values, its level and gain-reduction feeds as bars, its bypass as +//! a lamp — laid out by breakpoint: +//! +//! | box | shows | +//! |---|---| +//! | narrow (< 60 px) or short (< 40 px) | the first meter, else the hero's value | +//! | small | a meter and up to 2 knobs | +//! | tall (≥ 140 px) or wide (TCP) | a meter and up to 4 knobs | + +use crate::document::proto::{NodeId, TextAlign}; +use crate::surface::proto::{ + Binding, ButtonMode, Catalog, Control, ControlKind, FeedKind, ParamInfo, ParamKind, SlotRole, + ValueFormat, +}; +use crate::surface::widgets::sector_path; + +use crate::embed::embed::{BuildCtx, Built, EmbedView}; +use crate::embed::tone::style::{self as s, alpha}; +use crate::embed::tone::{Kit, Rect4}; + +/// See the module docs. +#[derive(Debug, Clone)] +pub struct FeaturedView { + pub catalog: Catalog, + /// Knob ink. + pub accent: String, +} + +impl FeaturedView { + #[must_use] + pub fn new(catalog: Catalog) -> Self { + Self { + catalog, + accent: "#38bdf8".into(), + } + } + + /// The params worth a knob, most important first. + #[must_use] + pub fn knobs(&self) -> Vec<&ParamInfo> { + let cont = |p: &&ParamInfo| { + matches!(p.kind, ParamKind::Continuous) && !p.addr.0.ends_with("bypass") + }; + let featured: Vec<&ParamInfo> = self + .catalog + .params + .iter() + .filter(|p| p.featured) + .filter(cont) + .collect(); + if featured.is_empty() { + self.catalog.params.iter().filter(cont).take(4).collect() + } else { + featured + } + } + + /// The feed a bar shows: a gain reduction first, then a level. + #[must_use] + pub fn meter(&self) -> Option<(String, bool)> { + let find = |gr: bool| { + self.catalog.streams.iter().find(|f| { + if gr { + matches!(f.kind, FeedKind::GainReduction) + } else { + matches!(f.kind, FeedKind::Level { .. }) + } + }) + }; + find(true) + .map(|f| (f.id.0.clone(), true)) + .or_else(|| find(false).map(|f| (f.id.0.clone(), false))) + } + + fn bypass(&self) -> Option<&ParamInfo> { + self.catalog.params.iter().find(|p| { + matches!(p.kind, ParamKind::Toggle) + && (p.addr.0.ends_with("bypass") || p.addr.0.ends_with("/on")) + }) + } +} + +impl EmbedView for FeaturedView { + fn catalog(&self) -> Catalog { + self.catalog.clone() + } + + fn build(&self, ctx: &BuildCtx) -> Built { + let (w, h) = ctx.size; + let mut k = Kit::new(w, h); + let root = k.root; + s::fill(&mut k.b(), root, "ground", (0.0, 0.0, w, h), s::WELL); + let knobs = self.knobs(); + let meter = self.meter(); + let wide = w > h * 2.5; + let tiny = w < 60.0 || h < 40.0; + let budget = if tiny { + 0 + } else if h >= 140.0 || wide { + 4 + } else { + 2 + }; + let mut area = (2.0, 2.0, w - 4.0, h - 4.0); + + // The bypass lamp, top right. + if let Some(b) = self.bypass() + && !tiny + { + let lamp = s::dot(&mut k.b(), root, "lamp", (w - 7.0, 7.0), 3.0, "#22c55e"); + let hit = s::frame(&mut k.b(), root, "bypass", (w - 14.0, 0.0, 14.0, 14.0)); + k.controls.insert( + hit, + Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .bound(Binding::param(b.addr.0.clone())) + .slot(SlotRole::Led, lamp.0), + ); + area.1 += 12.0; + area.3 -= 12.0; + } + // The meter: a bar down the left (a row along the top when wide). + if let Some((feed, gr)) = &meter { + let bar_at = if tiny { + area + } else { + (area.0, area.1, 6.0, area.3) + }; + bar(&mut k, root, feed, bar_at, *gr); + if !tiny { + area.0 += 10.0; + area.2 -= 10.0; + } + } + if tiny + && meter.is_none() + && let Some(p) = knobs.first() + { + readout(&mut k, root, p, area, 9.0); + } + let n = knobs.len().min(budget); + if n > 0 { + let (cols, rows) = if wide { + (n, 1) + } else if area.2 >= 90.0 { + (n.min(2), n.div_ceil(2)) + } else { + (1, n) + }; + let cw = area.2 / cols as f64; + let rh = area.3 / rows as f64; + for (i, p) in knobs.iter().take(n).enumerate() { + let (c, r) = (i % cols, i / cols); + mini_knob( + &mut k, + root, + p, + (area.0 + c as f64 * cw, area.1 + r as f64 * rh, cw, rh), + &self.accent, + ); + } + } + k.finish() + } +} + +fn bar(k: &mut Kit, p: NodeId, feed: &str, (x, y, w, h): Rect4, gr: bool) { + s::fill(&mut k.b(), p, "meter well", (x, y, w, h), "#ffffff0d"); + let mut holder = crate::surface::kit::draw::frame("meter", x, y, w, h); + if gr { + holder.transform = crate::document::proto::Transform { + a: -1.0, + b: 0.0, + tx: x + w, + c: 0.0, + d: -1.0, + ty: y + h, + }; + } + let holder = k.b().add(p, holder); + let fill = s::fill( + &mut k.b(), + holder, + "fill", + (0.0, 0.0, w, h), + if gr { s::REDUCTION_EDGE } else { "#22c55e" }, + ); + let (floor, ceiling) = if gr { (0.0, -24.0) } else { (-60.0, 0.0) }; + let c = Control::new(ControlKind::Gauge { + sweep_degrees: 0.0, + floor_db: floor, + ceiling_db: ceiling, + }) + .bound(Binding::stream(feed)) + .slot(SlotRole::Fill, fill.0); + k.controls.insert(holder, c); +} + +fn readout(k: &mut Kit, p: NodeId, param: &ParamInfo, (x, y, w, h): Rect4, size: f64) { + let t = s::text( + &mut k.b(), + p, + "value", + "", + (x, y + h / 2.0 + size * 0.35, w), + size, + s::TEXT, + TextAlign::Center, + ); + let hit = s::frame(&mut k.b(), p, "readout", (x, y, w, h)); + let c = Control::new(ControlKind::Readout) + .bound(Binding::param(param.addr.0.clone())) + .slot(SlotRole::Value, t.0) + .formatted(ValueFormat::default()); + k.controls.insert(hit, c); +} + +/// A small arc knob: the arc, its value inside, its name under. +fn mini_knob(k: &mut Kit, p: NodeId, param: &ParamInfo, (x, y, w, h): Rect4, accent: &str) { + let label_h = if h >= 34.0 { 9.0 } else { 0.0 }; + let d = (w - 4.0).min(h - label_h - 2.0).clamp(12.0, 44.0); + let root = s::frame(&mut k.b(), p, ¶m.name, (x, y, w, h)); + let (cx, top) = ((w - d) / 2.0, ((h - label_h - d) / 2.0).max(0.0)); + let dial = s::frame(&mut k.b(), root, "dial", (cx, top, d, d)); + let ring = (d * 0.14).max(2.0); + let track = sector_path( + d * 0.5, + d * 0.5, + d * 0.5 - ring, + d * 0.5, + -135f64.to_radians(), + 135f64.to_radians(), + ); + s::shape(&mut k.b(), dial, "track", (d, d), track, "#ffffff1f"); + let arc = s::shape(&mut k.b(), dial, "value arc", (d, d), String::new(), accent); + let size = (d * 0.26).clamp(6.0, 10.0); + let value = s::text( + &mut k.b(), + dial, + "value", + "", + (0.0, d / 2.0 + size * 0.35, d), + size, + s::TEXT, + TextAlign::Center, + ); + let mut c = Control::new(ControlKind::knob()) + .bound(Binding::param(param.addr.0.clone())) + .slot(SlotRole::ValueArc, arc.0) + .slot(SlotRole::Value, value.0); + c.format = Some(ValueFormat { + decimals: None, + unit: Some(String::new()), + ..ValueFormat::default() + }); + if label_h > 0.0 { + let name = param + .short_name + .clone() + .unwrap_or_else(|| param.name.clone()); + s::text( + &mut k.b(), + root, + "label", + &name, + (0.0, h - 1.0, w), + 7.0, + &alpha(s::TEXT_DIM, 1.0), + TextAlign::Center, + ); + } + k.controls.insert(root, c); +} diff --git a/crates/frame/src/embed/mod.rs b/crates/frame/src/embed/mod.rs new file mode 100644 index 00000000..bc3f3077 --- /dev/null +++ b/crates/frame/src/embed/mod.rs @@ -0,0 +1,63 @@ +//! Frame surfaces embedded in a host's strip. +//! +//! A mixer channel's Tone rack, an FX slot expanded in place, REAPER's TCP +//! and MCP inline UI: each is a frame surface drawn inside a box its host +//! owns. This crate is the host-facing half: +//! +//! - [`form`]: the `Embed { host: Strip | Tcp | Mcp }` form factor, and the +//! width tiers (session's `Rack::at`) every embed sheds detail by. +//! - [`Embed`]: one surface in a strip — resize (reflow, not letterbox), +//! two paint passes (params, recorded once; feeds and motion, live), +//! pointer routing in strip-local coordinates. +//! - [`Canvas`]: what it paints into — the host's own scene. An anyrender +//! 0.11 `PaintScene` (feature `anyrender-0-11`, session's mixer), frame's +//! replayable [`Recording`], or the CPU rasteriser ([`cpu`], REAPER's +//! LICE bitmap, tests). +//! - [`tone`]: session's Tone rack (EQ, gate, compressor, saturator, +//! de-esser, delay, reverb) as frame documents, at session's widths. +//! - [`generic`]: an embed for any catalog, from its featured params. +//! - [`curves`]: the rack's curve providers, which a host overrides with +//! the plugins' own maths. +//! - [`fx_slot`]: the provider shape for daw-ui's embedded-FX slot seam. +//! - `nice_plug` (feature `nice-plug`): any embed as a nice-plug +//! `EmbeddedEditor` — REAPER's `REAPER_FXEMBED` protocol. +//! +//! # Hosting one in a strip +//! +//! ```ignore +//! let mut rack = Embed::new(ToneRack::channel(), EmbedHost::Strip); +//! rack.set_size(strip_w, rack_h); +//! // Once, and again whenever `rack.needs_record()`: +//! rack.record(&mut AnyRender(&mut strip_scene), Affine::IDENTITY); +//! // Every frame, over the replayed recording, while `rack.animating()`: +//! rack.live(&mut AnyRender(&mut live_scene), strip_transform, now_ms); +//! // Pointer, in strip-local px: +//! let out = rack.pointer(Pointer::Down { x, y, button: Button::Left, mods }, now_ms); +//! engine.send(out.calls); +//! ``` + +#[cfg(feature = "cpu")] +pub mod cpu; +pub mod curves; +pub mod embed; +pub mod form; +pub mod fx_slot; +pub mod generic; +#[cfg(feature = "nice-plug")] +pub mod nice_plug; +pub mod tone; + +// The painting seam, shared with the theme runtime. +pub use crate::render::canvas::{canvas, paint, text}; + +#[cfg(feature = "anyrender-0-11")] +pub use canvas::AnyRender; +pub use canvas::{Canvas, CanvasExt, Recording, Tally}; +pub use embed::{ + BuildCtx, Built, Button, Derived, DocumentView, Embed, EmbedView, Handled, Pointer, Values, +}; +pub use form::{EmbedForm, EmbedHost, SizeHints, Tier}; +pub use generic::FeaturedView; +pub use tone::{TonePanel, ToneRack, Which}; + +pub use crate::surface::runtime::{EngineCall, Modifiers}; diff --git a/crates/frame/src/embed/nice_plug.rs b/crates/frame/src/embed/nice_plug.rs new file mode 100644 index 00000000..8b616684 --- /dev/null +++ b/crates/frame/src/embed/nice_plug.rs @@ -0,0 +1,268 @@ +//! Any frame embed as a nice-plug `EmbeddedEditor`: REAPER's TCP/MCP +//! inline UI (`REAPER_FXEMBED`, via nice-plug's CLAP `cockos.reaper_embedui` +//! shim). +//! +//! REAPER owns the slot. It asks for size hints (`GETMINMAXINFO`), then +//! paints on its UI timer — about 30 Hz, with `PAINT_OPTIONAL` set when +//! nothing forced it — into a LICE bitmap the size of the slot in device +//! pixels (BGRA words, `0xAARRGGBB`, a row span, possibly bottom-up), with +//! the DPI as 24.8 fixed point. Mouse messages arrive with slot-local +//! device-pixel coordinates; capture is automatic between down and up; +//! returning `INVALIDATE` forces a non-optional repaint. +//! +//! This editor rasterises on the CPU, on REAPER's thread, straight into +//! that bitmap: an MCP-sized frame costs well under 2 ms (release; see +//! `tests/harness.rs`), less than a GPU round trip before its readback. It +//! skips optional paints when nothing changed, and relayouts only when the +//! slot's size does. +//! +//! Parameter edits leave through `on_calls`: the plugin turns them into +//! its own param changes (a `ParamSetter`, its shared params) — nice-plug +//! gives an embedded editor no `GuiContext` of its own. Values and feeds +//! come in through [`EmbedHandle`], cloned into the plugin. + +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Instant; + +use crate::surface::proto::{ParamValue, StreamFrame}; +use crate::surface::runtime::{EngineCall, Modifiers}; +use nice_plug_core::editor::embedded::{ + EmbedBitmap, EmbedContext, EmbedDrawInfo, EmbedMouseEvent, EmbedSizeHints, EmbeddedEditor, + embed_flags, +}; + +use crate::embed::cpu::{Pixels, Rasterizer}; +use crate::embed::embed::{Button, Embed, EmbedView, Pointer}; +use crate::embed::form::EmbedHost; + +/// Where engine calls go. +pub type CallSink = Arc) + Send + Sync>; + +struct State { + embed: Embed, + raster: Rasterizer, + /// The last frame painted, and the device size it was painted at. + last: Option, + /// Something moved that the last frame does not show. + stale: bool, + context: EmbedContext, + scale: f64, +} + +/// Values and feeds for the editor, from the plugin (any thread). +#[derive(Clone)] +pub struct EmbedHandle(Arc>); + +impl EmbedHandle { + pub fn apply_values(&self, values: impl IntoIterator) { + let mut s = self.0.lock().unwrap_or_else(PoisonError::into_inner); + s.embed.apply_values(values); + s.stale = true; + } + + pub fn apply_frame(&self, frame: StreamFrame, t_ms: f64) { + let mut s = self.0.lock().unwrap_or_else(PoisonError::into_inner); + s.embed.apply_frame(frame, t_ms); + s.stale = true; + } + + /// Anything else the embed offers (curve overrides, say). + pub fn with(&self, f: impl FnOnce(&mut Embed) -> R) -> R { + let mut s = self.0.lock().unwrap_or_else(PoisonError::into_inner); + s.stale = true; + f(&mut s.embed) + } +} + +/// See the module docs. +pub struct FrameEmbeddedEditor { + state: Arc>, + on_calls: CallSink, + start: Instant, +} + +impl FrameEmbeddedEditor { + /// `view` in REAPER's slots; edits go to `on_calls`. + pub fn new( + view: impl EmbedView + 'static, + on_calls: impl Fn(Vec) + Send + Sync + 'static, + ) -> Self { + let state = State { + embed: Embed::new(view, EmbedHost::Mcp), + raster: Rasterizer::new(), + last: None, + stale: true, + context: EmbedContext::Mcp, + scale: 1.0, + }; + Self { + state: Arc::new(Mutex::new(state)), + on_calls: Arc::new(on_calls), + start: Instant::now(), + } + } + + /// The handle the plugin feeds values and frames through. + #[must_use] + pub fn handle(&self) -> EmbedHandle { + EmbedHandle(self.state.clone()) + } + + fn now(&self) -> f64 { + self.start.elapsed().as_secs_f64() * 1000.0 + } + + fn lock(&self) -> std::sync::MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// The embed host for REAPER's context. +#[must_use] +pub fn host_of(context: EmbedContext) -> EmbedHost { + match context { + EmbedContext::Tcp => EmbedHost::Tcp, + EmbedContext::Mcp | EmbedContext::Unknown => EmbedHost::Mcp, + } +} + +/// Logical px per device px for a draw. +fn scale_of(info: &EmbedDrawInfo) -> f64 { + let dpi = f64::from(info.dpi); + if dpi.is_finite() && dpi > 0.1 { + dpi + } else { + 1.0 + } +} + +impl EmbeddedEditor for FrameEmbeddedEditor { + fn size_hints(&self, context: EmbedContext, dpi: f32) -> Option { + // The view's own hints for the slot (a single unit knows its aspect). + let h = self.lock().embed.hints_for(host_of(context)); + let k = f64::from(if dpi > 0.1 { dpi } else { 1.0 }); + let px = |v: f64| (v * k).round() as i32; + Some(EmbedSizeHints { + preferred_aspect: h.preferred_aspect as f32, + minimum_aspect: h.minimum_aspect as f32, + min_width: px(h.min.0), + min_height: px(h.min.1), + max_width: px(h.max.0), + max_height: px(h.max.1), + }) + } + + fn paint(&self, bitmap: &mut EmbedBitmap<'_>, info: &EmbedDrawInfo) -> bool { + let t = self.now(); + let mut s = self.lock(); + let (dw, dh) = (bitmap.width, bitmap.height); + if dw == 0 || dh == 0 { + return false; + } + let scale = scale_of(info); + if s.context != info.context { + s.context = info.context; + s.stale = true; + } + let resized = s + .embed + .set_size(f64::from(dw) / scale, f64::from(dh) / scale) + || (s.scale - scale).abs() > 1e-6; + s.scale = scale; + let fresh = s + .last + .as_ref() + .is_some_and(|p| (p.width, p.height) == (dw, dh)); + let moving = s.embed.animating() || s.embed.needs_record() || s.stale; + if info.is_paint_optional() && fresh && !resized && !moving { + return false; + } + let calls = s.embed.poll(t); + let st = &mut *s; + let px = st + .raster + .render(dw, dh, scale, crate::embed::cpu::STRIP_GROUND, |c, at| { + st.embed.paint(c, at, t) + }); + px.write_bgra(bitmap.bits, bitmap.row_span as usize, bitmap.flipped); + st.last = Some(px); + st.stale = false; + drop(s); + if !calls.is_empty() { + (self.on_calls)(calls); + } + true + } + + fn mouse_event(&self, event: EmbedMouseEvent, info: &EmbedDrawInfo) -> u32 { + let t = self.now(); + let k = 1.0 / scale_of(info); + let (x, y) = (f64::from(info.mouse_x) * k, f64::from(info.mouse_y) * k); + let mods = Modifiers::default(); + let pointer = match event { + EmbedMouseEvent::Move => Pointer::Move { x, y }, + EmbedMouseEvent::LeftDown => Pointer::Down { + x, + y, + button: Button::Left, + mods, + }, + EmbedMouseEvent::LeftUp => Pointer::Up { + x, + y, + button: Button::Left, + }, + EmbedMouseEvent::LeftDoubleClick => Pointer::DoubleClick { + x, + y, + button: Button::Left, + }, + EmbedMouseEvent::RightDown => Pointer::Down { + x, + y, + button: Button::Right, + mods, + }, + EmbedMouseEvent::RightUp => Pointer::Up { + x, + y, + button: Button::Right, + }, + EmbedMouseEvent::RightDoubleClick => Pointer::DoubleClick { + x, + y, + button: Button::Right, + }, + // 120 per step is one notch. + EmbedMouseEvent::Wheel { amount } => Pointer::Wheel { + x, + y, + notches: f64::from(amount) / 120.0, + mods, + }, + }; + let out = { + let mut s = self.lock(); + let out = s.embed.pointer(pointer, t); + if out.redraw { + s.stale = true; + } + out + }; + if !out.calls.is_empty() { + (self.on_calls)(out.calls); + } + // A right click is REAPER's (its FX menu): not handled. + if out.passthrough { + return 0; + } + let mut flags = 0; + if out.handled { + flags |= embed_flags::HANDLED; + } + if out.redraw { + flags |= embed_flags::INVALIDATE; + } + flags + } +} diff --git a/crates/frame/src/embed/tone/catalog.rs b/crates/frame/src/embed/tone/catalog.rs new file mode 100644 index 00000000..cd862469 --- /dev/null +++ b/crates/frame/src/embed/tone/catalog.rs @@ -0,0 +1,358 @@ +//! What each unit binds: `/` params and `/` feeds. +//! +//! An engine (or session's `Tone` store, through an adapter) publishes +//! these; the panels bind nothing else. Values are engineering units, as +//! session keeps them: dB, Hz, ms, seconds, ratio, 0..1 shares. +//! +//! | unit | params | feeds | +//! |---|---|---| +//! | EQs (`eq`, `rescue_eq`, `polish_eq`, `space`) | `b{n}_{freq,gain,q,shape,on}`, `range` (zoom stop), `bypass` | `spectrum` | +//! | compressors (`comp`, `rescue_comp`) | `threshold`, `ratio`, `knee`, `makeup`, `attack`, `release`, `bypass` | `in_history`, `gr_history`, `gr` | +//! | `gate` | `threshold`, `range`, `attack`, `hold`, `release`, `bypass` | `in_history`, `open_history`, `in` | +//! | `sat` | `drive`, `bias`, `tilt`, `circuit`, `bits`, `bypass` | `peak` (0..1 linear) | +//! | `deess` | `low`, `high`, `threshold`, `depth`, `bypass` | `spectrum`, `level_history`, `gr_history`, `gr` | +//! | `delay` | `time`, `feedback`, `mix`, `family`, `bypass` | `wet` | +//! | `reverb` | `decay`, `predelay`, `mix`, `family`, `bypass` | `wet` | +//! | the rack | `rack/fold_` | — | + +use crate::surface::kit::viz::catalog as viz; +use crate::surface::proto::{Catalog, FeedInfo, FeedKind, ParamInfo, Range, ScalarKind, Unit}; + +use super::{Phase, Which}; +use crate::embed::curves::{CIRCUITS, DELAY_FAMILIES, ROOM_FAMILIES}; + +/// The EQ graph's zoom stops, ± dB (the plugin's `DB_RANGE_STEPS`). +pub const EQ_RANGES: [f64; 5] = [3.0, 6.0, 12.0, 18.0, 30.0]; +/// Where a rack opens (session `DEFAULT_EQ_RANGE`). +pub const DEFAULT_EQ_RANGE: usize = 2; +/// How many readings a history keeps (session: 72 at 30 Hz). +pub const HISTORY: u32 = 72; + +fn p(ns: &str, k: &str, name: &str, range: Range, v: f64) -> ParamInfo { + ParamInfo::new(format!("{ns}/{k}"), name, range) + .with_default_real(v) + .short(name) +} + +fn choice(ns: &str, k: &str, name: &str, options: &[&str], at: usize) -> ParamInfo { + let mut c = ParamInfo::new(format!("{ns}/{k}"), name, Range::default()) + .choice(options) + .short(name); + c.default = at as f64 / (options.len().max(2) - 1) as f64; + c +} + +fn toggle(ns: &str, k: &str, name: &str, on: bool) -> ParamInfo { + let mut t = ParamInfo::new(format!("{ns}/{k}"), name, Range::default()) + .toggle() + .short(name); + t.default = if on { 1.0 } else { 0.0 }; + t +} + +/// A unit's params. +#[must_use] +pub fn params(which: Which, bands: usize) -> Vec { + let ns = which.ns(); + let mut out = match which { + Which::RescueEq | Which::Eq | Which::PolishEq | Which::Space => { + let mut v = viz::eq_params(ns, bands); + let names: Vec = EQ_RANGES.iter().map(|r| format!("±{r:.0}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + v.push(choice(ns, "range", "Range", &names, DEFAULT_EQ_RANGE)); + v + } + Which::RescueComp | Which::Comp => vec![ + p( + ns, + "threshold", + "Threshold", + Range::linear(-60.0, 0.0, Unit::Db), + -18.0, + ) + .featured(), + p( + ns, + "ratio", + "Ratio", + Range::log(1.0, 20.0, Unit::Ratio), + 4.0, + ) + .featured(), + p(ns, "knee", "Knee", Range::linear(0.0, 24.0, Unit::Db), 6.0), + p( + ns, + "makeup", + "Makeup", + Range::linear(0.0, 24.0, Unit::Db), + 0.0, + ), + p( + ns, + "attack", + "Attack", + Range::log(0.1, 200.0, Unit::Milliseconds), + 10.0, + ), + p( + ns, + "release", + "Release", + Range::log(5.0, 3000.0, Unit::Milliseconds), + 150.0, + ), + ], + Which::Gate => vec![ + p( + ns, + "threshold", + "Threshold", + Range::linear(-60.0, 0.0, Unit::Db), + -40.0, + ) + .featured(), + p( + ns, + "range", + "Range", + Range::linear(-60.0, 0.0, Unit::Db), + -18.0, + ) + .featured(), + p( + ns, + "attack", + "Attack", + Range::log(0.05, 50.0, Unit::Milliseconds), + 1.0, + ), + p( + ns, + "hold", + "Hold", + Range::log(1.0, 500.0, Unit::Milliseconds), + 30.0, + ), + p( + ns, + "release", + "Release", + Range::log(5.0, 2000.0, Unit::Milliseconds), + 120.0, + ), + ], + Which::Sat => vec![ + p(ns, "drive", "Drive", Range::log(0.5, 12.0, Unit::None), 2.0).featured(), + p( + ns, + "bias", + "Bias", + Range::linear(-0.6, 0.6, Unit::None), + 0.15, + ) + .bipolar(), + p( + ns, + "tilt", + "Tilt", + Range::linear(-12.0, 12.0, Unit::Db), + 0.0, + ) + .bipolar(), + choice(ns, "circuit", "Circuit", &CIRCUITS, 0), + p( + ns, + "bits", + "Bits", + Range::linear(0.0, 16.0, Unit::None), + 0.0, + ), + p( + ns, + "mix", + "Mix", + Range::linear(0.0, 1.0, Unit::Percent), + 1.0, + ), + ], + Which::DeEss => vec![ + p( + ns, + "low", + "Low", + Range::log(800.0, 20_000.0, Unit::Hz), + 5000.0, + ), + p( + ns, + "high", + "High", + Range::log(800.0, 20_000.0, Unit::Hz), + 10_000.0, + ), + p( + ns, + "threshold", + "Threshold", + Range::linear(-40.0, 0.0, Unit::Db), + -24.0, + ) + .featured(), + p( + ns, + "depth", + "Depth", + Range::linear(0.0, 24.0, Unit::Db), + 6.0, + ) + .featured(), + ], + Which::Delay => vec![ + p( + ns, + "time", + "Time", + Range::log(10.0, 2000.0, Unit::Milliseconds), + 300.0, + ) + .featured(), + p( + ns, + "feedback", + "Feedback", + Range::linear(0.0, 0.99, Unit::Percent), + 0.38, + ) + .featured(), + p( + ns, + "mix", + "Mix", + Range::linear(0.0, 1.0, Unit::Percent), + 0.3, + ), + choice(ns, "family", "Family", &DELAY_FAMILIES, 0), + ], + Which::Reverb => vec![ + p( + ns, + "decay", + "Decay", + Range::log(0.1, 20.0, Unit::Seconds), + 1.8, + ) + .featured(), + p( + ns, + "predelay", + "Predelay", + Range::linear(0.0, 250.0, Unit::Milliseconds), + 20.0, + ), + p( + ns, + "mix", + "Mix", + Range::linear(0.0, 1.0, Unit::Percent), + 0.18, + ) + .featured(), + choice(ns, "family", "Family", &ROOM_FAMILIES, 1), + ], + }; + out.push(toggle(ns, "bypass", "Bypass", false)); + for q in &mut out { + q.group = Some(which.name().to_string()); + } + out +} + +/// A unit's feeds. +#[must_use] +pub fn feeds(which: Which) -> Vec { + let ns = which.ns(); + let f = |k: &str, name: &str, kind: FeedKind| FeedInfo::new(format!("{ns}/{k}"), name, kind); + let history = |k: &str, name: &str, of: ScalarKind| { + f(k, name, FeedKind::History { of, len: HISTORY }).rate(30.0) + }; + match which { + Which::RescueEq | Which::Eq | Which::PolishEq | Which::Space => { + vec![viz::spectrum_stream(ns, 96)] + } + Which::RescueComp | Which::Comp => vec![ + history("in_history", "Input History", ScalarKind::Level), + f("gr_history", "GR History", FeedKind::gr_history(HISTORY)).rate(30.0), + f("gr", "Gain Reduction", FeedKind::GainReduction), + ], + Which::Gate => vec![ + history("in_history", "Input History", ScalarKind::Level), + history( + "open_history", + "Open History", + ScalarKind::Scalar { + range: Range::linear(0.0, 1.0, Unit::None), + }, + ), + f("in", "Input", FeedKind::Level { channels: 1 }), + ], + Which::Sat => vec![f( + "peak", + "Peak", + FeedKind::Scalar { + range: Range::linear(0.0, 1.0, Unit::None), + }, + )], + Which::DeEss => vec![ + FeedInfo::new(format!("{ns}/spectrum"), "Spectrum", FeedKind::spectrum(96)), + history("level_history", "Band Level", ScalarKind::Level), + f("gr_history", "GR History", FeedKind::gr_history(HISTORY)).rate(30.0), + f("gr", "Gain Reduction", FeedKind::GainReduction), + ], + Which::Delay | Which::Reverb => vec![f( + "wet", + "Wet", + FeedKind::Scalar { + range: Range::linear(0.0, 1.0, Unit::None), + }, + )], + } +} + +/// The params a unit's *layout* depends on (a rebuild when they move). +#[must_use] +pub fn layout_params(which: Which) -> Vec { + let ns = which.ns(); + match which { + w if w.is_eq() => vec![format!("{ns}/range")], + Which::Sat => vec![format!("{ns}/circuit")], + Which::Delay | Which::Reverb => vec![format!("{ns}/family")], + _ => Vec::new(), + } +} + +/// The catalog of a rack of `units`. +#[must_use] +pub fn rack(units: &[Which], bands: usize) -> Catalog { + let mut c = Catalog { + id: "frame.tone".into(), + version: 1, + ..Catalog::default() + }; + for u in units { + c.params.extend(params(*u, bands)); + c.streams.extend(feeds(*u)); + } + for phase in Phase::ALL { + if units.iter().any(|u| u.phase() == phase) { + // Where a mixer opens (session `Folded::rest`): Rescue is dialled + // in once and then done, so it rests shut. + c.params.push(toggle( + "rack", + &format!("fold_{}", phase.name().to_lowercase()), + &format!("Fold {}", phase.name()), + phase == Phase::Rescue, + )); + } + } + c +} diff --git a/crates/frame/src/embed/tone/demo.rs b/crates/frame/src/embed/tone/demo.rs new file mode 100644 index 00000000..d43b334f --- /dev/null +++ b/crates/frame/src/embed/tone/demo.rs @@ -0,0 +1,276 @@ +//! Demo settings and feeds for a rack: what the harness renders, and what +//! a host without an engine can show. Varied per track `index`, the way +//! session's `tone::placeholder` varies its seeded tracks. + +use crate::surface::proto::{Catalog, EntryId, ParamAddr, ParamKind, ParamValue, StreamFrame}; + +use super::Which; +use super::catalog::HISTORY; + +fn norm(catalog: &Catalog, addr: &str, real: f64) -> Option { + let p = catalog.param(&ParamAddr::new(addr))?; + let value = match &p.kind { + ParamKind::Choice { options } => { + (real.round() / (options.len().max(2) - 1) as f64).clamp(0.0, 1.0) + } + ParamKind::Toggle => f64::from(u8::from(real >= 0.5)), + ParamKind::Continuous => p.range.to_normalized(real), + // Text params (names) carry no number to set. + ParamKind::Text => return None, + }; + Some(ParamValue { + addr: p.addr.clone(), + value, + }) +} + +/// A cheap deterministic hash → 0..1. +fn rnd(seed: usize, k: usize) -> f64 { + let mut x = (seed as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ (k as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x ^= x >> 31; + x = x.wrapping_mul(0x94D0_49BB_1331_11EB); + x ^= x >> 29; + (x % 10_000) as f64 / 10_000.0 +} + +/// Engineering-unit settings for track `index`'s `units`. +#[must_use] +pub fn settings(units: &[Which], index: usize) -> Vec<(String, f64)> { + let mut out = Vec::new(); + let r = |k: usize| rnd(index, k); + for (u, which) in units.iter().enumerate() { + let ns = which.ns(); + let mut set = |k: &str, v: f64| out.push((format!("{ns}/{k}"), v)); + let s = u * 32; + match which { + w if w.is_eq() => { + // A low cut or shelf, a cut in the mids, a presence lift, air. + let bands = [ + ( + 40.0 + 80.0 * r(s), + 0.0, + 0.7, + if r(s + 1) > 0.5 { 3.0 } else { 1.0 }, + ), + ( + 180.0 + 500.0 * r(s + 2), + -2.0 - 6.0 * r(s + 3), + 1.0 + 3.0 * r(s + 4), + 0.0, + ), + ( + 1500.0 + 3000.0 * r(s + 5), + 1.0 + 5.0 * r(s + 6), + 0.8 + 1.5 * r(s + 7), + 0.0, + ), + (8000.0 + 6000.0 * r(s + 8), 1.0 + 4.0 * r(s + 9), 0.71, 2.0), + ]; + for (n, (f, g, q, shape)) in bands.iter().enumerate() { + let n = n + 1; + set(&format!("b{n}_freq"), *f); + set( + &format!("b{n}_gain"), + if *which == Which::PolishEq && n != 2 { + 0.0 + } else { + *g + }, + ); + set(&format!("b{n}_q"), *q); + set(&format!("b{n}_shape"), *shape); + set(&format!("b{n}_on"), 1.0); + } + // The first band is a low shelf with some gain when it is one. + if r(s + 1) <= 0.5 { + set("b1_gain", 2.0 + 3.0 * r(s + 10)); + } + set("range", if r(s + 11) > 0.6 { 1.0 } else { 2.0 }); + } + w if w.is_comp() => { + set("threshold", -12.0 - 14.0 * r(s)); + set("ratio", 2.0 + 4.0 * r(s + 1)); + set("attack", 1.0 + 30.0 * r(s + 2)); + set("release", 60.0 + 400.0 * r(s + 3)); + } + Which::Gate => { + set("threshold", -44.0 + 14.0 * r(s)); + set("range", -12.0 - 18.0 * r(s + 1)); + } + Which::Sat => { + set("drive", 1.2 + 4.0 * r(s)); + set("bias", 0.05 + 0.3 * r(s + 1)); + set("circuit", (r(s + 2) * 4.99).floor()); + } + Which::DeEss => { + set("low", 4000.0 + 2000.0 * r(s)); + set("high", 9000.0 + 3000.0 * r(s + 1)); + set("threshold", -30.0 + 10.0 * r(s + 2)); + } + Which::Delay => { + set("time", 120.0 + 400.0 * r(s)); + set("feedback", 0.25 + 0.5 * r(s + 1)); + set("mix", 0.2 + 0.4 * r(s + 2)); + set("family", (r(s + 3) * 5.99).floor()); + } + _ => { + set("decay", 0.8 + 3.0 * r(s)); + set("predelay", 10.0 + 60.0 * r(s + 1)); + set("mix", 0.1 + 0.4 * r(s + 2)); + set("family", (r(s + 3) * 4.99).floor()); + } + } + } + out +} + +/// [`settings`] as normalized param values for `catalog`. +#[must_use] +pub fn values(catalog: &Catalog, units: &[Which], index: usize) -> Vec { + settings(units, index) + .into_iter() + .filter_map(|(a, v)| norm(catalog, &a, v)) + .collect() +} + +/// A drum-like input: a hit every `period` readings, decaying. +fn input_db(i: usize, period: usize, seed: usize) -> f64 { + let phase = i % period; + let accent = if (i / period).is_multiple_of(2) { + 0.0 + } else { + -3.0 * rnd(seed, i / period) + }; + -2.0 + accent - phase as f64 * (40.0 / period as f64) +} + +/// The reduction a history of input levels gets: the transfer's target, +/// approached at the attack and release rates (session `reduction_db`). +#[must_use] +pub fn reduction( + input: &[f64], + threshold: f64, + ratio: f64, + knee: f64, + attack_ms: f64, + release_ms: f64, + hz: f64, +) -> Vec { + let step = 1000.0 / hz; + let coef = |ms: f64| (-step / ms.max(0.01)).exp(); + let (a, r) = (coef(attack_ms), coef(release_ms)); + let mut held = 0.0; + input + .iter() + .map(|&db| { + let slope = 1.0 - 1.0 / ratio.max(1.0); + let over = db - threshold; + let target = if knee > 0.001 && over.abs() < knee / 2.0 { + let x = over + knee / 2.0; + slope * x * x / (2.0 * knee) + } else if over > 0.0 { + slope * over + } else { + 0.0 + }; + let rate = if target > held { a } else { r }; + held = target + (held - target) * rate; + -held + }) + .collect() +} + +/// Feed frames for track `index` at reading `tick` (30 per second). +#[must_use] +pub fn frames(catalog: &Catalog, units: &[Which], index: usize, tick: usize) -> Vec { + let settings: std::collections::HashMap = + settings(units, index).into_iter().collect(); + let g = |ns: &str, k: &str, d: f64| settings.get(&format!("{ns}/{k}")).copied().unwrap_or(d); + let period = 6 + index % 9; + let len = HISTORY as usize; + let input: Vec = (0..len) + .map(|i| input_db(i + tick, period, index)) + .collect(); + let mut out = Vec::new(); + let mut push = |id: String, values: Vec| { + if catalog.stream(&EntryId::new(id.clone())).is_some() { + out.push(StreamFrame { + id: EntryId::new(id), + values, + }); + } + }; + for which in units { + let ns = which.ns(); + match which { + w if w.is_eq() => { + // Pink-ish, with a body bump and some ring. + let spec: Vec = (0..96) + .map(|b| { + let t = b as f64 / 95.0; + let hz = 20.0 * 1000f64.powf(t); + -18.0 - 10.0 * t + + 8.0 * (-((hz / 180.0).ln()).powi(2)).exp() + + 6.0 * (b as f64 * 0.7 + index as f64).sin() * 0.5 + - 12.0 * (t - 0.85).max(0.0) * 6.0 + }) + .collect(); + push(format!("{ns}/spectrum"), spec); + } + w if w.is_comp() => { + let gr = reduction( + &input, + g(ns, "threshold", -18.0), + g(ns, "ratio", 4.0), + 6.0, + g(ns, "attack", 10.0), + g(ns, "release", 150.0), + 30.0, + ); + push(format!("{ns}/in_history"), input.clone()); + push(format!("{ns}/gr"), vec![*gr.last().unwrap_or(&0.0)]); + push(format!("{ns}/gr_history"), gr); + } + Which::Gate => { + let thr = g(ns, "threshold", -40.0); + push( + format!("{ns}/in_history"), + input.iter().map(|v| v - 6.0).collect(), + ); + push( + format!("{ns}/open_history"), + input + .iter() + .map(|v| f64::from(u8::from(v - 6.0 > thr))) + .collect(), + ); + push(format!("{ns}/in"), vec![input[len - 1] - 6.0]); + } + Which::Sat => push( + format!("{ns}/peak"), + vec![10f64.powf(input[len - 1] / 20.0).clamp(0.0, 1.0)], + ), + Which::DeEss => { + let lvl: Vec = (0..len) + .map(|i| -30.0 + 18.0 * ((i + tick) as f64 * 0.45).sin().max(0.0)) + .collect(); + let thr = g(ns, "threshold", -24.0); + push( + format!("{ns}/gr_history"), + lvl.iter().map(|l| -(l - thr).clamp(0.0, 12.0)).collect(), + ); + push(format!("{ns}/gr"), vec![-(lvl[len - 1] - thr).max(0.0)]); + push(format!("{ns}/level_history"), lvl); + push( + format!("{ns}/spectrum"), + (0..96) + .map(|b| -20.0 - 12.0 * (b as f64 / 95.0) + 5.0 * (b as f64 * 0.9).sin()) + .collect(), + ); + } + _ => push(format!("{ns}/wet"), vec![0.3 + 0.4 * rnd(index, tick / 10)]), + } + } + out +} diff --git a/crates/frame/src/embed/tone/mod.rs b/crates/frame/src/embed/tone/mod.rs new file mode 100644 index 00000000..c8e5d444 --- /dev/null +++ b/crates/frame/src/embed/tone/mod.rs @@ -0,0 +1,476 @@ +//! The Tone rack, as frame documents. +//! +//! session's mixer strip carries a rack of its processors — EQ, gate, +//! compressor, saturator, de-esser, delay, reverb — drawn by hand in +//! `apps/session-daw/src/tone.rs`. This is the same rack built from frame +//! nodes and controls: curves from the curve registry (the plugins' own +//! maths once the host registers it), plots bound to feeds for everything +//! that moves, handles for everything you grab. The geometry, the level-of- +//! detail tiers, the inks and the phase containers are session's, so the +//! two draw the same rack at the same widths. +//! +//! - [`ToneRack`]: the whole column — phase headers and units — for a strip. +//! - [`TonePanel`]: one unit alone, for a REAPER MCP/TCP slot. +//! - [`catalog`]: the params and feeds each unit binds (`/…`). + +pub mod catalog; +pub mod demo; +pub mod panels; +pub mod style; + +use crate::document::proto::{NodeId, Scene}; +use crate::surface::proto::{Catalog, ParamAddr}; +use crate::surface::runtime::Surface; +use crate::surface::widgets::{Builder, Theme}; + +use crate::embed::embed::{BuildCtx, Built, Derived, EmbedView, Values}; +use crate::embed::form::{EmbedHost, SizeHints, Tier}; +use style::{GAP, HEAD_H}; + +/// A mix phase: the container a run of units is read in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Phase { + Rescue, + Tone, + Polish, + Relational, + Depth, +} + +impl Phase { + /// The rack's phases, in signal order (session `RACK_PHASES`). + pub const ALL: [Self; 5] = [ + Self::Rescue, + Self::Tone, + Self::Polish, + Self::Relational, + Self::Depth, + ]; + + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Rescue => "Rescue", + Self::Tone => "Tone", + Self::Polish => "Polish", + Self::Relational => "Relational", + Self::Depth => "Depth", + } + } + + /// The phase's own colour (the toolbar icons' hues). + #[must_use] + pub const fn tint(self) -> &'static str { + match self { + Self::Rescue => "#ef4444", + Self::Tone => "#facc15", + Self::Polish => "#06b6d4", + Self::Relational => "#3b82f6", + Self::Depth => "#8b5cf6", + } + } + + /// The param that folds it shut (`rack/fold_`). + #[must_use] + pub fn fold_addr(self) -> String { + format!("rack/fold_{}", self.name().to_lowercase()) + } +} + +/// One unit of the rack. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Which { + RescueEq, + Gate, + RescueComp, + Eq, + Comp, + Sat, + DeEss, + PolishEq, + Space, + Delay, + Reverb, +} + +impl Which { + /// Every unit, in signal order (session `ALL_PANELS`). + pub const ALL: [Self; 11] = [ + Self::RescueEq, + Self::Gate, + Self::RescueComp, + Self::Eq, + Self::Comp, + Self::Sat, + Self::DeEss, + Self::PolishEq, + Self::Space, + Self::Delay, + Self::Reverb, + ]; + + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::RescueEq => "RESCUE EQ", + Self::Gate => "GATE", + Self::RescueComp => "RESCUE COMP", + Self::Eq => "EQ", + Self::Comp => "COMP", + Self::Sat => "SAT", + Self::DeEss => "DE-ESS", + Self::PolishEq => "POLISH EQ", + Self::Space => "SPACE", + Self::Delay => "DELAY", + Self::Reverb => "REVERB", + } + } + + /// The unit's param namespace. + #[must_use] + pub const fn ns(self) -> &'static str { + match self { + Self::RescueEq => "rescue_eq", + Self::Gate => "gate", + Self::RescueComp => "rescue_comp", + Self::Eq => "eq", + Self::Comp => "comp", + Self::Sat => "sat", + Self::DeEss => "deess", + Self::PolishEq => "polish_eq", + Self::Space => "space", + Self::Delay => "delay", + Self::Reverb => "reverb", + } + } + + #[must_use] + pub const fn phase(self) -> Phase { + match self { + Self::RescueEq | Self::Gate | Self::RescueComp => Phase::Rescue, + Self::Eq | Self::Comp | Self::Sat => Phase::Tone, + Self::DeEss | Self::PolishEq => Phase::Polish, + Self::Space => Phase::Relational, + Self::Delay | Self::Reverb => Phase::Depth, + } + } + + #[must_use] + pub const fn is_eq(self) -> bool { + matches!( + self, + Self::RescueEq | Self::Eq | Self::PolishEq | Self::Space + ) + } + + #[must_use] + pub const fn is_comp(self) -> bool { + matches!(self, Self::RescueComp | Self::Comp) + } + + /// How tall the unit is, px, at every tier (session `Which::natural`: + /// height is a fact about the unit, not about how wide it is). + #[must_use] + pub const fn natural(self) -> f64 { + match self { + Self::RescueEq | Self::Eq | Self::Space | Self::PolishEq => 175.0, + Self::DeEss => 130.0, + Self::Comp => 170.0, + Self::RescueComp | Self::Gate => 120.0, + Self::Sat => 130.0 + style::SELECTOR + 2.0, + Self::Delay | Self::Reverb => 100.0 + style::SELECTOR + 2.0, + } + } + + #[must_use] + pub fn parse(s: &str) -> Option { + Self::ALL + .into_iter() + .find(|w| w.ns() == s || w.name().eq_ignore_ascii_case(s)) + } +} + +/// A row of the column. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Row { + /// A phase's container bar. + Head(Phase), + /// A phase this chain has no units in, kept so the phases after it + /// line up with the strips beside it. + Blank(Phase), + Unit(Which), +} + +/// `(x, y, w, h)`. +pub type Rect4 = (f64, f64, f64, f64); + +/// Which phases are folded shut, from their params. +#[must_use] +pub fn folded(values: &Values) -> Vec { + Phase::ALL + .into_iter() + .filter(|p| values.get(&p.fold_addr()).is_some_and(|v| *v >= 0.5)) + .collect() +} + +/// The column: every phase header and every unit under it, top to bottom +/// from `y` (session `tone::chain`, the same rows at the same heights). +#[must_use] +pub fn chain(units: &[Which], (x, y, w): (f64, f64, f64), folded: &[Phase]) -> Vec<(Row, Rect4)> { + let mut out = Vec::new(); + if units.is_empty() { + return out; + } + let mut y = y; + // A chain that starts where a channel's does keeps a bar for every + // phase, so what follows lines up across the mixer. + let level = units + .iter() + .any(|u| matches!(u.phase(), Phase::Rescue | Phase::Tone)); + for phase in Phase::ALL { + let mine: Vec = units + .iter() + .copied() + .filter(|u| u.phase() == phase) + .collect(); + if mine.is_empty() { + if level { + out.push((Row::Blank(phase), (x, y, w, HEAD_H))); + y += HEAD_H; + } + continue; + } + out.push((Row::Head(phase), (x, y, w, HEAD_H))); + y += HEAD_H; + if folded.contains(&phase) { + continue; + } + for u in mine { + out.push((Row::Unit(u), (x, y, w, u.natural()))); + y += u.natural() + GAP; + } + } + out +} + +/// How tall the whole column comes to. +#[must_use] +pub fn tall(units: &[Which], folded: &[Phase]) -> f64 { + chain(units, (0.0, 0.0, 1.0), folded) + .last() + .map_or(0.0, |(_, r)| r.1 + r.3) +} + +/// The chains session gives each track role (`tone::*_CHAIN`). +pub mod chains { + use super::Which::{self, *}; + + /// An instrument or a voice: the whole chain. + pub const CHANNEL: [Which; 11] = Which::ALL; + /// A folder / bus. + pub const BUS: [Which; 2] = [Eq, Comp]; + /// A delay return. + pub const DELAY: [Which; 3] = [DeEss, Delay, Space]; + /// A reverb return. + pub const REVERB: [Which; 3] = [DeEss, Reverb, Space]; + /// A fundamental. + pub const FUND: [Which; 3] = [Gate, Eq, Sat]; + /// The Tone phase alone. + pub const TONE: [Which; 3] = [Eq, Comp, Sat]; +} + +// ── the column as a view ───────────────────────────────────────────────── + +/// A track's whole rack, for a strip: the phase containers and every +/// unit, laid out for the strip's width at session's tiers. Taller than +/// its box, it clips (the host scrolls by moving the box up). +#[derive(Debug, Clone)] +pub struct ToneRack { + pub units: Vec, + /// EQ bands per EQ unit. + pub bands: usize, + /// Scroll: how far the column is moved up, px. + pub scroll: f64, +} + +impl ToneRack { + #[must_use] + pub fn new(units: &[Which]) -> Self { + Self { + units: units.to_vec(), + bands: 4, + scroll: 0.0, + } + } + + /// The whole eleven-unit channel chain. + #[must_use] + pub fn channel() -> Self { + Self::new(&chains::CHANNEL) + } +} + +impl EmbedView for ToneRack { + fn catalog(&self) -> Catalog { + catalog::rack(&self.units, self.bands) + } + + fn build(&self, ctx: &BuildCtx) -> Built { + let (w, h) = ctx.size; + let mut k = Kit::new(w, h); + let fold = folded(&ctx.values); + let root = k.root; + let col = style::clip(&mut k.b(), root, "rack", (0.0, 0.0, w, h)); + let rows = chain(&self.units, (0.0, -self.scroll, w), &fold); + panels::column(&mut k, col, ctx, &rows, self.bands); + let mut built = k.finish(); + built + .rebuild_on + .extend(Phase::ALL.map(|p| ParamAddr::new(p.fold_addr()))); + for u in &self.units { + built + .rebuild_on + .extend(catalog::layout_params(*u).into_iter().map(ParamAddr::new)); + } + built + } + + fn hints(&self, host: EmbedHost) -> SizeHints { + let mut h = host.hints(); + if host == EmbedHost::Strip { + h.max.1 = tall(&self.units, &[]).max(h.min.1); + } + h + } + + fn height_for(&self, _width: f64, values: &Values) -> Option { + Some(tall(&self.units, &folded(values))) + } +} + +/// One unit alone — what a REAPER MCP or TCP slot shows for one plugin. +/// No phase bar; the unit takes the whole box. +#[derive(Debug, Clone)] +pub struct TonePanel { + pub which: Which, + pub bands: usize, +} + +impl TonePanel { + #[must_use] + pub fn new(which: Which) -> Self { + Self { which, bands: 4 } + } +} + +impl EmbedView for TonePanel { + fn catalog(&self) -> Catalog { + catalog::rack(&[self.which], self.bands) + } + + fn build(&self, ctx: &BuildCtx) -> Built { + let (w, h) = ctx.size; + let mut k = Kit::new(w, h); + let root = k.root; + let rows = [(Row::Unit(self.which), (0.0, 0.0, w, h))]; + panels::column(&mut k, root, ctx, &rows, self.bands); + let mut built = k.finish(); + built.rebuild_on.extend( + catalog::layout_params(self.which) + .into_iter() + .map(ParamAddr::new), + ); + built + } + + fn hints(&self, host: EmbedHost) -> SizeHints { + let mut h = host.hints(); + if host == EmbedHost::Mcp { + h.preferred_aspect = 96.0 / self.which.natural(); + } + h + } + + fn height_for(&self, _width: f64, _values: &Values) -> Option { + Some(self.which.natural()) + } +} + +/// A scene under construction: its page, root frame, controls and the +/// derived properties the panels register. +pub struct Kit { + pub scene: Scene, + pub controls: std::collections::BTreeMap, + pub theme: Theme, + pub page: NodeId, + pub root: NodeId, + pub derived: Vec, + pub rebuild_on: Vec, +} + +impl Kit { + /// A page named `embed:strip` holding a `w × h` root frame. + #[must_use] + pub fn new(w: f64, h: f64) -> Self { + let mut scene = Scene::new(EmbedHost::Strip.page()); + let page = scene.pages()[0]; + let mut controls = std::collections::BTreeMap::new(); + let theme = Theme::default(); + let root = { + let mut b = Builder { + scene: &mut scene, + controls: &mut controls, + theme: &theme, + }; + let mut n = crate::surface::kit::draw::frame("embed", 0.0, 0.0, w, h); + n.clips_content = true; + b.add(page, n) + }; + Self { + scene, + controls, + theme, + page, + root, + derived: Vec::new(), + rebuild_on: Vec::new(), + } + } + + /// A builder over this kit's scene. + pub fn b(&mut self) -> Builder<'_> { + Builder { + scene: &mut self.scene, + controls: &mut self.controls, + theme: &self.theme, + } + } + + pub fn derive( + &mut self, + node: NodeId, + f: impl Fn(&Values, &mut crate::document::proto::Node) + Send + Sync + 'static, + ) { + self.derived.push(Derived::new(node, f)); + } + + #[must_use] + pub fn finish(self) -> Built { + Built { + surface: Surface { + scene: self.scene, + controls: self.controls, + }, + page: self.page, + derived: self.derived, + rebuild_on: self.rebuild_on, + scale: 1.0, + } + } +} + +/// The tier a unit draws at, for a box `w` wide. +#[must_use] +pub fn tier_of(w: f64) -> Tier { + Tier::at(w) +} diff --git a/crates/frame/src/embed/tone/panels.rs b/crates/frame/src/embed/tone/panels.rs new file mode 100644 index 00000000..8ca1c0b9 --- /dev/null +++ b/crates/frame/src/embed/tone/panels.rs @@ -0,0 +1,2114 @@ +//! The Tone rack's panels as frame nodes and controls. +//! +//! One function per unit, each drawing into a body box at a tier, with the +//! geometry of session's `tone.rs` (`body_of`, `display_of`, `lane_of`, +//! `sat_split`, the comp's strips and arrow, the gate's two lines): the +//! same boxes, so the curves across a mixer of both sit on the same lines. +//! +//! What moves with params is a control or a [`crate::embed::Derived`] property; +//! what moves with the audio is a `Plot` (or a `Curve` reading a feed) and +//! so lands in the embed's live pass. + +use crate::document::proto::{NodeId, NodeKind, Paint, TextAlign, Transform}; +use crate::surface::kit::draw; +use crate::surface::proto::{ + Axis, Binding, ButtonMode, Control, ControlKind, CurveInput, HandleAxis, Orientation, + PlotRange, PlotStyle, SlotRole, +}; + +use super::catalog::EQ_RANGES; +use super::style::{self as s, alpha}; +use super::{Kit, Phase, Rect4, Row, Which}; +use crate::embed::curves::{self, CIRCUITS, DELAY_FAMILIES, ROOM_FAMILIES}; +use crate::embed::embed::{BuildCtx, Values}; +use crate::embed::form::Tier; + +// ── the column ─────────────────────────────────────────────────────────── + +/// Draw `rows` into `parent` (their rects are in `parent`'s space). +pub fn column(k: &mut Kit, parent: NodeId, ctx: &BuildCtx, rows: &[(Row, Rect4)], bands: usize) { + let tier = ctx.tier; + if !tier.on() { + return; + } + let folded = super::folded(&ctx.values); + for (row, at) in rows { + // Culled: nothing to draw outside the box. + if at.1 + at.3 < 0.0 || at.1 > ctx.size.1 { + continue; + } + match (*row, tier) { + (Row::Head(p) | Row::Blank(p), Tier::Minimal) => { + let tint = if matches!(row, Row::Blank(_)) { + alpha(p.tint(), s::BLANK_ALPHA) + } else { + p.tint().to_string() + }; + s::fill( + &mut k.b(), + parent, + "phase tick", + (at.0, at.1 + 3.0, 2.0, (at.3 - 6.0).max(0.0)), + &tint, + ); + } + (Row::Head(p), _) => container(k, parent, p, *at, folded.contains(&p)), + (Row::Blank(p), _) => blank(k, parent, p, *at), + (Row::Unit(w), Tier::Minimal) => minimal(k, parent, ctx, w, inset(*at, 1.0), bands), + (Row::Unit(w), _) => unit(k, parent, ctx, w, *at, bands), + } + } +} + +fn inset((x, y, w, h): Rect4, by: f64) -> Rect4 { + ( + x + by, + y + by, + (w - 2.0 * by).max(0.0), + (h - 2.0 * by).max(0.0), + ) +} + +/// `by` off the top, and what is left. +fn split_top((x, y, w, h): Rect4, by: f64) -> (Rect4, Rect4) { + ((x, y, w, by.min(h)), (x, y + by, w, (h - by).max(0.0))) +} + +/// A unit's body: its box less the 2 px inset and, when the row can spare +/// it, the header (session `body_of`). +#[must_use] +pub fn body_of(at: Rect4) -> Rect4 { + let inner = inset(at, 2.0); + if inner.3 > s::HEAD * 2.0 { + split_top(inner, s::HEAD).1 + } else { + inner + } +} + +/// The part of a body a unit's main display takes (session `display_of`). +#[must_use] +pub fn display_of(body: Rect4, which: Which, tier: Tier) -> Rect4 { + if !tier.detailed() { + return body; + } + let keep = match which { + Which::Gate => s::LANE + 2.0, + Which::DeEss => s::BAND_STRIP + 2.0, + Which::Sat | Which::Delay | Which::Reverb => s::SELECTOR + 2.0, + _ => 0.0, + }; + let over = which == Which::DeEss; + ( + body.0, + if over { body.1 + keep } else { body.1 }, + body.2, + (body.3 - keep).max(0.0), + ) +} + +/// The lane under (or, for the de-esser, over) a display, when it has one. +#[must_use] +pub fn lane_of(body: Rect4, which: Which, tier: Tier) -> Option { + let d = display_of(body, which, tier); + if d.3 >= body.3 { + return None; + } + if which == Which::DeEss { + return Some((body.0, body.1, body.2, s::BAND_STRIP)); + } + Some(( + body.0, + d.1 + d.3 + 2.0, + body.2, + (body.3 - d.3 - 2.0).max(0.0), + )) +} + +// ── containers ─────────────────────────────────────────────────────────── + +fn container(k: &mut Kit, parent: NodeId, phase: Phase, at: Rect4, shut: bool) { + let (x, y, w, h) = at; + let bar = s::fill(&mut k.b(), parent, "phase", at, s::WELL); + s::fill( + &mut k.b(), + parent, + "phase tint", + (x, y, 2.0, h), + phase.tint(), + ); + s::text( + &mut k.b(), + parent, + "phase name", + phase.name(), + (x + 6.0, y + h - 4.0, (w - 20.0).max(1.0)), + 8.0, + s::TEXT_DIM, + TextAlign::Left, + ); + // The chevron: up when open, down when shut — where a click moves it. + let (cx, cy, arm) = (x + w - 9.0, y + h / 2.0, 3.2); + let (tip, base) = if shut { + (cy + arm * 0.6, cy - arm * 0.6) + } else { + (cy - arm * 0.6, cy + arm * 0.6) + }; + s::line( + &mut k.b(), + parent, + "chevron", + (w, h), + s::poly(&[(cx - arm, base), (cx, tip), (cx + arm, base)]), + s::TEXT_FAINT, + 1.4, + ); + // The whole bar folds the phase. + let c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .bound(Binding::param(phase.fold_addr())); + k.controls.insert(bar, c); +} + +fn blank(k: &mut Kit, parent: NodeId, phase: Phase, at: Rect4) { + let (x, y, w, h) = at; + s::fill(&mut k.b(), parent, "blank phase", at, &alpha(s::WELL, 0.5)); + s::fill( + &mut k.b(), + parent, + "blank tint", + (x, y, 2.0, h), + &alpha(phase.tint(), s::BLANK_ALPHA), + ); + s::text( + &mut k.b(), + parent, + "phase name", + phase.name(), + (x + 6.0, y + h - 4.0, (w - 8.0).max(1.0)), + 8.0, + &alpha(s::TEXT_FAINT, s::BLANK_ALPHA), + TextAlign::Left, + ); +} + +// ── one unit ───────────────────────────────────────────────────────────── + +fn unit(k: &mut Kit, parent: NodeId, ctx: &BuildCtx, which: Which, at: Rect4, bands: usize) { + let tier = ctx.tier; + s::fill( + &mut k.b(), + parent, + &format!("{} ground", which.name()), + at, + s::WELL, + ); + let inner = inset(at, 2.0); + let body = body_of(at); + let head = (body.1 > inner.1).then(|| split_top(inner, s::HEAD).0); + if body.2 > 0.0 && body.3 > 0.0 { + let panel = s::clip(&mut k.b(), parent, which.name(), body); + let local = (0.0, 0.0, body.2, body.3); + match which { + w if w.is_eq() => eq(k, panel, ctx, which, local, tier, bands), + Which::Gate => gate(k, panel, which, local, tier), + w if w.is_comp() => comp(k, panel, which, local, tier), + Which::Sat => sat(k, panel, ctx, which, local, tier), + Which::DeEss => deess(k, panel, which, local, tier), + Which::Delay => delay(k, panel, ctx, which, local, tier), + _ => reverb(k, panel, ctx, which, local, tier), + } + if let Some(lane) = lane_of(local, which, tier) + && matches!(which, Which::Sat | Which::Delay | Which::Reverb) + { + selector(k, panel, ctx, which, lane); + } + } + scrim(k, parent, which, at, head.unwrap_or(inner)); + if let Some(head) = head { + header(k, parent, ctx, which, head); + } +} + +/// The bypass: a scrim over the whole unit, lit by `/bypass`, and +/// the header row is its switch. +fn scrim(k: &mut Kit, parent: NodeId, which: Which, at: Rect4, switch: Rect4) { + let scrim = s::fill( + &mut k.b(), + parent, + "bypass scrim", + at, + &alpha(s::WELL, 0.78), + ); + let hit = s::frame(&mut k.b(), parent, "bypass switch", switch); + let c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .bound(Binding::param(format!("{}/bypass", which.ns()))) + .slot(SlotRole::Glow, scrim.0); + k.controls.insert(hit, c); +} + +/// A unit's header: glyph, name, and its settings in one line — or +/// "BYPASS" when it is switched out. +fn header(k: &mut Kit, parent: NodeId, ctx: &BuildCtx, which: Which, at: Rect4) { + let (x, y, w, _) = at; + const SIZE: f64 = 7.0; + let baseline = y + SIZE; + let mut left = x; + if let Some((family, index)) = glyph_of(which, ctx) { + glyph(k, parent, family, index, (x, baseline - 0.5), s::TEXT_FAINT); + left += GLYPH_W; + } + let name_w = s::text_width(which.name(), SIZE) + (left - x); + if name_w > w { + return; + } + let name = s::text( + &mut k.b(), + parent, + "unit name", + which.name(), + (left, baseline, name_w.max(1.0) + 4.0), + SIZE, + s::TEXT_FAINT, + TextAlign::Left, + ); + let bypass = format!("{}/bypass", which.ns()); + let b2 = bypass.clone(); + k.derive(name, move |v, n| { + if v.get(&b2).is_some_and(|b| *b >= 0.5) { + n.fills = vec![Paint::solid(draw::hex(&alpha(s::TEXT_FAINT, 0.55)))]; + } + }); + let value = s::text( + &mut k.b(), + parent, + "unit value", + "", + (x, baseline, w), + SIZE, + s::TEXT_DIM, + TextAlign::Right, + ); + let tier = ctx.tier; + k.derive(value, move |v, n| { + let bypassed = v.get(&bypass).is_some_and(|b| *b >= 0.5); + let text = if bypassed { + "BYPASS".to_string() + } else { + summary(which, v, tier) + }; + // No room: the row keeps its height, the value is not drawn. + let fits = name_w + s::text_width(&text, SIZE) + 6.0 <= w; + if let NodeKind::Text(t) = &mut n.kind { + t.characters = if fits { text } else { String::new() }; + } + if bypassed { + n.fills = vec![Paint::solid(draw::hex(s::TEXT_FAINT))]; + } + }); +} + +fn millis(ms: f64) -> String { + if ms >= 1000.0 { + format!("{:.1}s", ms / 1000.0) + } else if ms < 10.0 { + format!("{ms:.1}ms") + } else { + format!("{ms:.0}ms") + } +} + +fn get(v: &Values, addr: &str, default: f64) -> f64 { + v.get(addr) + .copied() + .filter(|x| x.is_finite()) + .unwrap_or(default) +} + +/// Whether an EQ band is doing anything. +fn band_used(v: &Values, ns: &str, n: usize) -> bool { + let on = get(v, &format!("{ns}/b{n}_on"), 1.0) >= 0.5; + let gain = get(v, &format!("{ns}/b{n}_gain"), 0.0); + let shape = get(v, &format!("{ns}/b{n}_shape"), 0.0).round() as i64; + on && (gain.abs() > 0.05 || matches!(shape, 3..=6)) +} + +/// What a unit's settings come to, in one short line (session +/// `Which::summary`). +#[must_use] +pub fn summary(which: Which, v: &Values, tier: Tier) -> String { + let ns = which.ns(); + let g = |k: &str, d: f64| get(v, &format!("{ns}/{k}"), d); + match which { + w if w.is_eq() => { + let used: Vec = (1..=24) + .filter(|n| v.contains_key(&format!("{ns}/b{n}_on")) && band_used(v, ns, *n)) + .collect(); + let range = used + .iter() + .map(|n| get(v, &format!("{ns}/b{n}_gain"), 0.0).abs()) + .fold(0.0, f64::max); + if used.is_empty() { + "flat".into() + } else { + format!("{} · {range:.1}dB", used.len()) + } + } + w if w.is_comp() => { + let head = format!("{:.0}dB · {:.1}:1", g("threshold", -18.0), g("ratio", 4.0)); + if tier.editing() { + format!( + "{head} · {}/{}", + millis(g("attack", 10.0)), + millis(g("release", 150.0)) + ) + } else { + head + } + } + Which::Gate => format!("{:.0}dB · {:.0}", g("threshold", -40.0), g("range", -18.0)), + Which::Sat => { + let rungs = curves::harmonics( + g("drive", 2.0), + g("bias", 0.15), + g("circuit", 0.0).round().max(0.0) as usize, + 1.0, + ); + format!( + "x{:.1} · 2nd {:.0}%", + g("drive", 2.0), + curves::even_share(&rungs) * 100.0 + ) + } + Which::DeEss => format!("{:.0}dB · {:.0}", g("depth", 6.0), g("threshold", -24.0)), + Which::Delay => { + let head = format!( + "{} · {:.0}%", + millis(g("time", 300.0)), + g("feedback", 0.38) * 100.0 + ); + let fam = DELAY_FAMILIES + .get(g("family", 0.0).round().max(0.0) as usize) + .copied() + .unwrap_or(""); + if tier.editing() { + format!("{head} · {}", fam.to_lowercase()) + } else { + head + } + } + _ => { + let head = format!("{:.1}s · {:.0}%", g("decay", 1.8), g("mix", 0.18) * 100.0); + let fam = ROOM_FAMILIES + .get(g("family", 1.0).round().max(0.0) as usize) + .copied() + .unwrap_or(""); + if tier.editing() { + format!("{head} · {}", fam.to_lowercase()) + } else { + head + } + } + } +} + +// ── controls and traces ────────────────────────────────────────────────── + +fn curve_control( + provider: &str, + inputs: Vec, + series: Option<&str>, + style: PlotStyle, + x: Axis, + y: PlotRange, +) -> Control { + Control::new(ControlKind::Curve { + provider: provider.into(), + inputs, + series: series.map(Into::into), + style, + x, + y, + }) +} + +/// A curve (or plot) frame the size of `at` holding its area and/or line. +#[allow(clippy::too_many_arguments)] +fn traced( + k: &mut Kit, + parent: NodeId, + name: &str, + at: Rect4, + control: Control, + line: Option<(&str, f64, bool)>, + area: Option<&str>, +) -> (NodeId, Option, Option) { + let f = s::frame(&mut k.b(), parent, name, at); + let mut c = control; + let a = area.map(|color| { + let n = s::area(&mut k.b(), f, "area", (at.2, at.3), color); + c = c.clone().slot(SlotRole::Area, n.0); + n + }); + let l = line.map(|(color, width, dash)| { + let n = s::trace(&mut k.b(), f, "line", (at.2, at.3), color, width, dash); + c = c.clone().slot(SlotRole::Trace, n.0); + n + }); + k.controls.insert(f, c); + (f, a, l) +} + +fn hline(k: &mut Kit, parent: NodeId, at: Rect4, y: f64, color: &str, width: f64) -> NodeId { + s::line( + &mut k.b(), + parent, + "rule", + (at.2, at.3), + s::seg((at.0, y), (at.0 + at.2, y)), + color, + width, + ) +} + +fn vline(k: &mut Kit, parent: NodeId, at: Rect4, x: f64, color: &str) -> NodeId { + s::line( + &mut k.b(), + parent, + "rule", + (at.2, at.3), + s::seg((x, at.1), (x, at.1 + at.3)), + color, + 1.0, + ) +} + +// ── EQ ─────────────────────────────────────────────────────────────────── + +const DECADES: [(f64, &str); 3] = [(100.0, "100"), (1_000.0, "1k"), (10_000.0, "10k")]; + +fn ladder_step(range: f64) -> f64 { + if range <= 6.0 { + 3.0 + } else if range <= 12.0 { + 6.0 + } else { + 12.0 + } +} + +fn freq_x(hz: f64, w: f64) -> f64 { + (hz / 20.0).log10() / 3.0 * w +} + +/// The EQ graph: the plugin painter's look — each band filled in its own +/// hue, the total in gold, handles on the curve — at a strip's size. +fn eq(k: &mut Kit, p: NodeId, ctx: &BuildCtx, which: Which, at: Rect4, tier: Tier, bands: usize) { + let ns = which.ns(); + let (w, h) = (at.2, at.3); + let range = EQ_RANGES + .get(ctx.value(&format!("{ns}/range"), 2.0).round().max(0.0) as usize) + .copied() + .unwrap_or(12.0); + let db_y = |db: f64| (0.5 - db / (2.0 * range)) * h; + // Minimal draws the graph at Full's detail (session: a rail is thin, + // not blind), without the analyser. + let detail = if tier == Tier::Minimal { + Tier::Full + } else { + tier + }; + + if detail == Tier::Full { + for (hz, name) in DECADES { + let x = freq_x(hz, w); + vline(k, p, at, x, s::GRID_BEAT); + let lw = s::text_width(name, 6.0); + if x - lw / 2.0 >= 0.0 && x + lw / 2.0 <= w && tier != Tier::Minimal { + s::text( + &mut k.b(), + p, + "decade", + name, + (x - lw, h - 2.0, lw * 2.0), + 6.0, + s::TEXT_FAINT, + TextAlign::Center, + ); + } + } + } + if detail == Tier::Focus { + // The plugin's own grid: every 1-2-5, decades bolder, labelled. + for dec in [10.0, 100.0, 1000.0, 10_000.0] { + for m in [2.0, 5.0, 10.0] { + let hz: f64 = dec * m; + if hz > 20.0 && hz < 20_000.0 { + vline( + k, + p, + at, + freq_x(hz, w), + if m == 10.0 { s::GRID } else { s::GRID_BEAT }, + ); + } + } + } + for (hz, name) in [ + (50.0, "50"), + (100.0, "100"), + (200.0, "200"), + (500.0, "500"), + (1000.0, "1k"), + (2000.0, "2k"), + (5000.0, "5k"), + (10_000.0, "10k"), + ] { + let x = freq_x(hz, w); + s::text( + &mut k.b(), + p, + "freq", + name, + (x - 14.0, h - 2.0, 28.0), + 7.0, + s::TEXT_FAINT, + TextAlign::Center, + ); + } + } + if detail.detailed() { + let step = ladder_step(range); + let mut gain = step; + while gain <= range + 1e-9 { + for db in [gain, -gain] { + hline(k, p, at, db_y(db), s::GRID_BEAT, 1.0); + if detail == Tier::Focus && gain < range { + s::text( + &mut k.b(), + p, + "db", + &format!("{db:+.0}"), + (2.0, db_y(db) + 3.0, 24.0), + 6.5, + s::TEXT_FAINT, + TextAlign::Left, + ); + } + } + gain += step; + } + } + // Unity, always: without it a boost and a cut look the same. + hline(k, p, at, db_y(0.0), s::GRID, 1.0); + + let x_axis = Axis::Log { + f_min: 20.0, + f_max: 20_000.0, + }; + let y = PlotRange::linear(-range, range); + + // The analyser: a wash behind the graph at a glance width, the + // painter's own at focus. + if tier != Tier::Minimal { + let (fill, line) = if tier == Tier::Focus { + ("#8c8c9624", "#8c8c9659") + } else { + ("#8c8c9612", "#8c8c9638") + }; + let c = Control::new(ControlKind::Plot { + style: PlotStyle::Area { baseline: -40.0 }, + x: x_axis.clone(), + y: PlotRange::linear(-40.0, 16.0), + }) + .bound(Binding::stream(format!("{ns}/spectrum"))); + traced( + k, + p, + "spectrum", + at, + c, + Some((line, 1.0, false)), + Some(fill), + ); + } + + let mut inputs = Vec::new(); + for n in 1..=bands { + for key in ["freq", "gain", "q", "shape", "on"] { + inputs.push(CurveInput::param( + format!("b{n}_{key}"), + format!("{ns}/b{n}_{key}"), + )); + } + inputs.push(CurveInput::constant(format!("b{n}_slope"), 12.0)); + } + // Each band in its own hue, filled to unity (the painter's look). + for n in 1..=bands { + let c = curve_control( + "eq.response", + inputs.clone(), + Some(&format!("b{n}")), + PlotStyle::Area { baseline: 0.0 }, + x_axis.clone(), + y.clone(), + ); + let (_, area, line) = traced( + k, + p, + &format!("band {n}"), + at, + c, + Some(("#ffffffbf", 1.0, false)), + Some("#ffffff59"), + ); + let (fa, fl) = (format!("{ns}/b{n}_freq"), format!("{ns}/b{n}_on")); + for (node, a) in [(area, 0.35), (line, 0.75)] { + let Some(node) = node else { continue }; + let (fa, fl) = (fa.clone(), fl.clone()); + k.derive(node, move |v, nd| { + let hue = s::band_color(get(v, &fa, 1000.0)); + let paint = Paint::solid(crate::document::proto::Color { a, ..hue }); + if nd.stroke.weight > 0.0 && !nd.stroke.paints.is_empty() { + nd.stroke.paints = vec![paint]; + } else { + nd.fills = vec![paint]; + } + if get(v, &fl, 1.0) < 0.5 { + nd.visible = false; + } + }); + } + } + // The total, in the painter's gold. + let width = if tier == Tier::Focus { 2.0 } else { 1.5 }; + let c = curve_control( + "eq.response", + inputs, + Some("sum"), + PlotStyle::Line, + x_axis.clone(), + y, + ); + traced( + k, + p, + "response", + at, + c, + Some((s::EQ_INK, width, false)), + None, + ); + + // The bands as handles: dots at Full (and on a rail), the plugin's + // bigger nodes at focus, none at Curves (a kink, not a target). + if matches!(detail, Tier::Full | Tier::Focus) { + let focus = detail == Tier::Focus; + let r = if focus { 4.5 } else { s::HANDLE }; + let bx = if focus { 16.0 } else { 12.0 }; + for n in 1..=bands { + let node = s::frame( + &mut k.b(), + p, + &format!("band {n} handle"), + ((w - bx) / 2.0, (h - bx) / 2.0, bx, bx), + ); + let ring = s::dot( + &mut k.b(), + node, + "ring", + (bx / 2.0, bx / 2.0), + r + 1.0, + if focus { "#ffffffd9" } else { s::WELL }, + ); + let dot = s::dot(&mut k.b(), node, "dot", (bx / 2.0, bx / 2.0), r, "#ffffff"); + let _ = ring; + let kind = ControlKind::Handle { + x: Some(Binding::param(format!("{ns}/b{n}_freq"))), + y: Some(Binding::param(format!("{ns}/b{n}_gain"))), + wheel: Some(Binding::param(format!("{ns}/b{n}_q"))), + x_map: HandleAxis::new(x_axis.clone()), + y_map: HandleAxis::new(Axis::Linear { + min: -range, + max: range, + }), + }; + k.controls.insert(node, Control::new(kind)); + let (fa, n2, ns2) = (format!("{ns}/b{n}_freq"), n, ns.to_string()); + k.derive(dot, move |v, nd| { + nd.fills = vec![Paint::solid(s::band_color(get(v, &fa, 1000.0)))] + }); + k.derive(node, move |v, nd| nd.visible = band_used(v, &ns2, n2)); + } + } + // The zoom chip: what the height means, and the control that changes it. + if detail == Tier::Full { + let (cw, ch) = (30.0_f64.min(w), 12.0_f64.min(h)); + if cw >= 20.0 && ch >= 8.0 { + let chip = s::fill( + &mut k.b(), + p, + "zoom", + ((w - cw) / 2.0, 1.0, cw, ch), + s::WELL, + ); + let label = s::text( + &mut k.b(), + p, + "zoom value", + &format!("±{range:.0}"), + ((w - cw) / 2.0, 1.0 + ch - 3.0, cw), + 8.0, + s::TEXT_FAINT, + TextAlign::Center, + ); + let _ = label; + let c = + Control::new(ControlKind::Selector).bound(Binding::param(format!("{ns}/range"))); + k.controls.insert(chip, c); + } + } +} + +// ── compressor ─────────────────────────────────────────────────────────── + +/// dB (0 at the top … −60 at the floor) → y (the comp editor's axis). +fn comp_y(db: f64, h: f64) -> f64 { + ((-db) / 60.0).clamp(0.0, 1.0) * h +} + +const ARROW_X: f64 = 14.0; +const STRIP_H: f64 = 5.0; +const STRIP_GAP: f64 = 4.0; +const STRIP_LABEL_W: f64 = 8.0; +const STRIPS_H: f64 = STRIP_H * 2.0 + STRIP_GAP * 3.0 + 7.0; + +/// The input coming up from the floor, the reduction hanging from the top +/// (live), the threshold as a line across it, the ratio as an arrow down +/// from it, and the two times as strips along the floor. +fn comp(k: &mut Kit, p: NodeId, which: Which, at: Rect4, tier: Tier) { + let ns = which.ns(); + let h = at.3; + if tier.detailed() { + for db in [-12.0, -24.0, -36.0, -48.0] { + hline(k, p, at, comp_y(db, h), s::GRID_BEAT, 1.0); + } + } + levels(k, p, ns, at, "in_history", s::COMP_GREY); + // The reduction, hanging from the top. + let c = Control::new(ControlKind::Plot { + style: PlotStyle::Area { baseline: 0.0 }, + x: Axis::Time { + len: super::catalog::HISTORY, + }, + y: PlotRange::linear(-60.0, 0.0), + }) + .bound(Binding::stream(format!("{ns}/gr_history"))); + traced( + k, + p, + "reduction", + at, + c, + Some((s::REDUCTION_EDGE, 1.2, false)), + Some(&alpha(s::REDUCTION_FILL, 0.6)), + ); + + threshold( + k, + p, + &format!("{ns}/threshold"), + at, + s::THRESHOLD, + tier.detailed(), + ); + if tier.detailed() { + arrow(k, p, ns, at); + strips(k, p, ns, at); + } +} + +/// A level history coming up from the floor (live). +fn levels(k: &mut Kit, p: NodeId, ns: &str, at: Rect4, feed: &str, ink: &str) { + let c = Control::new(ControlKind::Plot { + style: PlotStyle::Area { baseline: -60.0 }, + x: Axis::Time { + len: super::catalog::HISTORY, + }, + y: PlotRange::linear(-60.0, 0.0), + }) + .bound(Binding::stream(format!("{ns}/{feed}"))); + traced( + k, + p, + "input", + at, + c, + Some((&alpha(ink, 0.85), 1.0, false)), + Some(&alpha(ink, 0.32)), + ); +} + +/// A threshold: a line across the display, grabbed anywhere near it. +fn threshold(k: &mut Kit, p: NodeId, addr: &str, at: Rect4, ink: &str, tab: bool) -> NodeId { + let (w, h) = (at.2, at.3); + const GRAB: f64 = 6.0; + let node = s::frame( + &mut k.b(), + p, + "threshold", + (0.0, h / 2.0 - GRAB, w, GRAB * 2.0), + ); + s::line( + &mut k.b(), + node, + "threshold line", + (w, GRAB * 2.0), + s::seg((0.0, GRAB), (w, GRAB)), + ink, + 1.5, + ); + if tab { + s::dot( + &mut k.b(), + node, + "threshold tab", + (w - s::HANDLE - 1.0, GRAB), + s::HANDLE, + ink, + ); + } + let kind = ControlKind::Handle { + x: None, + y: Some(Binding::param(addr)), + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { + min: -60.0, + max: 0.0, + }), + }; + k.controls.insert(node, Control::new(kind)); + node +} + +/// The ratio: an arrow down from the threshold as far as a full-scale +/// signal would be taken (session `ratio_drop`), the ratio at its tip. +fn arrow(k: &mut Kit, p: NodeId, ns: &str, at: Rect4) { + let (w, h) = (at.2, at.3); + let x = ARROW_X; + let shape = s::line( + &mut k.b(), + p, + "ratio arrow", + (w, h), + String::new(), + &alpha(s::THRESHOLD, 0.85), + 1.2, + ); + let head = s::shape( + &mut k.b(), + p, + "ratio head", + (w, h), + String::new(), + &alpha(s::THRESHOLD, 0.85), + ); + let label = s::text( + &mut k.b(), + p, + "ratio label", + "", + (x + 5.0, 0.0, 40.0), + 6.0, + &alpha(s::THRESHOLD, 0.85), + TextAlign::Left, + ); + let geo = { + let (thr, rat) = (format!("{ns}/threshold"), format!("{ns}/ratio")); + move |v: &Values| { + let t = get(v, &thr, -18.0); + let r = get(v, &rat, 4.0).max(1.0); + let top = comp_y(t, h); + let floor = h - STRIPS_H; + let drop = -t * (1.0 - 1.0 / r) / 60.0 * h; + (top, (top + drop).min(floor.max(top)), r) + } + }; + let g1 = geo.clone(); + k.derive(shape, move |v, n| { + let (top, tip, _) = g1(v); + n.fill_geometry = vec![s::seg((x, top), (x, tip))]; + }); + let g2 = geo.clone(); + k.derive(head, move |v, n| { + let (_, tip, _) = g2(v); + n.fill_geometry = vec![format!( + "{} Z", + s::poly(&[(x - 3.0, tip - 4.8), (x + 3.0, tip - 4.8), (x, tip)]) + )]; + }); + k.derive(label, move |v, n| { + let (_, tip, r) = geo(v); + n.transform = Transform::translate(x + 5.0, tip + 1.0 - 6.0 * 0.97 - 1.0); + if let NodeKind::Text(t) = &mut n.kind { + t.characters = if (r - r.round()).abs() < 0.05 { + format!("{r:.0}:1") + } else { + format!("{r:.1}:1") + }; + } + }); + // Grab the arrow's column: drag down for more. + let hit = s::frame( + &mut k.b(), + p, + "ratio grip", + (x - 5.5, 0.0, 11.0, (h - STRIPS_H).max(1.0)), + ); + let b = Binding { + min: 1.0, + max: 0.0, + ..Binding::param(format!("{ns}/ratio")) + }; + k.controls.insert( + hit, + Control::new(ControlKind::Knob { + sweep_degrees: 270.0, + }) + .bound(b), + ); +} + +/// The two times along the floor: attack over release, FAST at the left, +/// the default marked, a bar from it to the setting. +fn strips(k: &mut Kit, p: NodeId, ns: &str, at: Rect4) { + let (w, h) = (at.2, at.3); + if w < 70.0 || h < 40.0 { + return; + } + let ink = s::THRESHOLD; + let (left, right) = (3.0, w - 3.0 - STRIP_LABEL_W); + let floor = h - STRIP_GAP; + let release = (left, floor - STRIP_H, right - left, STRIP_H); + let attack = (left, release.1 - STRIP_GAP - STRIP_H, right - left, STRIP_H); + let over = attack.1 - STRIP_GAP + 1.0; + s::text( + &mut k.b(), + p, + "fast", + "FAST", + (left, over, 30.0), + 6.0, + &alpha(ink, 0.6), + TextAlign::Left, + ); + s::text( + &mut k.b(), + p, + "slow", + "SLOW", + (right - 30.0, over, 30.0), + 6.0, + &alpha(ink, 0.6), + TextAlign::Right, + ); + for (strip, key, label, (lo, hi, def)) in [ + (attack, "attack", "A", (0.1, 200.0, 10.0)), + (release, "release", "R", (5.0, 3000.0, 150.0)), + ] { + let place = move |ms: f64| ((ms.clamp(lo, hi) / lo).ln() / (hi / lo).ln()) * strip.2; + let mut groove = draw::rect( + "groove", + strip.0, + strip.1, + strip.2, + strip.3, + draw::solid(&alpha(ink, 0.28)), + ); + groove = draw::rounded(groove, 2.0); + let groove = k.b().add(p, groove); + let _ = groove; + let cx = strip.0 + place(def); + s::line( + &mut k.b(), + p, + "default", + (w, h), + s::seg((cx, strip.1 - 1.5), (cx, strip.1 + strip.3 + 1.5)), + &alpha(ink, 0.6), + 1.0, + ); + let bar = s::fill( + &mut k.b(), + p, + "departure", + (cx, strip.1, 0.0, strip.3), + &alpha(ink, 0.55), + ); + let addr = format!("{ns}/{key}"); + let a2 = addr.clone(); + k.derive(bar, move |v, n| { + let x = strip.0 + place(get(v, &a2, def)); + let (x0, x1) = (x.min(cx), x.max(cx)); + n.transform = Transform::translate(x0, strip.1); + n.size.width = x1 - x0; + }); + s::text( + &mut k.b(), + p, + "strip label", + label, + (strip.0 + strip.2 + 3.0, strip.1 + strip.3 + 1.0, 8.0), + 7.0, + &alpha(ink, 0.7), + TextAlign::Left, + ); + // The strip is a slider; its dot is the thumb. + // Wider than the strip by the thumb, so the dot's centre spans it. + let lane = s::frame( + &mut k.b(), + p, + &format!("{key} strip"), + ( + strip.0 - s::HANDLE, + strip.1 - 3.0, + strip.2 + 2.0 * s::HANDLE, + strip.3 + 6.0, + ), + ); + let thumb = s::frame( + &mut k.b(), + lane, + "thumb", + ( + 0.0, + 3.0 + strip.3 / 2.0 - s::HANDLE, + 2.0 * s::HANDLE, + 2.0 * s::HANDLE, + ), + ); + s::dot( + &mut k.b(), + thumb, + "dot", + (s::HANDLE, s::HANDLE), + s::HANDLE, + ink, + ); + let c = Control::new(ControlKind::Fader { + orientation: Orientation::Horizontal, + }) + .bound(Binding::param(addr)) + .slot(SlotRole::Thumb, thumb.0); + k.controls.insert(lane, c); + } +} + +// ── gate ───────────────────────────────────────────────────────────────── + +/// The level against a line, what the gate takes off shaded below it down +/// to the range, and the door lane under it (open, in green). +fn gate(k: &mut Kit, p: NodeId, which: Which, body: Rect4, tier: Tier) { + let ns = which.ns(); + let at = display_of(body, which, tier); + let (w, h) = (at.2, at.3); + let disp = s::frame(&mut k.b(), p, "display", at); + let local = (0.0, 0.0, w, h); + if tier.detailed() { + for db in [-12.0, -24.0, -36.0, -48.0] { + hline(k, disp, local, comp_y(db, h), s::GRID_BEAT, 1.0); + } + } + let band = s::fill( + &mut k.b(), + disp, + "shut band", + (0.0, 0.0, w, 0.0), + &alpha(s::GATE_INK, 0.18), + ); + let (thr, rng) = (format!("{ns}/threshold"), format!("{ns}/range")); + { + let (thr, rng) = (thr.clone(), rng.clone()); + k.derive(band, move |v, n| { + let line = comp_y(get(v, &thr, -40.0), h); + let floor = comp_y(get(v, &thr, -40.0) + get(v, &rng, -18.0), h); + n.transform = Transform::translate(0.0, line); + n.size.height = (floor - line).max(0.0); + }); + } + levels(k, disp, ns, local, "in_history", s::COMP_GREY); + threshold(k, disp, &thr, local, s::GATE_INK, tier.detailed()); + if tier.detailed() { + // The range: a second, fainter line — how far down "shut" goes. + const GRAB: f64 = 5.0; + let node = s::frame( + &mut k.b(), + disp, + "range", + (0.0, h / 2.0 - GRAB, w, GRAB * 2.0), + ); + s::line( + &mut k.b(), + node, + "range line", + (w, GRAB * 2.0), + s::seg((0.0, GRAB), (w, GRAB)), + &alpha(s::GATE_INK, 0.55), + 1.0, + ); + let kind = ControlKind::Handle { + x: None, + y: Some(Binding::param(rng.clone())), + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { + min: -60.0, + max: 0.0, + }) + .after(&[thr.as_str()], 0.0), + }; + k.controls.insert(node, Control::new(kind)); + // The times, standing up from the floor to the line: rise, hold, + // fall — a gate opens, and up is open. + let glyph = s::line( + &mut k.b(), + disp, + "times", + (w, h), + String::new(), + s::GATE_INK, + 1.4, + ); + let ns2 = ns.to_string(); + k.derive(glyph, move |v, n| { + let g = |key: &str, d: f64| get(v, &format!("{ns2}/{key}"), d); + let line = comp_y(g("threshold", -40.0), h); + let floor = comp_y(g("threshold", -40.0) + g("range", -18.0), h); + let norm = |ms: f64, lo: f64, hi: f64| { + ((ms.clamp(lo, hi) / lo).ln() / (hi / lo).ln()).clamp(0.0, 1.0) + }; + let x0 = w * 0.3; + let rise = x0 + w * (0.04 + 0.16 * norm(g("attack", 1.0), 0.05, 50.0)); + let hold = rise + w * (0.04 + 0.16 * norm(g("hold", 30.0), 1.0, 500.0)); + let fall = hold + w * (0.04 + 0.2 * norm(g("release", 120.0), 5.0, 2000.0)); + n.fill_geometry = vec![s::poly(&[ + (x0, floor), + (rise, line), + (hold, line), + (fall.min(w - 8.0), floor), + ])]; + }); + } + // The door lane (live): when the gate was open. + if let Some(lane) = lane_of(body, which, tier) { + s::fill(&mut k.b(), p, "lane", lane, &alpha(s::GATE_INK, 0.06)); + let c = Control::new(ControlKind::Plot { + style: PlotStyle::Steps, + x: Axis::Time { + len: super::catalog::HISTORY, + }, + y: PlotRange::linear(0.0, 1.0), + }) + .bound(Binding::stream(format!("{ns}/open_history"))); + traced(k, p, "door", lane, c, None, Some(&alpha(s::GATE_INK, 0.7))); + } +} + +// ── saturator ──────────────────────────────────────────────────────────── + +/// The saturator's curve square and the ladder beside it (session +/// `sat_split`). +fn sat_split(body: Rect4, tier: Tier) -> (Rect4, Option) { + if !tier.detailed() || body.2 < 80.0 { + return (body, None); + } + let lw = body.2 * 0.34; + ( + (body.0, body.1, body.2 - lw - 4.0, body.3), + Some((body.0 + body.2 - lw, body.1, lw, body.3 - 6.0)), + ) +} + +/// How hot the stage runs at full scale, 0..1 (session: the sum of the +/// ladder's rungs, on a 0.7 power). +fn heat(v: &Values, ns: &str) -> f64 { + let g = |key: &str, d: f64| get(v, &format!("{ns}/{key}"), d); + let rungs = curves::harmonics( + g("drive", 2.0), + g("bias", 0.15), + g("circuit", 0.0).round().max(0.0) as usize, + 1.0, + ); + (rungs + .iter() + .map(|r| (r * 4.0).clamp(0.0, 1.0).sqrt()) + .sum::() + / 7.0 + / 0.9) + .clamp(0.0, 1.0) + .powf(0.7) +} + +fn glow(k: &mut Kit, p: NodeId, ns: &str, at: Rect4) { + let mut n = draw::rect( + "heat", + at.0, + at.1, + at.2, + at.3, + draw::solid(&alpha(s::SAT_GLOW, 0.22)), + ); + let (hot, mid, cold) = ( + alpha(s::SAT_HOT, 0.7), + alpha(s::SAT_GLOW, 0.55), + alpha(s::SAT_GLOW, 0.0), + ); + n.fills.push(draw::radial(&[ + (0.0, hot.as_str()), + (0.35, mid.as_str()), + (1.0, cold.as_str()), + ])); + let node = k.b().add(p, n); + let ns = ns.to_string(); + k.derive(node, move |v, n| { + let h = heat(v, &ns); + n.opacity = h; + n.visible = h > 0.02; + }); +} + +fn sat(k: &mut Kit, p: NodeId, ctx: &BuildCtx, which: Which, body: Rect4, tier: Tier) { + let ns = which.ns(); + let whole = display_of(body, which, tier); + let (at, ladder) = sat_split(whole, tier); + glow(k, p, ns, whole); + let (w, h) = (at.2, at.3); + if tier.detailed() { + hline(k, p, at, at.1 + h / 2.0, s::GRID, 1.0); + vline(k, p, at, at.0 + w / 2.0, s::GRID); + s::line( + &mut k.b(), + p, + "unity", + (w, h), + s::seg((at.0, at.1 + h), (at.0 + w, at.1)), + s::GRID_BEAT, + 1.0, + ); + } + let inputs = |live: bool| { + let mut v = vec![ + CurveInput::param("drive", format!("{ns}/drive")), + CurveInput::param("bias", format!("{ns}/bias")), + CurveInput::param("circuit", format!("{ns}/circuit")), + CurveInput::param("bits", format!("{ns}/bits")), + ]; + if live { + v.push(CurveInput::stream("reach", format!("{ns}/peak"))); + } + v + }; + let unit = PlotRange::linear(-1.0, 1.0); + let x = Axis::Linear { + min: -1.0, + max: 1.0, + }; + let c = curve_control( + "sat.transfer", + inputs(false), + Some("transfer"), + PlotStyle::Line, + x.clone(), + unit.clone(), + ); + traced( + k, + p, + "transfer", + at, + c, + Some((s::SAT_INK, 1.5, false)), + None, + ); + // The lit reach (live): the part of the curve the signal is on. + let c = curve_control( + "sat.transfer", + inputs(true), + Some("transfer"), + PlotStyle::Line, + x, + unit, + ); + traced(k, p, "reach", at, c, Some((s::SAT_HOT, 2.4, false)), None); + + if let Some(lb) = ladder { + hline(k, p, lb, lb.1 + lb.3 - 1.0, s::GRID, 1.0); + // Seven rungs, H2…H8, evens in the unit's red and odds in grey, on a + // square root so the fourth and sixth show beside the second. + let floor = lb.1 + lb.3 - 1.0; + let (gap, n) = (1.2, 7.0); + let bar = ((lb.2 - 2.0 - gap * (n - 1.0)) / n).max(1.0); + let tall = lb.3 - 4.0; + for (even, ink) in [ + (true, s::SAT_INK.to_string()), + (false, alpha(s::TEXT_FAINT, 0.75)), + ] { + let node = s::shape( + &mut k.b(), + p, + if even { "evens" } else { "odds" }, + (lb.2, lb.3), + String::new(), + &ink, + ); + let ns = ns.to_string(); + k.derive(node, move |v, nd| { + let g = |key: &str, d: f64| get(v, &format!("{ns}/{key}"), d); + let rungs = curves::harmonics( + g("drive", 2.0), + g("bias", 0.15), + g("circuit", 0.0).round().max(0.0) as usize, + 1.0, + ); + let mut d = String::new(); + for (k, r) in rungs + .iter() + .enumerate() + .filter(|(k, _)| (k % 2 == 0) == even) + { + let h = (r * 4.0).clamp(0.0, 1.0).sqrt() * tall; + let x = lb.0 + 1.0 + k as f64 * (bar + gap); + if h > 0.2 { + d.push_str(&format!( + "M {x:.2} {:.2} H {:.2} V {floor:.2} H {x:.2} Z ", + floor - h, + x + bar + )); + } + } + nd.fill_geometry = vec![d.trim_end().to_string()]; + }); + } + } + let _ = ctx; +} + +// ── de-esser ───────────────────────────────────────────────────────────── + +const SUPPRESS_FLOOR_DB: f64 = -40.0; +const SUPPRESS_CEIL_DB: f64 = 16.0; + +fn suppress_y(db: f64, h: f64) -> f64 { + let t = ((db - SUPPRESS_FLOOR_DB) / (SUPPRESS_CEIL_DB - SUPPRESS_FLOOR_DB)).clamp(0.0, 1.0); + h - 1.0 - t * (h - 2.0) +} + +/// The band it watches (a strip of the top of the spectrum, over), and +/// what happened in it (time, under). +fn deess(k: &mut Kit, p: NodeId, which: Which, body: Rect4, tier: Tier) { + let ns = which.ns(); + let at = display_of(body, which, tier); + let (w, h) = (at.2, at.3); + let disp = s::frame(&mut k.b(), p, "display", at); + let local = (0.0, 0.0, w, h); + if tier.detailed() { + for db in [-12.0, -24.0] { + hline(k, disp, local, suppress_y(db, h), s::GRID_BEAT, 1.0); + } + } + let y = PlotRange::linear(SUPPRESS_FLOOR_DB, SUPPRESS_CEIL_DB); + let c = Control::new(ControlKind::Plot { + style: PlotStyle::Line, + x: Axis::Time { + len: super::catalog::HISTORY, + }, + y: y.clone(), + }) + .bound(Binding::stream(format!("{ns}/level_history"))); + traced( + k, + disp, + "band level", + local, + c, + Some((s::DEESS_INK, 1.2, false)), + None, + ); + let c = Control::new(ControlKind::Plot { + style: PlotStyle::Area { baseline: 0.0 }, + x: Axis::Time { + len: super::catalog::HISTORY, + }, + y: PlotRange::linear(-24.0, 0.0), + }) + .bound(Binding::stream(format!("{ns}/gr_history"))); + traced( + k, + disp, + "cut", + local, + c, + None, + Some(&alpha(s::DEESS_INK, 0.35)), + ); + // The reference it is judged against. + let refl = s::line( + &mut k.b(), + disp, + "threshold", + (w, h), + String::new(), + &alpha(s::DEESS_INK, 0.6), + 1.0, + ); + if let Some(n) = k.scene.node_mut(refl) { + n.stroke.dash_pattern = vec![2.5, 2.0]; + } + let thr = format!("{ns}/threshold"); + k.derive(refl, move |v, n| { + let yy = suppress_y(get(v, &thr, -24.0), h); + n.fill_geometry = vec![s::seg((0.0, yy), (w, yy))]; + }); + + let Some(strip) = lane_of(body, which, tier) else { + return; + }; + let (sw, sh) = (strip.2, strip.3); + let band_frame = s::frame(&mut k.b(), p, "band strip", strip); + let local = (0.0, 0.0, sw, sh); + let fx = Axis::Log { + f_min: 800.0, + f_max: 20_000.0, + }; + let c = Control::new(ControlKind::Plot { + style: PlotStyle::Area { + baseline: SUPPRESS_FLOOR_DB, + }, + x: fx.clone(), + y, + }) + .bound(Binding::stream(format!("{ns}/spectrum"))); + traced( + k, + band_frame, + "top end", + local, + c, + None, + Some(&alpha(s::TEXT_FAINT, 0.25)), + ); + let place = move |hz: f64| (hz / 800.0).ln() / (20_000.0_f64 / 800.0).ln() * sw; + let wash = s::fill( + &mut k.b(), + band_frame, + "band", + (0.0, 0.0, 0.0, sh), + &alpha(s::DEESS_INK, 0.2), + ); + let (lo, hi) = (format!("{ns}/low"), format!("{ns}/high")); + { + let (lo, hi) = (lo.clone(), hi.clone()); + k.derive(wash, move |v, n| { + let (a, b) = (place(get(v, &lo, 5000.0)), place(get(v, &hi, 10_000.0))); + n.transform = Transform::translate(a.min(b), 0.0); + n.size.width = (a - b).abs(); + }); + } + for hz in [1000.0, 2000.0, 5000.0, 10_000.0] { + let x = place(hz); + if x > 3.0 && x < sw - 3.0 { + s::fill( + &mut k.b(), + band_frame, + "mark", + (x - 0.5, sh - 3.0, 1.0, 3.0), + s::GRID, + ); + } + } + for addr in [lo, hi] { + let node = s::frame(&mut k.b(), band_frame, "edge", (0.0, 0.0, 8.0, sh)); + s::line( + &mut k.b(), + node, + "edge line", + (8.0, sh), + s::seg((4.0, 0.0), (4.0, sh)), + s::DEESS_INK, + 1.2, + ); + let kind = ControlKind::Handle { + x: Some(Binding::param(addr)), + y: None, + wheel: None, + x_map: HandleAxis::new(fx.clone()), + y_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + }; + k.controls.insert(node, Control::new(kind)); + } +} + +// ── delay ──────────────────────────────────────────────────────────────── + +/// The dry hit, then the repeats at the level the feedback leaves them; +/// the mix inks them, it does not shrink them. +fn delay(k: &mut Kit, p: NodeId, ctx: &BuildCtx, which: Which, body: Rect4, tier: Tier) { + let ns = which.ns(); + let at = display_of(body, which, tier); + let (w, h) = (at.2, at.3); + let floor = at.1 + h - 1.0; + let family = ctx.value(&format!("{ns}/family"), 0.0).round().max(0.0) as usize; + if tier.detailed() { + hline(k, p, at, floor, s::GRID, 1.0); + if family == 3 { + let mut when = 0.0; + for tap in 0..16 { + if when > 4.5 { + break; + } + let x = at.0 + 3.0 + when / 4.5 * (w - 6.0); + vline( + k, + p, + (at.0, at.1, w, h - 1.0), + x, + if tap % 4 == 0 { s::GRID_BEAT } else { s::GRID }, + ); + when += [1.0, 0.5, 0.75, 0.5, 1.25][tap % 5]; + } + } + } + let thin = !tier.detailed() && tier != Tier::Curves; + let taps_at = (at.0 + 3.0, at.1, (w - 6.0).max(1.0), (h - 1.0).max(1.0)); + s::line( + &mut k.b(), + p, + "dry", + (w, h), + s::seg((taps_at.0, floor), (taps_at.0, floor - 0.9 * (h - 1.0))), + s::TEXT, + if thin { 1.0 } else { 2.0 }, + ); + let inputs = vec![ + CurveInput::param("time", format!("{ns}/time")), + CurveInput::param("feedback", format!("{ns}/feedback")), + CurveInput::param("family", format!("{ns}/family")), + ]; + let c = curve_control( + "echo.taps", + inputs, + Some("taps"), + PlotStyle::Line, + Axis::Linear { min: 0.0, max: 4.5 }, + PlotRange::linear(0.0, 1.0 / 0.9), + ); + let (f, _, _) = traced( + k, + p, + "repeats", + taps_at, + c, + Some((s::DELAY_INK, if thin { 1.0 } else { 1.4 }, false)), + None, + ); + let mix = format!("{ns}/mix"); + k.derive(f, move |v, n| { + n.opacity = (0.35 + get(v, &mix, 0.3).clamp(0.0, 1.0) * 0.65) * 0.85 + }); +} + +// ── reverb ─────────────────────────────────────────────────────────────── + +/// An impulse, the gap before its tail, and the tail — its height the +/// mix, its length the decay window. +fn reverb(k: &mut Kit, p: NodeId, ctx: &BuildCtx, which: Which, body: Rect4, tier: Tier) { + let ns = which.ns(); + let at = display_of(body, which, tier); + let (w, h) = (at.2, at.3); + let floor = at.1 + h - 1.0; + if tier.detailed() { + hline(k, p, at, floor, s::GRID, 1.0); + } + let thin = tier == Tier::Minimal; + s::line( + &mut k.b(), + p, + "dry", + (w, h), + s::seg((at.0 + 1.0, floor), (at.0 + 1.0, floor - 0.92 * h)), + s::TEXT, + if thin { 1.0 } else { 2.0 }, + ); + let tail_at = (at.0, at.1, w, (h - 1.0).max(1.0)); + let inputs = |live: bool| { + let mut v = vec![ + CurveInput::param("decay", format!("{ns}/decay")), + CurveInput::param("predelay", format!("{ns}/predelay")), + CurveInput::param("mix", format!("{ns}/mix")), + CurveInput::param("family", format!("{ns}/family")), + ]; + if live { + v.push(CurveInput::stream("wet", format!("{ns}/wet"))); + } + v + }; + // Lit by the wet return (live): a reverb washing a chorus is filled. + if !thin { + let c = curve_control( + "room.tail", + inputs(true), + Some("tail"), + PlotStyle::Area { baseline: 0.0 }, + Axis::Linear { min: 0.0, max: 1.0 }, + PlotRange::linear(0.0, 1.0), + ); + let (_, area, _) = traced( + k, + p, + "wash", + tail_at, + c, + None, + Some(&alpha(s::REVERB_INK, 0.3)), + ); + if let Some(a) = area { + let glow = Control::new(ControlKind::Readout) + .bound(Binding::stream(format!("{ns}/wet"))) + .slot(SlotRole::Glow, a.0); + let holder = s::frame(&mut k.b(), p, "wet", (at.0, at.1, 1.0, 1.0)); + k.controls.insert(holder, glow); + } + } + let c = curve_control( + "room.tail", + inputs(false), + Some("tail"), + PlotStyle::Line, + Axis::Linear { min: 0.0, max: 1.0 }, + PlotRange::linear(0.0, 1.0), + ); + traced( + k, + p, + "tail", + tail_at, + c, + Some((s::REVERB_INK, if thin { 1.0 } else { 1.4 }, false)), + None, + ); + if tier.detailed() { + let pre = s::line( + &mut k.b(), + p, + "predelay", + (w, h), + String::new(), + s::GRID_BEAT, + 1.0, + ); + let (d, pd) = (format!("{ns}/decay"), format!("{ns}/predelay")); + let x0 = at.0; + let y0 = at.1; + k.derive(pre, move |v, n| { + let window = (get(v, &d, 1.8) * 1000.0).max(1.0); + let x = x0 + (get(v, &pd, 20.0) / window).clamp(0.0, 0.5) * w; + n.fill_geometry = vec![s::seg((x, y0), (x, floor))]; + }); + } + let _ = ctx; +} + +// ── the machine selector ───────────────────────────────────────────────── + +/// How wide a header glyph is, with its gap. +pub const GLYPH_W: f64 = 11.0; + +/// Which family of machines a glyph draws. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Family { + Circuit, + Delay, + Room, +} + +fn glyph_of(which: Which, ctx: &BuildCtx) -> Option<(Family, usize)> { + let at = |k: &str, d: f64| { + ctx.value(&format!("{}/{k}", which.ns()), d) + .round() + .max(0.0) as usize + }; + match which { + Which::Sat => Some((Family::Circuit, at("circuit", 0.0))), + Which::Delay => Some((Family::Delay, at("family", 0.0))), + Which::Reverb => Some((Family::Room, at("family", 1.0))), + _ => None, + } +} + +/// A machine in 9 px: its left edge at `x`, sitting on `baseline`. +/// Paths, never text (session: a glyph run is the dearest thing drawn). +fn glyph( + k: &mut Kit, + parent: NodeId, + family: Family, + index: usize, + (x, baseline): (f64, f64), + ink: &str, +) -> NodeId { + let (t, b) = (baseline - 7.0, baseline); + let d = match (family, index) { + // A valve: an envelope with a grid. + (Family::Circuit, 0) => format!( + "M {:.1} {b:.1} L {:.1} {:.1} A 3 3 0 0 1 {:.1} {:.1} L {:.1} {b:.1} Z M {:.1} {:.1} L {:.1} {:.1}", + x + 1.5, + x + 1.5, + t + 3.0, + x + 7.5, + t + 3.0, + x + 7.5, + x + 3.0, + t + 4.0, + x + 6.0, + t + 4.0 + ), + // Tape and reels: two circles. + (Family::Circuit, 1) | (Family::Delay, 1) => format!( + "M {:.1} {:.1} A 2 2 0 1 0 {:.1} {:.1} A 2 2 0 1 0 {:.1} {:.1} M {:.1} {:.1} A 2 2 0 1 0 {:.1} {:.1} A 2 2 0 1 0 {:.1} {:.1}", + x + 0.5, + t + 3.5, + x + 4.5, + t + 3.5, + x + 0.5, + t + 3.5, + x + 4.5, + t + 3.5, + x + 8.5, + t + 3.5, + x + 4.5, + t + 3.5 + ), + // A core: a box with its windings. + (Family::Circuit, 2) => format!( + "M {:.1} {:.1} H {:.1} V {b:.1} H {:.1} Z M {:.1} {:.1} H {:.1} M {:.1} {:.1} H {:.1}", + x + 1.0, + t + 1.0, + x + 8.0, + x + 1.0, + x + 1.0, + t + 3.5, + x + 8.0, + x + 1.0, + t + 5.5, + x + 8.0 + ), + // Solid state: an op-amp. + (Family::Circuit, 3) => format!( + "M {:.1} {:.1} L {:.1} {:.1} L {:.1} {b:.1} Z", + x + 1.0, + t + 0.5, + x + 8.5, + t + 4.0, + x + 1.0 + ), + // Steps: a staircase (also the rhythmic delay's grid). + (Family::Circuit, _) | (Family::Delay, 3) => format!( + "M {:.1} {b:.1} V {:.1} H {:.1} V {:.1} H {:.1} V {:.1} H {:.1}", + x + 0.5, + t + 5.0, + x + 3.0, + t + 3.0, + x + 5.5, + t + 1.0, + x + 8.5 + ), + // Digital: a square wave. + (Family::Delay, 0) => format!( + "M {:.1} {b:.1} V {:.1} H {:.1} V {b:.1} H {:.1} V {:.1} H {:.1}", + x + 0.5, + t + 1.0, + x + 3.0, + x + 5.5, + t + 1.0, + x + 8.5 + ), + // Analog: a chip on its legs. + (Family::Delay, 2) => format!( + "M {:.1} {:.1} H {:.1} V {:.1} H {:.1} Z M {:.1} {:.1} V {b:.1} M {:.1} {:.1} V {b:.1} M {:.1} {:.1} V {b:.1}", + x + 1.0, + t + 1.0, + x + 8.0, + t + 5.0, + x + 1.0, + x + 2.5, + t + 5.0, + x + 4.5, + t + 5.0, + x + 6.5, + t + 5.0 + ), + // Pitch: rising repeats. + (Family::Delay, 4) => format!( + "M {:.1} {b:.1} V {:.1} M {:.1} {:.1} V {:.1} M {:.1} {:.1} V {:.1}", + x + 1.0, + t + 4.0, + x + 4.5, + b - 1.5, + t + 2.0, + x + 8.0, + b - 3.0, + t + ), + // Special: a reversed swell. + (Family::Delay, _) => format!( + "M {:.1} {b:.1} L {:.1} {b:.1} L {:.1} {t:.1} Z", + x + 0.5, + x + 8.5, + x + 8.5 + ), + // A room: a box. + (Family::Room, 0) => format!( + "M {:.1} {:.1} H {:.1} V {b:.1} H {:.1} Z", + x + 1.0, + t + 1.5, + x + 8.0, + x + 1.0 + ), + // A hall: an arch. + (Family::Room, 1) => format!( + "M {:.1} {b:.1} V {:.1} A 3.5 3.5 0 0 1 {:.1} {:.1} V {b:.1}", + x + 1.0, + t + 3.5, + x + 8.0, + t + 3.5 + ), + // A plate: a slanted sheet. + (Family::Room, 2) => format!( + "M {:.1} {b:.1} L {:.1} {:.1} H {:.1} L {:.1} {b:.1} Z", + x + 0.5, + x + 2.5, + t + 1.0, + x + 8.5, + x + 6.5 + ), + // A spring: a coil. + (Family::Room, 3) => format!( + "M {:.1} {:.1} L {:.1} {:.1} L {:.1} {:.1} L {:.1} {:.1} L {:.1} {:.1} L {:.1} {:.1}", + x + 0.5, + t + 3.5, + x + 2.0, + t + 0.5, + x + 3.5, + b, + x + 5.0, + t + 0.5, + x + 6.5, + b, + x + 8.5, + t + 3.5 + ), + // Bloom: a swell that opens. + (Family::Room, _) => format!( + "M {:.1} {b:.1} Q {:.1} {:.1} {:.1} {:.1} Q {:.1} {:.1} {:.1} {b:.1}", + x + 0.5, + x + 2.5, + t, + x + 4.5, + t + 1.0, + x + 6.5, + t + 2.0, + x + 8.5 + ), + }; + s::line(&mut k.b(), parent, "glyph", (1.0, 1.0), d, ink, 1.1) +} + +/// One chip per family, the current one lit and underlined in the unit's +/// colour, the machine's name beside them. +fn selector(k: &mut Kit, p: NodeId, ctx: &BuildCtx, which: Which, lane: Rect4) { + let ns = which.ns(); + let (family, names, key, tint): (Family, &[&str], &str, &str) = match which { + Which::Sat => (Family::Circuit, &CIRCUITS, "circuit", "#facc15"), + Which::Delay => (Family::Delay, &DELAY_FAMILIES, "family", s::DELAY_INK), + _ => (Family::Room, &ROOM_FAMILIES, "family", s::REVERB_INK), + }; + let baseline = lane.1 + lane.3 - 4.0; + let count = names.len() as u32; + for (i, _) in names.iter().enumerate() { + let x = lane.0 + 2.0 + i as f64 * s::CHIP; + let chip = s::frame(&mut k.b(), p, "chip", (x - 1.0, lane.1, s::CHIP, lane.3)); + let ink = glyph(k, p, family, i, (x, baseline), s::TEXT); + let under = s::fill( + &mut k.b(), + p, + "current", + (x - 1.0, lane.1 + lane.3 - 1.75, 10.0, 1.5), + tint, + ); + let c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count, + }, + }) + .bound(Binding::param(format!("{ns}/{key}"))) + .slot(SlotRole::Led, ink.0) + .slot(SlotRole::Led, under.0); + k.controls.insert(chip, c); + } + let chips_w = names.len() as f64 * s::CHIP + 2.0; + let current = ctx.value(&format!("{ns}/{key}"), 0.0).round().max(0.0) as usize; + let name = names.get(current).copied().unwrap_or(""); + let nw = s::text_width(name, 6.5); + if chips_w + nw + 4.0 <= lane.2 { + s::text( + &mut k.b(), + p, + "machine", + name, + (lane.0 + chips_w, baseline, lane.2 - chips_w), + 6.5, + s::TEXT_DIM, + TextAlign::Right, + ); + } +} + +// ── the rail (Minimal) ─────────────────────────────────────────────────── + +/// A rail's row: the same row at the same height, one live indicator in +/// it instead of a panel. +fn minimal(k: &mut Kit, parent: NodeId, ctx: &BuildCtx, which: Which, row: Rect4, bands: usize) { + let ns = which.ns(); + s::fill(&mut k.b(), parent, "rail ground", row, &alpha(s::WELL, 0.5)); + let inner = inset(row, 2.0); + let at = body_of(row); + if at.1 > inner.1 { + let head = split_top(inner, s::HEAD).0; + let name_w = s::text_width(which.name(), 7.0); + if name_w <= head.2 { + s::text( + &mut k.b(), + parent, + "unit name", + which.name(), + (head.0, head.1 + 7.0, head.2), + 7.0, + s::TEXT_FAINT, + TextAlign::Left, + ); + } + } + let (w, h) = (at.2, at.3); + if w <= 0.0 || h <= 0.0 { + return; + } + let panel = s::clip(&mut k.b(), parent, which.name(), at); + let local = (0.0, 0.0, w, h); + match which { + wh if wh.is_eq() => eq(k, panel, ctx, which, local, Tier::Minimal, bands), + wh if wh.is_comp() || wh == Which::Gate => { + let (ink, feed) = if wh == Which::Gate { + (s::GATE_INK, "in") + } else { + (s::THRESHOLD, "gr") + }; + let line = s::line( + &mut k.b(), + panel, + "threshold", + (w, h), + String::new(), + &alpha(ink, 0.7), + 1.0, + ); + let thr = format!("{ns}/threshold"); + k.derive(line, move |v, n| { + let y = comp_y(get(v, &thr, -18.0), h); + n.fill_geometry = vec![s::seg((0.0, y), (w, y))]; + }); + if wh == Which::Gate { + // The level, up from the floor. + bar( + k, + panel, + &format!("{ns}/{feed}"), + (2.0, 0.0, w - 4.0, h), + &alpha(s::COMP_GREY, 0.6), + (-60.0, 0.0), + false, + ); + } else { + // What it takes off now, hanging from the top. + bar( + k, + panel, + &format!("{ns}/{feed}"), + (2.0, 0.0, w - 4.0, h), + s::REDUCTION_EDGE, + (0.0, -60.0), + true, + ); + } + } + Which::Sat => glow(k, panel, ns, local), + Which::DeEss => bar( + k, + panel, + &format!("{ns}/gr"), + (2.0, 0.0, w - 4.0, h), + s::DEESS_INK, + (0.0, -12.0), + true, + ), + Which::Delay => delay(k, panel, ctx, which, local, Tier::Minimal), + _ => reverb(k, panel, ctx, which, local, Tier::Minimal), + } + // The bypass, over the row. + scrim( + k, + parent, + which, + row, + (row.0, row.1, row.2, s::HEAD.min(row.3)), + ); +} + +/// A live bar: a gauge on a level or reduction feed, `(floor, ceiling)` dB, +/// growing up from the floor — or down from the top when `hang`. +fn bar( + k: &mut Kit, + p: NodeId, + feed: &str, + at: Rect4, + ink: &str, + (floor, ceiling): (f64, f64), + hang: bool, +) { + let (x, y, w, h) = at; + let mut holder = draw::frame("bar", x, y, w, h); + if hang { + // Turned half round about its centre: its bottom is the top. + holder.transform = Transform { + a: -1.0, + b: 0.0, + tx: x + w, + c: 0.0, + d: -1.0, + ty: y + h, + }; + } + let holder = k.b().add(p, holder); + let fill = s::fill(&mut k.b(), holder, "fill", (0.0, 0.0, w, h), ink); + let c = Control::new(ControlKind::Gauge { + sweep_degrees: 0.0, + floor_db: floor, + ceiling_db: ceiling, + }) + .bound(Binding::stream(feed)) + .slot(SlotRole::Fill, fill.0); + k.controls.insert(holder, c); +} diff --git a/crates/frame/src/embed/tone/style.rs b/crates/frame/src/embed/tone/style.rs new file mode 100644 index 00000000..63ec9bd3 --- /dev/null +++ b/crates/frame/src/embed/tone/style.rs @@ -0,0 +1,325 @@ +//! The Tone rack's look: session's palette and inks, and the node helpers +//! the panels are built from. +//! +//! Colours are session's, taken from where session takes them (the FTS +//! dark theme tokens, `comp_ui::comp_graph_svg::colors`, the plugins' inks) +//! so a frame panel and the rack it replaces are the same colours. + +use crate::document::proto::{ + Color, Node, NodeId, NodeKind, Paint, Size, Stroke, StrokeAlign, StrokeCap, StrokeJoin, + TextAlign, TextData, Transform, +}; +use crate::surface::kit::draw; +use crate::surface::widgets::Builder; + +/// FTS dark theme tokens (`daw-ui` `Theme::dark`). +pub const SURFACE: &str = "#09090b"; +pub const SURFACE_RAISED: &str = "#18181b"; +/// `tcp_meter_well`, `tcp_field`: a panel's well. +pub const WELL: &str = "#0a0a0c"; +pub const TEXT: &str = "#e4e4e7"; +pub const TEXT_DIM: &str = "#a1a1aa"; +pub const TEXT_FAINT: &str = "#71717a"; +/// `grid_measure` (white 36/255) and `grid_beat` (white 20/255). +pub const GRID: &str = "#ffffff24"; +pub const GRID_BEAT: &str = "#ffffff14"; + +/// The EQ's total response (the plugin painter's gold). +pub const EQ_INK: &str = "#d4a932"; +/// The compressor display (`comp_graph_svg::colors`). +pub const THRESHOLD: &str = "#ff7878"; +pub const REDUCTION_EDGE: &str = "#ff5050"; +pub const REDUCTION_FILL: &str = "#dc2828"; +pub const COMP_GREY: &str = "#8a8a92"; +/// The gate's green (the door lane's "open"). +pub const GATE_INK: &str = "#34d399"; +/// The saturator: red, hotter, glow. +pub const SAT_INK: &str = "#ff4a3d"; +pub const SAT_HOT: &str = "#ffb347"; +pub const SAT_GLOW: &str = "#ff2e0c"; +/// The de-esser's pink. +pub const DEESS_INK: &str = "#f472b6"; +/// The returns: delay blue, reverb violet. +pub const DELAY_INK: &str = "#3b82f6"; +pub const REVERB_INK: &str = "#8b5cf6"; + +/// A panel header's height, px (7 pt type plus air). +pub const HEAD: f64 = 10.0; +/// Between two panels. +pub const GAP: f64 = 3.0; +/// A phase container's bar. +pub const HEAD_H: f64 = 15.0; +/// An EQ band handle's radius. +pub const HANDLE: f64 = 2.6; +/// The machine selector under a delay, reverb or saturator. +pub const SELECTOR: f64 = 16.0; +/// One chip of it. +pub const CHIP: f64 = 14.0; +/// The gate's door lane. +pub const LANE: f64 = 9.0; +/// The de-esser's band strip. +pub const BAND_STRIP: f64 = 18.0; +/// How faint a blank phase is. +pub const BLANK_ALPHA: f64 = 0.35; + +/// `#rrggbb` with an alpha. +#[must_use] +pub fn alpha(c: &str, a: f64) -> String { + let h = c.trim_start_matches('#'); + let base = &h[..6.min(h.len())]; + let a0 = if h.len() == 8 { + f64::from(u8::from_str_radix(&h[6..8], 16).unwrap_or(255)) / 255.0 + } else { + 1.0 + }; + format!( + "#{base}{:02x}", + ((a0 * a).clamp(0.0, 1.0) * 255.0).round() as u8 + ) +} + +/// An EQ band's hue by frequency (`eq_graph_model::freq_to_color`: red → +/// violet across 20 Hz–20 kHz, HSL s 0.68 l 0.58). +#[must_use] +pub fn band_color(hz: f64) -> Color { + let lo = 20.0_f64.log10(); + let hi = 20_000.0_f64.log10(); + let t = ((hz.max(1.0).log10() - lo) / (hi - lo)).clamp(0.0, 1.0); + hsl(t * 290.0, 0.68, 0.58) +} + +fn hsl(h: f64, s: f64, l: f64) -> Color { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = h.rem_euclid(360.0) / 60.0; + let x = c * (1.0 - (hp % 2.0 - 1.0).abs()); + let (r, g, b) = match hp as i32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + // Quantised like the plugin's `#rrggbb`. + let q = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) / 255.0; + Color { + r: q(r), + g: q(g), + b: q(b), + a: 1.0, + } +} + +/// A colour's `#rrggbbaa`. +#[must_use] +pub fn to_hex(c: Color) -> String { + let b = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}{:02x}", b(c.r), b(c.g), b(c.b), b(c.a)) +} + +// ── nodes ──────────────────────────────────────────────────────────────── + +/// A frame at (`x`, `y`), `w × h`, parent-relative. +pub fn frame( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), +) -> NodeId { + b.add(parent, draw::frame(name, x, y, w.max(0.0), h.max(0.0))) +} + +/// A clipping frame. +pub fn clip( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), +) -> NodeId { + let mut n = draw::frame(name, x, y, w.max(0.0), h.max(0.0)); + n.clips_content = true; + b.add(parent, n) +} + +/// A filled rectangle. +pub fn fill( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + color: &str, +) -> NodeId { + b.add( + parent, + draw::rect(name, x, y, w.max(0.0), h.max(0.0), draw::solid(color)), + ) +} + +/// A stroked path (SVG data in the parent's box space). +pub fn line( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (w, h): (f64, f64), + d: String, + color: &str, + width: f64, +) -> NodeId { + let mut n = Node::new(NodeKind::Vector, name); + n.size = Size { + width: w, + height: h, + }; + n.fill_geometry = vec![d]; + n.stroke = Stroke { + paints: vec![draw::solid(color)], + weight: width, + cap: StrokeCap::Round, + join: StrokeJoin::Round, + align: StrokeAlign::Center, + ..Stroke::default() + }; + b.add(parent, n) +} + +/// A filled path. +pub fn shape( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (w, h): (f64, f64), + d: String, + color: &str, +) -> NodeId { + b.add( + parent, + draw::path(name, 0.0, 0.0, w, h, d, draw::solid(color)), + ) +} + +/// An empty vector for a `Trace` (stroked) slot. +pub fn trace( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (w, h): (f64, f64), + color: &str, + width: f64, + dash: bool, +) -> NodeId { + let mut n = Node::new(NodeKind::Vector, name); + n.size = Size { + width: w, + height: h, + }; + n.stroke = Stroke { + paints: vec![draw::solid(color)], + weight: width, + cap: StrokeCap::Round, + join: StrokeJoin::Round, + align: StrokeAlign::Center, + dash_pattern: if dash { vec![2.5, 2.0] } else { Vec::new() }, + ..Stroke::default() + }; + b.add(parent, n) +} + +/// An empty vector for an `Area` (filled) slot. +pub fn area( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (w, h): (f64, f64), + color: &str, +) -> NodeId { + let mut n = Node::new(NodeKind::Vector, name); + n.size = Size { + width: w, + height: h, + }; + n.fills = vec![draw::solid(color)]; + b.add(parent, n) +} + +/// A filled circle. +pub fn dot( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (cx, cy): (f64, f64), + r: f64, + color: &str, +) -> NodeId { + b.add(parent, draw::circle(name, cx, cy, r, draw::solid(color))) +} + +/// A line of text with its baseline at `baseline`, left at `x`, in a box +/// `w` wide (aligned within it). +#[allow(clippy::too_many_arguments)] +pub fn text( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + s: &str, + (x, baseline, w): (f64, f64, f64), + size: f64, + color: &str, + align: TextAlign, +) -> NodeId { + // The painter sets the first baseline at box top + 1 + ascent (≈ 0.97 + // of the size for Inter). + let top = baseline - size * 0.97 - 1.0; + let mut n = Node::new( + NodeKind::Text(TextData { + characters: s.to_string(), + font_size: size, + align, + ..TextData::default() + }), + name, + ); + n.transform = Transform::translate(x, top); + n.size = Size { + width: w.max(1.0), + height: (size * 1.3).ceil(), + }; + n.fills = vec![Paint::solid(draw::hex(color))]; + b.add(parent, n) +} + +/// The rough width of `s` at `size` (Inter): for fitting labels at build +/// time, where the font is not at hand. +#[must_use] +pub fn text_width(s: &str, size: f64) -> f64 { + s.chars() + .map(|c| match c { + 'i' | 'l' | '.' | ',' | ':' | '·' | '\'' | '|' | ' ' => 0.3, + 'I' | '1' | 'j' | 't' | 'f' | 'r' => 0.42, + 'M' | 'W' | 'm' | 'w' => 0.85, + c if c.is_uppercase() => 0.68, + c if c.is_ascii_digit() => 0.6, + _ => 0.56, + }) + .sum::() + * size +} + +/// `M x0 y0 L x1 y1`. +#[must_use] +pub fn seg((x0, y0): (f64, f64), (x1, y1): (f64, f64)) -> String { + format!("M {x0:.2} {y0:.2} L {x1:.2} {y1:.2}") +} + +/// A polyline. +#[must_use] +pub fn poly(points: &[(f64, f64)]) -> String { + let mut d = String::new(); + for (i, (x, y)) in points.iter().enumerate() { + d.push_str(&format!( + "{} {x:.2} {y:.2} ", + if i == 0 { 'M' } else { 'L' } + )); + } + d.trim_end().to_string() +} diff --git a/crates/frame/src/feed/dsp/envelope.rs b/crates/frame/src/feed/dsp/envelope.rs new file mode 100644 index 00000000..438d6ac3 --- /dev/null +++ b/crates/frame/src/feed/dsp/envelope.rs @@ -0,0 +1,113 @@ +//! [`EnvelopeTap`] → [`EnvelopeReader`]: per-voice envelope playheads +//! (stage, position in the stage, level). Each voice is one `AtomicU64`, +//! so a read never sees half of an update. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::feed::proto::{FeedInfo, FeedKind, FeedOptions, VoiceState}; + +use crate::feed::dsp::FeedSource; + +const IDLE: u64 = 0xFFFF; + +#[inline] +fn pack(stage: Option, position: f32, level: f32) -> u64 { + let s = stage.map_or(IDLE, |s| u64::from(s.min(0xFFFE))); + let p = (position.clamp(0.0, 1.0) * 65535.0).round() as u64; + (s << 48) | (p << 32) | u64::from(level.to_bits()) +} + +fn unpack(bits: u64) -> VoiceState { + let s = bits >> 48; + VoiceState { + stage: (s != IDLE).then_some(s as usize), + position: ((bits >> 32) & 0xFFFF) as f64 / 65535.0, + level: f64::from(f32::from_bits(bits as u32)), + } +} + +/// The audio-thread end. +pub struct EnvelopeTap { + voices: Arc<[AtomicU64]>, +} + +/// The control-thread end. +pub struct EnvelopeReader { + voices: Arc<[AtomicU64]>, + shown: usize, +} + +/// Playheads for `voices` voices, all idle. Allocates here; the tap never +/// does. +#[must_use] +pub fn envelope(voices: usize) -> (EnvelopeTap, EnvelopeReader) { + let v: Arc<[AtomicU64]> = (0..voices) + .map(|_| AtomicU64::new(pack(None, 0.0, 0.0))) + .collect(); + ( + EnvelopeTap { voices: v.clone() }, + EnvelopeReader { + voices: v, + shown: voices, + }, + ) +} + +impl EnvelopeTap { + #[must_use] + pub fn voices(&self) -> usize { + self.voices.len() + } + + /// Voice `voice` is in `stage` (an index into the feed's stages), + /// `position` (0..1) through it, at `level` (0..1). Wait-free, no + /// allocation; out-of-range voices are ignored. + #[inline] + pub fn set(&self, voice: usize, stage: u16, position: f32, level: f32) { + if let Some(v) = self.voices.get(voice) { + v.store(pack(Some(stage), position, level), Ordering::Relaxed); + } + } + + /// Voice `voice` finished (or was stolen). + #[inline] + pub fn idle(&self, voice: usize) { + if let Some(v) = self.voices.get(voice) { + v.store(pack(None, 0.0, 0.0), Ordering::Relaxed); + } + } +} + +impl EnvelopeReader { + /// Report only the first `voices` voices. + pub fn set_voices(&mut self, voices: usize) { + self.shown = voices.min(self.voices.len()); + } + + /// Every reported voice's state. + pub fn states(&self) -> impl Iterator + '_ { + self.voices + .iter() + .take(self.shown) + .map(|v| unpack(v.load(Ordering::Relaxed))) + } + + /// A frame's `values`: `[stage, position, level]` per voice. + pub fn read_into(&self, out: &mut Vec) { + out.clear(); + out.extend(self.states().flat_map(|s| s.encode())); + } +} + +impl FeedSource for EnvelopeReader { + fn configure(&mut self, info: &FeedInfo, _options: &FeedOptions) { + if let FeedKind::Envelope { voices, .. } = info.kind { + self.set_voices(usize::from(voices)); + } + } + + fn read(&mut self, _dt: f64, out: &mut Vec) { + self.read_into(out); + } +} diff --git a/crates/frame/src/feed/dsp/fft.rs b/crates/frame/src/feed/dsp/fft.rs new file mode 100644 index 00000000..b700f39e --- /dev/null +++ b/crates/frame/src/feed/dsp/fft.rs @@ -0,0 +1,82 @@ +//! A small in-place radix-2 complex FFT (no dependency). Plans allocate +//! their tables once; [`Fft::forward`] never allocates. + +use std::f64::consts::PI; + +/// A planned FFT of one power-of-two size. +pub struct Fft { + n: usize, + /// `e^{-2πik/n}` for `k < n/2`. + twiddles: Box<[(f64, f64)]>, + /// Bit-reversed index of each position. + rev: Box<[u32]>, +} + +impl Fft { + /// # Panics + /// When `n` is not a power of two ≥ 2. + #[must_use] + pub fn new(n: usize) -> Self { + assert!( + n >= 2 && n.is_power_of_two(), + "FFT size must be a power of two ≥ 2, got {n}" + ); + let bits = n.trailing_zeros(); + let twiddles = (0..n / 2) + .map(|k| { + let a = -2.0 * PI * k as f64 / n as f64; + (a.cos(), a.sin()) + }) + .collect(); + let rev = (0..n as u32) + .map(|i| i.reverse_bits() >> (32 - bits)) + .collect(); + Self { n, twiddles, rev } + } + + #[must_use] + pub fn len(&self) -> usize { + self.n + } + + #[must_use] + pub fn is_empty(&self) -> bool { + false + } + + /// Forward transform of `re + i·im` in place (unnormalized). + /// + /// # Panics + /// When the buffers aren't the planned length. + pub fn forward(&self, re: &mut [f64], im: &mut [f64]) { + let n = self.n; + assert!( + re.len() == n && im.len() == n, + "FFT buffers must be {n} long" + ); + for i in 0..n { + let j = self.rev[i] as usize; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + let mut size = 2; + while size <= n { + let half = size / 2; + let step = n / size; + for start in (0..n).step_by(size) { + for k in 0..half { + let (wr, wi) = self.twiddles[k * step]; + let (a, b) = (start + k, start + k + half); + let (xr, xi) = (re[b] * wr - im[b] * wi, re[b] * wi + im[b] * wr); + re[b] = re[a] - xr; + im[b] = im[a] - xi; + re[a] += xr; + im[a] += xi; + } + } + size *= 2; + } + } +} diff --git a/crates/frame/src/feed/dsp/history.rs b/crates/frame/src/feed/dsp/history.rs new file mode 100644 index 00000000..e05b729e --- /dev/null +++ b/crates/frame/src/feed/dsp/history.rs @@ -0,0 +1,188 @@ +//! [`HistoryTap`] → [`HistoryReader`]: a scrolling trace of a scalar (gain +//! reduction, input level) at a requested point rate. The audio thread +//! folds each block's value into the current point and, every +//! `sample_rate / rate_hz` frames, pushes the point; the control thread +//! snapshots the last `len` points, oldest first. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::feed::proto::{FeedInfo, FeedKind, FeedOptions}; + +use crate::feed::dsp::FeedSource; +use crate::feed::dsp::ring::{RingReader, RingWriter, ring}; + +/// How the values inside one point (or between two reads) combine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Aggregate { + /// The loudest (levels). + Max, + /// The deepest (gain reduction, ≤ 0). + Min, + /// The frame-weighted mean. ([`crate::feed::dsp::ScalarTap`] treats it as `Last`.) + Mean, + /// The latest. + Last, +} + +impl Aggregate { + #[inline] + pub(crate) fn fold(self, acc: f64, weight: f64, v: f64, w: f64) -> f64 { + if weight <= 0.0 { + return v; + } + match self { + Self::Max => acc.max(v), + Self::Min => acc.min(v), + Self::Mean => (acc * weight + v * w) / (weight + w), + Self::Last => v, + } + } +} + +/// The audio-thread end. +pub struct HistoryTap { + ring: RingWriter, + frames_per_point: Arc, + agg: Aggregate, + acc: f64, + /// Frames folded into `acc`. + frames: u32, +} + +/// The control-thread end. +pub struct HistoryReader { + ring: RingReader, + frames_per_point: Arc, + sample_rate: f64, + /// What a point reads before any data (0 dB of reduction, a floor). + rest: f64, + len: usize, + scratch: Vec, +} + +/// A history of up to `capacity` points at `rate_hz`, for a signal at +/// `sample_rate`, folding with `agg`; points before any data read `rest`. +/// Allocates here; the tap never does. +#[must_use] +pub fn history( + capacity: usize, + sample_rate: f64, + rate_hz: f64, + agg: Aggregate, + rest: f64, +) -> (HistoryTap, HistoryReader) { + let (w, r) = ring(capacity); + let fpp = Arc::new(AtomicU32::new(frames_per_point(sample_rate, rate_hz))); + let len = capacity.min(r.capacity()); + ( + HistoryTap { + ring: w, + frames_per_point: fpp.clone(), + agg, + acc: 0.0, + frames: 0, + }, + HistoryReader { + ring: r, + frames_per_point: fpp, + sample_rate, + rest, + len, + scratch: vec![0.0; len], + }, + ) +} + +fn frames_per_point(sample_rate: f64, rate_hz: f64) -> u32 { + let r = if rate_hz.is_finite() && rate_hz > 0.0 { + rate_hz + } else { + 30.0 + }; + (sample_rate / r).round().clamp(1.0, f64::from(u32::MAX)) as u32 +} + +impl HistoryTap { + /// Fold `value`, which held for `frames` samples (usually one block). + /// Pushes a point each time a point's worth of frames is in. Wait-free, + /// no allocation. + #[inline] + pub fn push(&mut self, value: f32, frames: u32) { + let fpp = self.frames_per_point.load(Ordering::Relaxed).max(1); + let v = f64::from(value); + let mut left = frames; + while left > 0 { + let take = left.min(fpp.saturating_sub(self.frames).max(1)); + self.acc = self + .agg + .fold(self.acc, f64::from(self.frames), v, f64::from(take)); + self.frames += take; + left -= take; + if self.frames >= fpp { + self.ring.push(self.acc as f32); + self.frames = 0; + } + } + } + + /// Push one finished point directly. Wait-free, no allocation. + #[inline] + pub fn push_point(&mut self, value: f32) { + self.ring.push(value); + } +} + +impl HistoryReader { + /// Points the ring can hold. + #[must_use] + pub fn capacity(&self) -> usize { + self.ring.capacity() + } + + /// Change the point rate (takes effect from the audio thread's next + /// point). + pub fn set_rate(&self, rate_hz: f64) { + self.frames_per_point.store( + frames_per_point(self.sample_rate, rate_hz), + Ordering::Relaxed, + ); + } + + #[must_use] + pub fn rate_hz(&self) -> f64 { + self.sample_rate / f64::from(self.frames_per_point.load(Ordering::Relaxed).max(1)) + } + + /// The points [`FeedSource::read`] returns. + pub fn set_len(&mut self, len: usize) { + self.len = len.max(1); + } + + /// The last `out.len()` points, oldest first; the front reads `rest` + /// until that many exist (and beyond the ring's capacity). + pub fn snapshot(&mut self, out: &mut [f64]) { + let n = out.len().min(self.ring.capacity()); + self.scratch.resize(n, 0.0); + let got = self.ring.latest(&mut self.scratch); + let pad = out.len() - got; + out[..pad].fill(self.rest); + for (o, s) in out[pad..].iter_mut().zip(&self.scratch[n - got..]) { + *o = f64::from(*s); + } + } +} + +impl FeedSource for HistoryReader { + fn configure(&mut self, info: &FeedInfo, _options: &FeedOptions) { + if let FeedKind::History { len, .. } = info.kind { + self.set_len(len as usize); + } + self.set_rate(info.rate_hz); + } + + fn read(&mut self, _dt: f64, out: &mut Vec) { + out.resize(self.len, self.rest); + self.snapshot(out); + } +} diff --git a/crates/frame/src/feed/dsp/level.rs b/crates/frame/src/feed/dsp/level.rs new file mode 100644 index 00000000..c8cda993 --- /dev/null +++ b/crates/frame/src/feed/dsp/level.rs @@ -0,0 +1,214 @@ +//! Meters: [`LevelTap`] (peak and RMS per channel) and [`ScalarTap`] (one +//! value an engine computes itself — gain reduction, an LFO, CPU). +//! +//! Both hand the control thread "the extreme since the last read", so a +//! short peak between two reads is never lost, and a read with nothing +//! new repeats the previous value rather than dropping to silence. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::feed::proto::{FeedInfo, FeedOptions}; + +use crate::feed::dsp::history::Aggregate; +use crate::feed::dsp::{FeedSource, amp_to_db}; + +/// "Nothing since the last read." +const EMPTY: u32 = u32::MAX; + +struct LevelShared { + /// |peak| since the last read, as f32 bits (`EMPTY` = none). + peak: Box<[AtomicU32]>, + /// The RMS follower's latest value, f32 bits. + rms: Box<[AtomicU32]>, +} + +/// The audio-thread end. +pub struct LevelTap { + shared: Arc, + /// Mean-square follower state per channel. + ms: Box<[f32]>, + /// One-pole coefficient per sample (300 ms). + coeff: f32, +} + +/// The control-thread end. +pub struct LevelReader { + shared: Arc, + last_peak: Box<[f64]>, +} + +/// A level meter over `channels` channels at `sample_rate` (RMS window +/// 300 ms). Allocates here; the tap never does. +#[must_use] +pub fn level(channels: usize, sample_rate: f64) -> (LevelTap, LevelReader) { + let channels = channels.max(1); + let shared = Arc::new(LevelShared { + peak: (0..channels).map(|_| AtomicU32::new(EMPTY)).collect(), + rms: (0..channels).map(|_| AtomicU32::new(0)).collect(), + }); + let coeff = (1.0 - (-1.0 / (0.3 * sample_rate)).exp()) as f32; + ( + LevelTap { + shared: shared.clone(), + ms: vec![0.0; channels].into(), + coeff, + }, + LevelReader { + shared, + last_peak: vec![crate::feed::dsp::FLOOR_DB; channels].into(), + }, + ) +} + +/// Raise `slot` to `v` (a non-negative f32: its bits order like its value). +#[inline] +fn raise(slot: &AtomicU32, v: f32) { + let bits = v.max(0.0).to_bits(); + let _ = slot.try_update(Ordering::AcqRel, Ordering::Relaxed, |cur| { + (cur == EMPTY || bits > cur).then_some(bits) + }); +} + +impl LevelTap { + #[must_use] + pub fn channels(&self) -> usize { + self.ms.len() + } + + /// One channel's block. Wait-free, no allocation. + #[inline] + pub fn push(&mut self, channel: usize, samples: &[f32]) { + let Some(ms) = self.ms.get_mut(channel) else { + return; + }; + let mut peak = 0.0f32; + for &x in samples { + peak = peak.max(x.abs()); + *ms += self.coeff * (x * x - *ms); + } + raise(&self.shared.peak[channel], peak); + self.shared.rms[channel].store(ms.sqrt().to_bits(), Ordering::Relaxed); + } + + /// An interleaved block (`L R L R …`). Wait-free, no allocation. + #[inline] + pub fn push_interleaved(&mut self, samples: &[f32]) { + let ch = self.ms.len(); + for c in 0..ch { + let mut peak = 0.0f32; + let ms = &mut self.ms[c]; + for x in samples.iter().skip(c).step_by(ch) { + peak = peak.max(x.abs()); + *ms += self.coeff * (x * x - *ms); + } + raise(&self.shared.peak[c], peak); + self.shared.rms[c].store(ms.sqrt().to_bits(), Ordering::Relaxed); + } + } +} + +impl LevelReader { + #[must_use] + pub fn channels(&self) -> usize { + self.last_peak.len() + } + + /// Peak dBFS per channel since the last read (the previous reading + /// when nothing arrived). + pub fn peak_db(&mut self, out: &mut [f64]) { + for (c, o) in out.iter_mut().enumerate().take(self.last_peak.len()) { + let bits = self.shared.peak[c].swap(EMPTY, Ordering::AcqRel); + if bits != EMPTY { + self.last_peak[c] = amp_to_db(f64::from(f32::from_bits(bits))); + } + *o = self.last_peak[c]; + } + } + + /// RMS dBFS per channel (300 ms). + pub fn rms_db(&self, out: &mut [f64]) { + for (o, r) in out.iter_mut().zip(self.shared.rms.iter()) { + *o = amp_to_db(f64::from(f32::from_bits(r.load(Ordering::Relaxed)))); + } + } +} + +impl FeedSource for LevelReader { + fn read(&mut self, _dt: f64, out: &mut Vec) { + out.resize(self.channels(), crate::feed::dsp::FLOOR_DB); + self.peak_db(out); + } +} + +/// The audio-thread end of a scalar. +pub struct ScalarTap { + slot: Arc, + agg: Aggregate, +} + +/// The control-thread end of a scalar. +pub struct ScalarReader { + slot: Arc, + last: f64, +} + +/// A scalar the engine computes (gain reduction with [`Aggregate::Min`], +/// a level with `Max`, anything else with `Last`), reading `rest` until +/// the first push. +#[must_use] +pub fn scalar(agg: Aggregate, rest: f64) -> (ScalarTap, ScalarReader) { + let slot = Arc::new(AtomicU32::new(EMPTY)); + ( + ScalarTap { + slot: slot.clone(), + agg, + }, + ScalarReader { slot, last: rest }, + ) +} + +impl ScalarTap { + /// Fold `v` into the value since the last read. Lock-free, no + /// allocation. + #[inline] + pub fn push(&self, v: f32) { + let agg = self.agg; + let _ = self + .slot + .try_update(Ordering::AcqRel, Ordering::Relaxed, |cur| { + if cur == EMPTY { + return Some(v.to_bits()); + } + let c = f32::from_bits(cur); + Some( + match agg { + Aggregate::Max => c.max(v), + Aggregate::Min => c.min(v), + Aggregate::Mean | Aggregate::Last => v, + } + .to_bits(), + ) + }); + } +} + +impl ScalarReader { + /// The value since the last read (the previous one when none arrived). + pub fn take(&mut self) -> f64 { + let bits = self.slot.swap(EMPTY, Ordering::AcqRel); + if bits != EMPTY { + self.last = f64::from(f32::from_bits(bits)); + } + self.last + } +} + +impl FeedSource for ScalarReader { + fn configure(&mut self, _info: &FeedInfo, _options: &FeedOptions) {} + + fn read(&mut self, _dt: f64, out: &mut Vec) { + out.clear(); + out.push(self.take()); + } +} diff --git a/crates/frame/src/feed/dsp/mod.rs b/crates/frame/src/feed/dsp/mod.rs new file mode 100644 index 00000000..f32c0877 --- /dev/null +++ b/crates/frame/src/feed/dsp/mod.rs @@ -0,0 +1,80 @@ +//! Realtime-safe feed producers every engine shares, so every plugin gets +//! the same analyser, trace and meters. +//! +//! Each tap is a pair made once, off the audio thread: the **tap** (audio +//! thread) only pushes — wait-free, no locks, no allocation — and the +//! **reader** (control thread) turns what was pushed into a frame's values +//! at the rate a surface asked for. +//! +//! | tap | reader | feed kind | +//! |---|---|---| +//! | [`SpectrumTap`] | [`SpectrumAnalyzer`] (Hann → FFT → log bins → smoothing → ballistics) | `Spectrum` | +//! | [`HistoryTap`] | [`HistoryReader`] (last `len` points at `rate_hz`) | `History` | +//! | [`LevelTap`] | [`LevelReader`] (peak/RMS dBFS per channel) | `Level` | +//! | [`ScalarTap`] | [`ScalarReader`] (min/max/last since the last read) | `GainReduction`, `Scalar` | +//! | [`ScopeTap`] | [`ScopeReader`] (triggered window) | `Waveform` | +//! | [`EnvelopeTap`] | [`EnvelopeReader`] (stage/position/level per voice) | `Envelope` | +//! +//! Every reader is a [`FeedSource`]; a [`Publisher`] holds one per catalog +//! feed, applies `request_feeds` options, and emits due frames on +//! [`Publisher::poll`]. +//! +//! ``` +//! use frame::feed::dsp::{Aggregate, Publisher, history, scalar, spectrum}; +//! use frame::feed::proto::{FeedInfo, FeedKind, FeedRequest}; +//! +//! let sr = 48_000.0; +//! // Made once, when the block is built: +//! let (mut spec_tap, spec) = spectrum(4096, sr); +//! let (gr_tap, gr) = scalar(Aggregate::Min, 0.0); +//! let (mut trace_tap, trace) = history(1024, sr, 30.0, Aggregate::Min, 0.0); +//! let mut feeds = Publisher::new(); +//! feeds.add(FeedInfo::new("comp/spectrum", "Spectrum", FeedKind::spectrum(64)), spec); +//! feeds.add(FeedInfo::new("comp/gr", "GR", FeedKind::GainReduction), gr); +//! feeds.add(FeedInfo::new("comp/gr_trace", "GR history", FeedKind::gr_history(256)), trace); +//! +//! // Audio thread, per block: +//! let block = [0.0f32; 256]; +//! spec_tap.push(&block); +//! gr_tap.push(-3.5); +//! trace_tap.push(-3.5, block.len() as u32); +//! +//! // Control thread: a surface asks, a timer publishes. +//! feeds.request(&[FeedRequest::new("comp/spectrum").bins(128).smoothing(1.0 / 6.0)]).unwrap(); +//! let mut frames = Vec::new(); +//! feeds.poll(0.0, |f| frames.push(f)); +//! assert_eq!(frames.len(), 3); +//! assert_eq!(frames[0].values.len(), 128); +//! ``` + +pub mod envelope; +pub mod fft; +pub mod history; +pub mod level; +pub mod publish; +pub mod ring; +pub mod scope; +pub mod spectrum; + +pub use envelope::{EnvelopeReader, EnvelopeTap, envelope}; +pub use fft::Fft; +pub use history::{Aggregate, HistoryReader, HistoryTap, history}; +pub use level::{LevelReader, LevelTap, ScalarReader, ScalarTap, level, scalar}; +pub use publish::{FeedSource, Publisher}; +pub use ring::{RingReader, RingWriter, ring}; +pub use scope::{ScopeReader, ScopeTap, scope}; +pub use spectrum::{SpectrumAnalyzer, SpectrumConfig, SpectrumTap, spectrum}; + +/// What silence reads, dBFS. +pub const FLOOR_DB: f64 = -120.0; + +/// Linear amplitude → dBFS, never below [`FLOOR_DB`]. +#[must_use] +#[inline] +pub fn amp_to_db(amp: f64) -> f64 { + if amp > 1e-6 { + (20.0 * amp.log10()).max(FLOOR_DB) + } else { + FLOOR_DB + } +} diff --git a/crates/frame/src/feed/dsp/publish.rs b/crates/frame/src/feed/dsp/publish.rs new file mode 100644 index 00000000..b25055de --- /dev/null +++ b/crates/frame/src/feed/dsp/publish.rs @@ -0,0 +1,129 @@ +//! The control-thread side of an engine's feeds: a [`Publisher`] holds one +//! [`FeedSource`] (a tap's reader) per catalog feed, applies requests +//! (`SurfaceEngine::request_feeds`), and on each [`Publisher::poll`] emits a +//! [`FeedFrame`] for every feed that is due at its rate. + +use crate::feed::proto::{EntryId, FeedFrame, FeedInfo, FeedOptions, FeedRequest}; + +/// Something that produces a feed's frame values on the control thread — +/// every tap's reader half implements it. +pub trait FeedSource: Send { + /// Reshape for a request: `info` is the feed as resolved + /// ([`FeedInfo::resolve`]), `options` what was asked. + fn configure(&mut self, info: &FeedInfo, options: &FeedOptions) { + let _ = (info, options); + } + + /// The current frame's values; `dt` is the time since the last read, + /// seconds. + fn read(&mut self, dt: f64, out: &mut Vec); +} + +/// Any closure `(dt, out)` is a source (derived feeds, test signals). +impl) + Send> FeedSource for F { + fn read(&mut self, dt: f64, out: &mut Vec) { + self(dt, out); + } +} + +struct Entry { + /// As the catalog lists it. + base: FeedInfo, + /// As last requested. + current: FeedInfo, + source: Box, + next_due: f64, + last_read: Option, + buf: Vec, +} + +/// An engine's feeds, published at their rates. +#[derive(Default)] +pub struct Publisher { + entries: Vec, +} + +impl Publisher { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Offer `info`, produced by `source`. A feed with the same id is + /// replaced. + pub fn add(&mut self, info: FeedInfo, source: impl FeedSource + 'static) -> &mut Self { + let mut source: Box = Box::new(source); + source.configure(&info, &FeedOptions::default()); + self.entries.retain(|e| e.base.id != info.id); + self.entries.push(Entry { + current: info.clone(), + base: info, + source, + next_due: f64::NEG_INFINITY, + last_read: None, + buf: Vec::new(), + }); + self + } + + /// The feeds as a catalog lists them (`Catalog::streams`). + #[must_use] + pub fn infos(&self) -> Vec { + self.entries.iter().map(|e| e.base.clone()).collect() + } + + /// Feed `id` as currently shaped. + #[must_use] + pub fn current(&self, id: &EntryId) -> Option<&FeedInfo> { + self.entries + .iter() + .find(|e| &e.base.id == id) + .map(|e| &e.current) + } + + /// Apply requests: each feed is resolved against its catalog entry (not + /// a previous request) and its source reconfigured. + /// + /// # Errors + /// The first unknown id; nothing is applied then. + pub fn request(&mut self, requests: &[FeedRequest]) -> Result, EntryId> { + if let Some(r) = requests + .iter() + .find(|r| !self.entries.iter().any(|e| e.base.id == r.id)) + { + return Err(r.id.clone()); + } + let mut out = Vec::with_capacity(requests.len()); + for r in requests { + if let Some(e) = self.entries.iter_mut().find(|e| e.base.id == r.id) { + e.current = e.base.resolve(&r.options); + e.source.configure(&e.current, &r.options); + e.next_due = f64::NEG_INFINITY; + out.push(e.current.clone()); + } + } + Ok(out) + } + + /// Emit a frame for every feed due at `now` (seconds, any monotonic + /// clock). Call it from a control-thread timer at least as fast as the + /// fastest feed. + pub fn poll(&mut self, now: f64, mut emit: impl FnMut(FeedFrame)) { + for e in &mut self.entries { + if now + 1e-4 < e.next_due { + continue; + } + let period = 1.0 / e.current.rate_hz.max(1e-3); + // Keep the cadence, but never try to catch up a backlog. + let next = e.next_due + period; + e.next_due = if next > now { next } else { now + period }; + let dt = e.last_read.map_or(0.0, |t| (now - t).max(0.0)); + e.last_read = Some(now); + e.source.read(dt, &mut e.buf); + emit(FeedFrame { + id: e.current.id.clone(), + values: e.buf.clone(), + }); + } + } +} diff --git a/crates/frame/src/feed/dsp/ring.rs b/crates/frame/src/feed/dsp/ring.rs new file mode 100644 index 00000000..1cfd87ba --- /dev/null +++ b/crates/frame/src/feed/dsp/ring.rs @@ -0,0 +1,130 @@ +//! The one audio → control handoff every tap uses: a lock-free, +//! single-producer, overwriting ring of `f32`. +//! +//! The audio thread [`RingWriter::push`]es and never waits: when the ring +//! is full the oldest samples go. The control thread copies the latest `n` +//! out with [`RingReader::latest`]. Samples are stored as `AtomicU32` bits, +//! so a reader racing the writer sees old-or-new values, never torn ones. +//! Like a seqlock, the writer announces how far it is about to write +//! (`claimed`) before it writes and publishes (`written`) after, so a +//! reader knows which part of its copy the writer may have lapped: it +//! retries, and at worst returns only the part that is certainly intact. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering, fence}; + +struct Shared { + data: Box<[AtomicU32]>, + mask: usize, + /// Positions the writer may be writing up to (announced before). + claimed: AtomicU64, + /// Samples written since creation (the next write's position). + written: AtomicU64, +} + +/// The audio-thread end. Not `Clone`: one producer. +pub struct RingWriter { + shared: Arc, + /// Local copy of `written` (only this end writes it). + pos: u64, +} + +/// The control-thread end. Cloneable: any number of readers. +#[derive(Clone)] +pub struct RingReader { + shared: Arc, +} + +/// A ring holding at least `capacity` samples (rounded up to a power of +/// two). Allocates here, never afterwards. +#[must_use] +pub fn ring(capacity: usize) -> (RingWriter, RingReader) { + let cap = capacity.max(2).next_power_of_two(); + let shared = Arc::new(Shared { + data: (0..cap).map(|_| AtomicU32::new(0)).collect(), + mask: cap - 1, + claimed: AtomicU64::new(0), + written: AtomicU64::new(0), + }); + ( + RingWriter { + shared: shared.clone(), + pos: 0, + }, + RingReader { shared }, + ) +} + +impl RingWriter { + /// Append one sample. Wait-free, no allocation. + #[inline] + pub fn push(&mut self, x: f32) { + let s = &*self.shared; + s.claimed.store(self.pos + 1, Ordering::Relaxed); + fence(Ordering::Release); + s.data[(self.pos as usize) & s.mask].store(x.to_bits(), Ordering::Relaxed); + self.pos += 1; + s.written.store(self.pos, Ordering::Release); + } + + /// Append a block. Wait-free, no allocation. + #[inline] + pub fn push_slice(&mut self, xs: &[f32]) { + let s = &*self.shared; + s.claimed + .store(self.pos + xs.len() as u64, Ordering::Relaxed); + fence(Ordering::Release); + for &x in xs { + s.data[(self.pos as usize) & s.mask].store(x.to_bits(), Ordering::Relaxed); + self.pos += 1; + } + s.written.store(self.pos, Ordering::Release); + } + + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.data.len() + } +} + +impl RingReader { + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.data.len() + } + + /// Samples written since creation. + #[must_use] + pub fn written(&self) -> u64 { + self.shared.written.load(Ordering::Acquire) + } + + /// Copy the latest `out.len()` samples into `out`, oldest first. + /// Returns how many were available (≤ `out.len()` and ≤ capacity); + /// when fewer, they fill the *end* of `out` and the front is left as is. + pub fn latest(&self, out: &mut [f32]) -> usize { + let s = &*self.shared; + let cap = s.data.len() as u64; + let mut intact = 0; + for _ in 0..4 { + let end = s.written.load(Ordering::Acquire); + let n = (out.len() as u64).min(end).min(cap); + let start = end - n; + let dst_at = out.len() - n as usize; + for (i, d) in out[dst_at..].iter_mut().enumerate() { + *d = f32::from_bits( + s.data[((start + i as u64) as usize) & s.mask].load(Ordering::Relaxed), + ); + } + // Which positions might the writer have overwritten meanwhile? + fence(Ordering::Acquire); + let lapped_below = s.claimed.load(Ordering::Relaxed).saturating_sub(cap); + if start >= lapped_below { + return n as usize; + } + intact = end.saturating_sub(lapped_below).min(n) as usize; + } + // Under a writer this fast, return only the certainly-intact tail. + intact + } +} diff --git a/crates/frame/src/feed/dsp/scope.rs b/crates/frame/src/feed/dsp/scope.rs new file mode 100644 index 00000000..10b130cc --- /dev/null +++ b/crates/frame/src/feed/dsp/scope.rs @@ -0,0 +1,96 @@ +//! [`ScopeTap`] → [`ScopeReader`]: a triggered waveform. The audio thread +//! pushes samples; the control thread finds the latest trigger whose +//! window is complete and copies `len` samples from it, so a periodic wave +//! stands still. + +use crate::feed::proto::{FeedInfo, FeedKind, FeedOptions, Trigger}; + +use crate::feed::dsp::FeedSource; +use crate::feed::dsp::ring::{RingReader, RingWriter, ring}; + +/// The audio-thread end. +pub struct ScopeTap { + ring: RingWriter, +} + +/// The control-thread end. +pub struct ScopeReader { + ring: RingReader, + len: usize, + trigger: Trigger, + scratch: Vec, +} + +/// A scope keeping `capacity` samples (show up to `capacity / 2` with a +/// trigger). Allocates here; the tap never does. +#[must_use] +pub fn scope(capacity: usize) -> (ScopeTap, ScopeReader) { + let (w, r) = ring(capacity); + let len = (r.capacity() / 4).max(1); + ( + ScopeTap { ring: w }, + ScopeReader { + ring: r, + len, + trigger: Trigger::Rising, + scratch: Vec::new(), + }, + ) +} + +impl ScopeTap { + /// Push a block. Wait-free, no allocation. + #[inline] + pub fn push(&mut self, samples: &[f32]) { + self.ring.push_slice(samples); + } +} + +impl ScopeReader { + pub fn set_len(&mut self, len: usize) { + self.len = len.clamp(1, self.ring.capacity()); + } + + pub fn set_trigger(&mut self, trigger: Trigger) { + self.trigger = trigger; + } + + /// `len` samples starting at the latest trigger with a full window + /// after it (the latest `len` when none, or free-running). + pub fn snapshot(&mut self, out: &mut Vec) { + let len = self.len; + let look = (len * 2).min(self.ring.capacity()); + self.scratch.clear(); + self.scratch.resize(look, 0.0); + let got = self.ring.latest(&mut self.scratch); + let data = &self.scratch[look - got..]; + let free = data.len().saturating_sub(len); + let hit = |i: usize| { + let (a, b) = (data[i - 1], data[i]); + match self.trigger { + Trigger::Rising => a < 0.0 && b >= 0.0, + Trigger::Falling => a >= 0.0 && b < 0.0, + Trigger::Free => false, + } + }; + let start = (1..=free).rev().find(|&i| hit(i)).unwrap_or(free); + out.clear(); + out.extend(data[start..].iter().take(len).map(|&x| f64::from(x))); + out.resize(len, 0.0); + } +} + +impl FeedSource for ScopeReader { + fn configure(&mut self, info: &FeedInfo, options: &FeedOptions) { + if let FeedKind::Waveform { len } = info.kind { + self.set_len(len as usize); + } + if let Some(t) = options.trigger { + self.trigger = t; + } + } + + fn read(&mut self, _dt: f64, out: &mut Vec) { + self.snapshot(out); + } +} diff --git a/crates/frame/src/feed/dsp/spectrum.rs b/crates/frame/src/feed/dsp/spectrum.rs new file mode 100644 index 00000000..07c727b8 --- /dev/null +++ b/crates/frame/src/feed/dsp/spectrum.rs @@ -0,0 +1,277 @@ +//! [`SpectrumTap`] → [`SpectrumAnalyzer`]: the analyser every plugin +//! shares. The audio thread only pushes samples; the control thread +//! windows (Hann) the latest `fft_size` of them, transforms, folds the FFT +//! bins into log-spaced display bins, smooths across frequency and applies +//! ballistics (instant attack, decay in dB/s). + +use crate::feed::proto::{FeedKind, FeedOptions}; + +use crate::feed::dsp::fft::Fft; +use crate::feed::dsp::ring::{RingReader, RingWriter, ring}; +use crate::feed::dsp::{FeedSource, amp_to_db}; + +/// How a spectrum is shaped for display. +#[derive(Debug, Clone, PartialEq)] +pub struct SpectrumConfig { + /// Log-spaced display bins. + pub bins: usize, + pub f_min: f64, + pub f_max: f64, + /// Values clamp here (dBFS). + pub floor_db: f64, + /// How fast a bin falls after a peak. + pub decay_db_per_s: f64, + /// Frequency smoothing width in octaves (0 = none; `1/3` = third-octave). + pub smoothing_octaves: f64, +} + +impl Default for SpectrumConfig { + fn default() -> Self { + Self { + bins: 64, + f_min: 20.0, + f_max: 20_000.0, + floor_db: -90.0, + decay_db_per_s: 30.0, + smoothing_octaves: 0.0, + } + } +} + +impl SpectrumConfig { + /// The config a feed of `kind` asks for (`None` unless a spectrum), + /// with `options` applied (bins, smoothing). + #[must_use] + pub fn for_feed(kind: &FeedKind, options: &FeedOptions) -> Option { + match kind.with_options(options) { + FeedKind::Spectrum { + bins, + f_min, + f_max, + floor_db, + } => Some(Self { + bins: usize::from(bins.max(1)), + f_min, + f_max, + floor_db, + smoothing_octaves: options.smoothing.unwrap_or(0.0).max(0.0), + ..Self::default() + }), + _ => None, + } + } +} + +/// The audio-thread end: push samples, nothing else. +pub struct SpectrumTap { + ring: RingWriter, +} + +impl SpectrumTap { + /// Push a mono block. Wait-free, no allocation. + #[inline] + pub fn push(&mut self, samples: &[f32]) { + self.ring.push_slice(samples); + } + + /// Push a stereo block as its mid (L+R)/2. Wait-free, no allocation. + #[inline] + pub fn push_stereo(&mut self, left: &[f32], right: &[f32]) { + for (l, r) in left.iter().zip(right) { + self.ring.push(0.5 * (l + r)); + } + } +} + +/// The control-thread end: turns the latest samples into display bins. +pub struct SpectrumAnalyzer { + reader: RingReader, + fft: Fft, + sample_rate: f64, + window: Box<[f64]>, + /// `Σ window`: a full-scale sine's bin reads `Σw / 2`. + window_sum: f64, + samples: Vec, + re: Vec, + im: Vec, + /// Linear amplitude per FFT bin (`0..=n/2`). + amp: Vec, + cfg: SpectrumConfig, + /// Display bin edges, Hz (`bins + 1`). + edges: Vec, + /// This read's power per display bin. + power: Vec, + prefix: Vec, + /// What's on screen, dB (ballistics state). + held: Vec, + primed: bool, +} + +/// An analyser over `fft_size` samples (a power of two; 2048–8192 suits +/// 44.1–96 kHz) at `sample_rate`. Allocates here; the tap never does. +/// +/// # Panics +/// When `fft_size` isn't a power of two ≥ 2. +#[must_use] +pub fn spectrum(fft_size: usize, sample_rate: f64) -> (SpectrumTap, SpectrumAnalyzer) { + let (w, r) = ring(fft_size * 2); + let n = fft_size; + let window: Box<[f64]> = (0..n) + .map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n as f64).cos()) + .collect(); + let window_sum = window.iter().sum(); + let mut a = SpectrumAnalyzer { + reader: r, + fft: Fft::new(n), + sample_rate, + window, + window_sum, + samples: vec![0.0; n], + re: vec![0.0; n], + im: vec![0.0; n], + amp: vec![0.0; n / 2 + 1], + cfg: SpectrumConfig::default(), + edges: Vec::new(), + power: Vec::new(), + prefix: Vec::new(), + held: Vec::new(), + primed: false, + }; + a.configure(SpectrumConfig::default()); + (SpectrumTap { ring: w }, a) +} + +impl SpectrumAnalyzer { + #[must_use] + pub fn config(&self) -> &SpectrumConfig { + &self.cfg + } + + /// Reshape (bins, band, floor, decay, smoothing). Resets the ballistics + /// when the bins change. + pub fn configure(&mut self, cfg: SpectrumConfig) { + let bins = cfg.bins.max(1); + let f_min = cfg.f_min.max(1e-3); + let f_max = cfg.f_max.max(f_min * 1.0001); + let ratio = f_max / f_min; + self.edges = (0..=bins) + .map(|j| f_min * ratio.powf(j as f64 / bins as f64)) + .collect(); + if self.held.len() != bins { + self.held = vec![cfg.floor_db; bins]; + self.primed = false; + } + self.power.resize(bins, 0.0); + self.prefix.resize(bins + 1, 0.0); + self.cfg = SpectrumConfig { + bins, + f_min, + f_max, + ..cfg + }; + } + + /// Forget the ballistics (the next read shows the spectrum as is). + pub fn reset(&mut self) { + self.primed = false; + } + + /// The display bin a frequency falls in. + #[must_use] + pub fn bin_of(&self, hz: f64) -> Option { + self.edges.windows(2).position(|e| hz >= e[0] && hz < e[1]) + } + + /// The display bins' centres (geometric), Hz. + pub fn centers(&self) -> impl Iterator + '_ { + self.edges.windows(2).map(|e| (e[0] * e[1]).sqrt()) + } + + /// Analyse the latest samples: `out` becomes one dB value per display + /// bin. `dt` is the time since the previous read (for the decay). + pub fn analyze(&mut self, dt: f64, out: &mut Vec) { + let n = self.fft.len(); + self.samples.fill(0.0); + self.reader.latest(&mut self.samples); + for i in 0..n { + self.re[i] = f64::from(self.samples[i]) * self.window[i]; + self.im[i] = 0.0; + } + self.fft.forward(&mut self.re, &mut self.im); + let scale = 2.0 / self.window_sum; + for (k, a) in self.amp.iter_mut().enumerate() { + *a = (self.re[k] * self.re[k] + self.im[k] * self.im[k]).sqrt() * scale; + } + self.fold(); + self.smooth(); + let floor = self.cfg.floor_db; + let fall = self.cfg.decay_db_per_s.max(0.0) * dt.max(0.0); + for (h, p) in self.held.iter_mut().zip(&self.power) { + let now = amp_to_db(p.sqrt()).max(floor); + *h = if self.primed { + now.max(*h - fall).max(floor) + } else { + now + }; + } + self.primed = true; + out.clear(); + out.extend_from_slice(&self.held); + } + + /// FFT amplitudes → power per display bin: the loudest FFT bin inside + /// a display bin, or (for display bins narrower than an FFT bin) the + /// amplitude interpolated at its centre. + fn fold(&mut self) { + let n = self.fft.len() as f64; + let hz_per_bin = self.sample_rate / n; + let last = self.amp.len() - 1; + for (j, e) in self.edges.windows(2).enumerate() { + let lo = (e[0] / hz_per_bin).ceil() as usize; + let hi = ((e[1] / hz_per_bin).ceil() as usize).min(last + 1); + let a = if lo < hi { + self.amp[lo..hi].iter().copied().fold(0.0, f64::max) + } else { + let pos = ((e[0] * e[1]).sqrt() / hz_per_bin).min(last as f64); + let k = pos.floor() as usize; + let t = pos - k as f64; + let next = self.amp[(k + 1).min(last)]; + self.amp[k] * (1.0 - t) + next * t + }; + self.power[j] = a * a; + } + } + + /// Average power over ±half the smoothing width (in display bins). + fn smooth(&mut self) { + let bins = self.power.len(); + let octaves = (self.cfg.f_max / self.cfg.f_min).log2(); + let half = (self.cfg.smoothing_octaves * bins as f64 / octaves / 2.0).round() as usize; + if half == 0 { + return; + } + self.prefix[0] = 0.0; + for j in 0..bins { + self.prefix[j + 1] = self.prefix[j] + self.power[j]; + } + for j in 0..bins { + let (a, b) = (j.saturating_sub(half), (j + half + 1).min(bins)); + self.power[j] = (self.prefix[b] - self.prefix[a]) / (b - a) as f64; + } + } +} + +impl FeedSource for SpectrumAnalyzer { + fn configure(&mut self, info: &crate::feed::proto::FeedInfo, options: &FeedOptions) { + if let Some(cfg) = SpectrumConfig::for_feed(&info.kind, options) { + self.configure(SpectrumConfig { + decay_db_per_s: self.cfg.decay_db_per_s, + ..cfg + }); + } + } + + fn read(&mut self, dt: f64, out: &mut Vec) { + self.analyze(dt, out); + } +} diff --git a/crates/frame/src/feed/memory/mod.rs b/crates/frame/src/feed/memory/mod.rs new file mode 100644 index 00000000..36d586d0 --- /dev/null +++ b/crates/frame/src/feed/memory/mod.rs @@ -0,0 +1,238 @@ +//! Demo generators for every feed kind, as pure functions of time: the +//! mock engine's telemetry, previews, tests and offline renders +//! (`render_surface --demo-feeds`) all draw the same signals. +//! +//! [`frame`] makes a frame of any [`FeedInfo`] at `t` seconds, shaped as +//! the info says (bins, len, voices, rate), so a feed resolved against a +//! request ([`FeedInfo::resolve`]) generates the shape that was asked for. +//! A history is the scalar generator sampled backwards from `t` at the +//! feed's rate, so a GR trace and a GR meter agree. + +use std::f64::consts::{PI, TAU}; + +use crate::feed::proto::{FeedFrame, FeedInfo, FeedKind, Range, ScalarKind, Unit, VoiceState}; + +/// A frame of `info` at `t` seconds. +#[must_use] +pub fn frame(info: &FeedInfo, t: f64) -> FeedFrame { + FeedFrame { + id: info.id.clone(), + values: values(&info.kind, info.rate_hz, t), + } +} + +/// One frame per feed at `t` seconds. +#[must_use] +pub fn frames<'a>(feeds: impl IntoIterator, t: f64) -> Vec { + feeds.into_iter().map(|f| frame(f, t)).collect() +} + +/// A frame's values for `kind` (published at `rate_hz`) at `t` seconds; +/// always [`FeedKind::frame_len`] long. +#[must_use] +pub fn values(kind: &FeedKind, rate_hz: f64, t: f64) -> Vec { + match kind { + FeedKind::Level { channels } => (0..*channels).map(|c| level_db(c, t)).collect(), + FeedKind::GainReduction => vec![gr_db(t)], + FeedKind::Spectrum { + bins, + f_min, + f_max, + floor_db, + } => { + let n = f64::from(*bins); + let ratio = f_max / f_min.max(1e-3); + (0..*bins) + .map(|j| { + let hz = f_min * ratio.powf((f64::from(j) + 0.5) / n); + (spectrum_db(hz, t) + 1.5 * (t * 5.0 + f64::from(j) * 0.9).sin()).max(*floor_db) + }) + .collect() + } + FeedKind::Tuner => vec![64.0, 8.0 * (t * 0.9).sin()], + FeedKind::Scalar { range } => vec![scalar(range, t)], + FeedKind::History { of, len } => { + let dt = 1.0 / if rate_hz > 0.0 { rate_hz } else { 30.0 }; + let n = *len as usize; + (0..n) + .map(|i| history_point(of, t - (n - 1 - i) as f64 * dt)) + .collect() + } + FeedKind::Waveform { len } => waveform(*len as usize, t), + FeedKind::Envelope { stages, voices } => { + let last = stages.len().max(1) - 1; + (0..*voices) + .flat_map(|v| { + let mut s = Adsr::DEMO.voice(t, v); + s.stage = s.stage.map(|i| i.min(last)); + s.encode() + }) + .collect() + } + } +} + +/// The demo level of `channel`, dBFS. +#[must_use] +pub fn level_db(channel: u8, t: f64) -> f64 { + -18.0 + 12.0 * (t * 2.3 + f64::from(channel) * 0.7).sin() - 6.0 * (t * 0.37).cos().abs() +} + +/// The demo gain reduction, dB (−3…−6). +#[must_use] +pub fn gr_db(t: f64) -> f64 { + -3.0 - 3.0 * (t * 1.7).sin().abs() +} + +/// A demo scalar sweeping `range`. +#[must_use] +pub fn scalar(range: &Range, t: f64) -> f64 { + range.to_real(0.5 + 0.5 * (t * 0.5).sin()) +} + +fn history_point(of: &ScalarKind, t: f64) -> f64 { + match of { + ScalarKind::Level => level_db(0, t).max(level_db(1, t)), + ScalarKind::GainReduction => gr_db(t), + ScalarKind::Scalar { range } => scalar(range, t), + } +} + +/// A mix's spectrum at `hz`, dBFS: a pink-ish slope with a pulsing kick +/// near 55 Hz, a tone gliding around 440 Hz and a presence bump at 3 kHz. +#[must_use] +pub fn spectrum_db(hz: f64, t: f64) -> f64 { + let oct = |a: f64, b: f64| (a / b).log2(); + let bump = |center: f64, width_oct: f64, gain_db: f64| { + gain_db * (-(oct(hz, center) / width_oct).powi(2)).exp() + }; + let slope = -30.0 - 3.0 * oct(hz, 1000.0) - 12.0 * (-(oct(hz, 30.0) * 1.5)).exp().min(4.0); + let kick = bump(55.0, 0.4, 20.0 * (t * PI * 2.0).sin().abs().powi(4)); + let tone = bump(440.0 * (0.5 * (t * 0.3).sin()).exp2(), 1.0 / 12.0, 22.0); + let presence = bump(3000.0, 1.0, 6.0); + slope + kick + tone + presence +} + +/// `len` samples of a guitar-ish wave, three periods, starting at its +/// upward crossing (as a triggered scope shows it), −1..1. +#[must_use] +pub fn waveform(len: usize, t: f64) -> Vec { + let amp = 0.6 + 0.35 * (t * 0.8).sin().abs(); + let n = len.max(1) as f64; + (0..len) + .map(|i| { + let ph = TAU * 3.0 * i as f64 / n; + let x = + 0.7 * ph.sin() + 0.25 * (2.0 * ph).sin() * (t * 1.3).cos() + 0.1 * (3.0 * ph).sin(); + (amp * x).clamp(-1.0, 1.0) + }) + .collect() +} + +/// An ADSR, seconds and a sustain level, played by voices that each +/// retrigger every [`Adsr::cycle`] seconds, staggered. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Adsr { + pub attack: f64, + pub decay: f64, + pub sustain: f64, + pub release: f64, + /// How long the key is held (attack and decay included). + pub gate: f64, + /// Each voice's note-on is this much later than the previous voice's. + pub stagger: f64, +} + +impl Adsr { + pub const DEMO: Self = Self { + attack: 0.25, + decay: 0.35, + sustain: 0.6, + release: 0.6, + gate: 1.2, + stagger: 0.7, + }; + + /// Note-on to note-on. + #[must_use] + pub fn cycle(&self) -> f64 { + self.gate + self.release + 1.0 + } + + /// The playhead `since` seconds after note-on (stages A=0, D=1, S=2, + /// R=3; idle after the release). + #[must_use] + pub fn at(&self, since: f64) -> VoiceState { + let (a, d, s, r) = ( + self.attack.max(1e-6), + self.decay.max(1e-6), + self.sustain, + self.release.max(1e-6), + ); + let hold = (self.gate - a - d).max(1e-6); + let state = |stage, position: f64, level| VoiceState { + stage: Some(stage), + position: position.clamp(0.0, 1.0), + level, + }; + if since < 0.0 { + VoiceState { + stage: None, + position: 0.0, + level: 0.0, + } + } else if since < a { + state(0, since / a, since / a) + } else if since < a + d { + let p = (since - a) / d; + state(1, p, 1.0 - (1.0 - s) * p) + } else if since < a + d + hold { + state(2, (since - a - d) / hold, s) + } else if since < a + d + hold + r { + let p = (since - a - d - hold) / r; + state(3, p, s * (1.0 - p)) + } else { + VoiceState { + stage: None, + position: 0.0, + level: 0.0, + } + } + } + + /// Voice `voice`'s playhead at `t`. + #[must_use] + pub fn voice(&self, t: f64, voice: u8) -> VoiceState { + self.at((t - f64::from(voice) * self.stagger).rem_euclid(self.cycle())) + } +} + +/// One feed of every kind (ids under `demo/`), for previews and tests. +#[must_use] +pub fn demo_feeds() -> Vec { + vec![ + FeedInfo::new("demo/in", "Input", FeedKind::Level { channels: 2 }), + FeedInfo::new("demo/gr", "Gain Reduction", FeedKind::GainReduction), + FeedInfo::new("demo/gr_trace", "GR History", FeedKind::gr_history(256)), + FeedInfo::new( + "demo/in_trace", + "Input History", + FeedKind::History { + of: ScalarKind::Level, + len: 256, + }, + ), + FeedInfo::new("demo/spectrum", "Spectrum", FeedKind::spectrum(96)), + FeedInfo::new("demo/scope", "Scope", FeedKind::Waveform { len: 512 }), + FeedInfo::new("demo/env", "Envelope", FeedKind::adsr(4)).rate(60.0), + FeedInfo::new("demo/tuner", "Tuner", FeedKind::Tuner), + FeedInfo::new( + "demo/lfo", + "LFO", + FeedKind::Scalar { + range: Range::linear(0.0, 1.0, Unit::None), + }, + ) + .rate(60.0), + ] +} diff --git a/crates/frame/src/feed/mod.rs b/crates/frame/src/feed/mod.rs new file mode 100644 index 00000000..4fddd457 --- /dev/null +++ b/crates/frame/src/feed/mod.rs @@ -0,0 +1,9 @@ +//! Feeds: the live values a surface shows — protocol, in-memory feeds and DSP-derived meters. + +pub mod proto; + +#[cfg(feature = "runtime")] +pub mod memory; + +#[cfg(feature = "dsp")] +pub mod dsp; diff --git a/crates/frame/src/feed/proto/curve.rs b/crates/frame/src/feed/proto/curve.rs new file mode 100644 index 00000000..27286128 --- /dev/null +++ b/crates/frame/src/feed/proto/curve.rs @@ -0,0 +1,130 @@ +//! The one response-curve type. +//! +//! Every "shape this 0..1 value" in a surface — a parameter's taper, a macro +//! binding's response, a MIDI CC's feel — is a [`Curve`]. It replaces three +//! parallel systems (CC curves, easing/power response curves, multi-point +//! macro curves) with one serializable, testable function `[0,1] → [0,1]`. + +use facet::Facet; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Facet)] +pub struct CurvePoint { + pub x: f64, + pub y: f64, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum Curve { + /// `y = x`. + #[default] + Linear, + /// `y = x^exponent`: 1 = linear, 2 = slow start, 0.5 = fast start. + Power { exponent: f64 }, + /// Slow start, fast end (finer control at the low end). + Exponential, + /// Fast start, slow end (finer control at the high end). + Logarithmic, + /// S-curve: fine control near both ends. + SmoothStep, + /// Off below `threshold`, on at or above it. + Toggle { threshold: f64 }, + /// Snap to `steps` evenly spaced values (steps ≥ 2). + Stepped { steps: u32 }, + /// Piecewise-linear through points (sorted by `x`; clamped outside). + Points { points: Vec }, + /// `inner` mirrored: `1 - inner(1 - x)`. + Inverted { inner: Box }, +} + +/// How strongly the exponential/logarithmic shapes bend. +const BEND: f64 = 4.0; + +impl Curve { + /// Map `x` (clamped to 0..=1) through the curve; the result is in 0..=1. + #[must_use] + pub fn apply(&self, x: f64) -> f64 { + let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; + let y = match self { + Self::Linear => x, + Self::Power { exponent } => { + if *exponent > 0.0 { + x.powf(*exponent) + } else { + x + } + } + Self::Exponential => (BEND * x).exp_m1() / BEND.exp_m1(), + Self::Logarithmic => (BEND * x).ln_1p() / BEND.ln_1p(), + Self::SmoothStep => x * x * (3.0 - 2.0 * x), + Self::Toggle { threshold } => { + if x >= *threshold { + 1.0 + } else { + 0.0 + } + } + Self::Stepped { steps } => { + let n = f64::from((*steps).max(2) - 1); + (x * n).round() / n + } + Self::Points { points } => piecewise(points, x), + Self::Inverted { inner } => 1.0 - inner.apply(1.0 - x), + }; + y.clamp(0.0, 1.0) + } + + /// The inverse mapping where one exists (monotone curves), for turning + /// a stored value back into a control position. + #[must_use] + pub fn invert(&self, y: f64) -> f64 { + let y = if y.is_nan() { 0.0 } else { y.clamp(0.0, 1.0) }; + match self { + Self::Linear | Self::Stepped { .. } | Self::Toggle { .. } => y, + Self::Power { exponent } if *exponent > 0.0 => y.powf(1.0 / exponent), + Self::Power { .. } => y, + Self::Exponential => (y * BEND.exp_m1()).ln_1p() / BEND, + Self::Logarithmic => (y * BEND.ln_1p()).exp_m1() / BEND, + // Monotone but no closed form: bisect. + Self::SmoothStep | Self::Points { .. } | Self::Inverted { .. } => bisect(self, y), + } + } +} + +fn piecewise(points: &[CurvePoint], x: f64) -> f64 { + match points { + [] => x, + [only] => only.y, + _ => { + if x <= points[0].x { + return points[0].y; + } + for w in points.windows(2) { + let (a, b) = (w[0], w[1]); + if x <= b.x { + let span = b.x - a.x; + return if span <= 0.0 { + b.y + } else { + a.y + (b.y - a.y) * (x - a.x) / span + }; + } + } + points[points.len() - 1].y + } + } +} + +fn bisect(curve: &Curve, y: f64) -> f64 { + let (mut lo, mut hi) = (0.0_f64, 1.0_f64); + for _ in 0..48 { + let mid = 0.5 * (lo + hi); + if curve.apply(mid) < y { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} diff --git a/crates/frame/src/feed/proto/feed.rs b/crates/frame/src/feed/proto/feed.rs new file mode 100644 index 00000000..9ed81a35 --- /dev/null +++ b/crates/frame/src/feed/proto/feed.rs @@ -0,0 +1,388 @@ +//! Feed kinds, catalog entries, requests and frames. + +use facet::Facet; +use serde::{Deserialize, Serialize}; + +use crate::feed::proto::EntryId; +use crate::feed::proto::range::Range; + +/// Largest spectrum a request may ask for. +pub const MAX_BINS: u16 = 4096; +/// Longest history or waveform a request may ask for. +pub const MAX_LEN: u32 = 1 << 16; +/// Fastest publish rate a request may ask for. +pub const MAX_RATE_HZ: f64 = 240.0; + +const fn default_f_min() -> f64 { + 20.0 +} +const fn default_f_max() -> f64 { + 20_000.0 +} +const fn default_floor_db() -> f64 { + -90.0 +} + +/// What a feed carries — and so how a frame's `values` are laid out +/// ([`Self::frame_len`]): +/// +/// | kind | `values` | +/// |---|---| +/// | `Level { channels }` | dBFS per channel | +/// | `GainReduction` | `[dB]`, ≤ 0 | +/// | `Spectrum { bins, .. }` | dB per log-spaced bin, `f_min` → `f_max` | +/// | `Tuner` | `[midi_note, cents]` | +/// | `Scalar { range }` | `[v]` in `range`'s units | +/// | `History { of, len }` | the last `len` values of `of`, oldest first | +/// | `Waveform { len }` | `len` samples, −1..1 | +/// | `Envelope { stages, voices }` | `voices` × `[stage, position, level]` | +/// +/// Variant order is wire order: new kinds go at the end. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum FeedKind { + /// Peak level in dBFS, per channel. + Level { channels: u8 }, + /// Gain reduction in dB (≤ 0). + GainReduction, + /// Magnitude spectrum in dB over `bins` log-spaced bins from `f_min` to + /// `f_max` Hz; values at or below `floor_db` read as silence. (Catalogs + /// written before the band fields default to 20 Hz–20 kHz, −90 dB.) + Spectrum { + bins: u16, + #[serde(default = "default_f_min")] + #[facet(default = 20.0)] + f_min: f64, + #[serde(default = "default_f_max")] + #[facet(default = 20_000.0)] + f_max: f64, + #[serde(default = "default_floor_db")] + #[facet(default = -90.0)] + floor_db: f64, + }, + /// Detected pitch: note, cents offset. + Tuner, + /// Any scalar readout (tempo, CPU %, an LFO). + Scalar { range: Range }, + /// A scrolling trace: the last `len` values of a scalar feed (gain + /// reduction over time, input level over time). + History { of: ScalarKind, len: u32 }, + /// A scope: `len` samples of the signal, −1..1, triggered. + Waveform { len: u32 }, + /// Envelope playheads: per voice, which stage (an index into `stages`, + /// −1 when the voice is idle), how far through it (0..1) and the + /// envelope's level (0..1). + Envelope { stages: Vec, voices: u8 }, +} + +/// What a [`FeedKind::History`] records: one value per point. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum ScalarKind { + /// A level in dBFS (the loudest channel). + Level, + /// Gain reduction in dB (≤ 0). + GainReduction, + /// A value in `range`'s units. + Scalar { range: Range }, +} + +impl FeedKind { + /// A spectrum over the audible band (20 Hz–20 kHz, floor −90 dB). + #[must_use] + pub fn spectrum(bins: u16) -> Self { + Self::Spectrum { + bins, + f_min: default_f_min(), + f_max: default_f_max(), + floor_db: default_floor_db(), + } + } + + /// A gain-reduction trace of `len` points. + #[must_use] + pub fn gr_history(len: u32) -> Self { + Self::History { + of: ScalarKind::GainReduction, + len, + } + } + + /// ADSR playheads for `voices` voices (stages `A`, `D`, `S`, `R`). + #[must_use] + pub fn adsr(voices: u8) -> Self { + Self::Envelope { + stages: ["A", "D", "S", "R"].map(String::from).to_vec(), + voices, + } + } + + /// How many `values` a frame of this kind carries. + #[must_use] + pub fn frame_len(&self) -> usize { + match self { + Self::Level { channels } => usize::from(*channels), + Self::GainReduction | Self::Scalar { .. } => 1, + Self::Spectrum { bins, .. } => usize::from(*bins), + Self::Tuner => 2, + Self::History { len, .. } | Self::Waveform { len } => *len as usize, + Self::Envelope { voices, .. } => usize::from(*voices) * 3, + } + } + + /// Whether a frame is a level in dB a meter can show (a level or a gain + /// reduction, not a curve of many values). + #[must_use] + pub fn is_meter(&self) -> bool { + matches!(self, Self::Level { .. } | Self::GainReduction) + } + + /// Whether a frame is a series a plot draws (spectrum, history, + /// waveform) rather than a handful of readouts. + #[must_use] + pub fn is_series(&self) -> bool { + matches!( + self, + Self::Spectrum { .. } | Self::History { .. } | Self::Waveform { .. } + ) + } + + /// This kind as a request's `options` reshape it: `bins` resizes a + /// spectrum, `len` a history or waveform, `voices` an envelope. Other + /// options (tap, smoothing, trigger) don't change the frame's shape. + #[must_use] + pub fn with_options(&self, o: &FeedOptions) -> Self { + let mut k = self.clone(); + match &mut k { + Self::Spectrum { bins, .. } => { + if let Some(b) = o.bins { + *bins = b.clamp(1, MAX_BINS); + } + } + Self::History { len, .. } | Self::Waveform { len } => { + if let Some(l) = o.len { + *len = l.clamp(1, MAX_LEN); + } + } + Self::Envelope { voices, .. } => { + if let Some(v) = o.voices { + *voices = v; + } + } + _ => {} + } + k + } +} + +/// One feed an engine offers — an entry of the catalog's `streams`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct FeedInfo { + pub id: EntryId, + pub name: String, + pub kind: FeedKind, + /// Nominal publish rate. + pub rate_hz: f64, +} + +impl FeedInfo { + /// A feed at 30 Hz. + #[must_use] + pub fn new(id: impl Into, name: impl Into, kind: FeedKind) -> Self { + Self { + id: EntryId::new(id), + name: name.into(), + kind, + rate_hz: 30.0, + } + } + + #[must_use] + pub fn rate(mut self, rate_hz: f64) -> Self { + self.rate_hz = rate_hz; + self + } + + /// What this feed becomes under `options`: its kind reshaped + /// ([`FeedKind::with_options`]) and its rate, if asked, within + /// `(0, MAX_RATE_HZ]`. An engine answers a request with this. + #[must_use] + pub fn resolve(&self, options: &FeedOptions) -> Self { + let rate_hz = match options.rate_hz { + Some(r) if r.is_finite() && r > 0.0 => r.min(MAX_RATE_HZ), + _ => self.rate_hz, + }; + Self { + id: self.id.clone(), + name: self.name.clone(), + kind: self.kind.with_options(options), + rate_hz, + } + } +} + +/// Where in the signal path a feed listens. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum Tap { + /// Before the block's processing (the input). + Pre, + /// After it (the output). + #[default] + Post, + /// The detector's key input. + Sidechain, +} + +/// When a scope ([`FeedKind::Waveform`]) starts its window. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum Trigger { + /// The latest `len` samples, no alignment. + Free, + /// Start at an upward zero crossing, so a periodic wave stands still. + #[default] + Rising, + /// Start at a downward zero crossing. + Falling, +} + +/// How a surface wants a feed. Every field is optional: `None` keeps the +/// engine's default (the catalog's shape and rate). +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct FeedOptions { + /// Spectrum bins. + #[serde(default)] + #[facet(default)] + pub bins: Option, + /// History points or waveform samples. + #[serde(default)] + #[facet(default)] + pub len: Option, + /// Publish rate. + #[serde(default)] + #[facet(default)] + pub rate_hz: Option, + /// Where to listen (pre, post, sidechain). + #[serde(default)] + #[facet(default)] + pub tap: Option, + /// Frequency smoothing of a spectrum, in octaves (`1/3` = third-octave). + #[serde(default)] + #[facet(default)] + pub smoothing: Option, + /// A scope's trigger. + #[serde(default)] + #[facet(default)] + pub trigger: Option, + /// Envelope voices to report. + #[serde(default)] + #[facet(default)] + pub voices: Option, +} + +/// A subscription with options: "feed `id`, shaped like this". +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct FeedRequest { + pub id: EntryId, + #[serde(default)] + #[facet(default)] + pub options: FeedOptions, +} + +impl FeedRequest { + /// Feed `id` as the engine offers it. + #[must_use] + pub fn new(id: impl Into) -> Self { + Self { + id: EntryId::new(id), + options: FeedOptions::default(), + } + } + + #[must_use] + pub fn bins(mut self, bins: u16) -> Self { + self.options.bins = Some(bins); + self + } + + #[must_use] + pub fn len(mut self, len: u32) -> Self { + self.options.len = Some(len); + self + } + + #[must_use] + pub fn rate(mut self, rate_hz: f64) -> Self { + self.options.rate_hz = Some(rate_hz); + self + } + + #[must_use] + pub fn tap(mut self, tap: Tap) -> Self { + self.options.tap = Some(tap); + self + } + + #[must_use] + pub fn smoothing(mut self, octaves: f64) -> Self { + self.options.smoothing = Some(octaves); + self + } + + #[must_use] + pub fn trigger(mut self, trigger: Trigger) -> Self { + self.options.trigger = Some(trigger); + self + } + + #[must_use] + pub fn voices(mut self, voices: u8) -> Self { + self.options.voices = Some(voices); + self + } +} + +/// One sample of a feed; `values` are laid out by the feed's kind +/// ([`FeedKind`]'s table). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct FeedFrame { + pub id: EntryId, + pub values: Vec, +} + +/// One voice of an [`FeedKind::Envelope`] frame. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VoiceState { + /// Index into the feed's `stages`; `None` when idle. + pub stage: Option, + /// How far through the stage, 0..1. + pub position: f64, + /// The envelope's output, 0..1. + pub level: f64, +} + +impl VoiceState { + /// The voices of an envelope frame's `values`. + pub fn decode(values: &[f64]) -> impl Iterator + '_ { + values + .as_chunks::<3>() + .0 + .iter() + .map(|&[stage, position, level]| Self { + stage: (stage >= 0.0).then(|| stage.round() as usize), + position, + level, + }) + } + + /// This voice as a frame's triple. + #[must_use] + pub fn encode(&self) -> [f64; 3] { + [ + self.stage.map_or(-1.0, |s| s as f64), + self.position, + self.level, + ] + } +} diff --git a/crates/frame/src/feed/proto/mod.rs b/crates/frame/src/feed/proto/mod.rs new file mode 100644 index 00000000..d85d3c7c --- /dev/null +++ b/crates/frame/src/feed/proto/mod.rs @@ -0,0 +1,45 @@ +//! The `feed` contract: typed data a surface can request from an engine. +//! +//! A **feed** is a telemetry stream with a declared shape ([`FeedKind`]). +//! An engine lists its feeds in its catalog ([`FeedInfo`]); a surface asks +//! for them with options ([`FeedRequest`]: bins, history length, rate, +//! tap, smoothing) and receives [`FeedFrame`]s whose `values` follow the +//! kind's payload layout ([`FeedKind::frame_len`]). +//! +//! `surface-proto`'s `StreamKind` / `StreamInfo` / `StreamFrame` are these +//! types under their old names, so catalogs and surfaces written before +//! feeds existed keep working (same JSON). +//! +//! - [`range`]: engineering ranges and units ([`Range`], [`Unit`]) — shared +//! with parameters, and what a [`FeedKind::Scalar`] reads in. +//! - [`curve`]: the one response-curve type ([`Curve`]). + +pub mod curve; +pub mod feed; +pub mod range; + +pub use curve::{Curve, CurvePoint}; +pub use feed::*; +pub use range::*; + +use facet::Facet; +use serde::{Deserialize, Serialize}; + +/// Identity of an action, stream (feed) or collection in a catalog. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Facet)] +#[facet(transparent)] +#[serde(transparent)] +pub struct EntryId(pub String); + +impl EntryId { + #[must_use] + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } +} + +impl std::fmt::Display for EntryId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} diff --git a/crates/frame/src/feed/proto/range.rs b/crates/frame/src/feed/proto/range.rs new file mode 100644 index 00000000..b698a52f --- /dev/null +++ b/crates/frame/src/feed/proto/range.rs @@ -0,0 +1,381 @@ +//! Engineering ranges and units, shared by parameters and feeds: how a +//! normalized 0..1 position maps to a value, and how a value prints. + +use facet::Facet; +use serde::{Deserialize, Serialize}; + +use crate::feed::proto::curve::Curve; + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum Unit { + #[default] + None, + Hz, + Db, + Seconds, + Milliseconds, + Percent, + Ratio, + Semitones, + Cents, + Degrees, + Beats, + Bpm, + Custom { + suffix: String, + }, +} + +impl Unit { + #[must_use] + pub fn suffix(&self) -> &str { + match self { + Self::None => "", + Self::Hz => "Hz", + Self::Db => "dB", + Self::Seconds => "s", + Self::Milliseconds => "ms", + Self::Percent => "%", + Self::Ratio => ":1", + Self::Semitones => "st", + Self::Cents => "ct", + Self::Degrees => "°", + Self::Beats => "beats", + Self::Bpm => "BPM", + Self::Custom { suffix } => suffix, + } + } +} + +/// How the normalized 0..1 control position spreads over `min..max`. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum Scale { + /// Even across the range (dB gain, mix, pan). + #[default] + Linear, + /// Even in ratio: equal travel is equal multiplication (frequency, + /// time). Needs `min > 0`; otherwise behaves linearly. + Logarithmic, + /// Any other shape. + Curve { curve: Curve }, +} + +/// A parameter's engineering range. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Range { + pub min: f64, + pub max: f64, + pub scale: Scale, + pub unit: Unit, + /// Whole steps only (modes, algorithm choices): `Some(n)` values. + pub steps: Option, +} + +impl Default for Range { + /// The unit interval: normalized in, normalized out. + fn default() -> Self { + Self { + min: 0.0, + max: 1.0, + scale: Scale::Linear, + unit: Unit::None, + steps: None, + } + } +} + +impl Range { + #[must_use] + pub fn linear(min: f64, max: f64, unit: Unit) -> Self { + Self { + min, + max, + scale: Scale::Linear, + unit, + steps: None, + } + } + + #[must_use] + pub fn log(min: f64, max: f64, unit: Unit) -> Self { + Self { + min, + max, + scale: Scale::Logarithmic, + unit, + steps: None, + } + } + + /// Snap a normalized value (0..1) to this range's steps, if it has any. + #[must_use] + pub fn quantize(&self, n: f64) -> f64 { + match self.steps { + Some(s) if s >= 2 => { + let k = f64::from(s - 1); + (n * k).round() / k + } + _ => n, + } + } + + /// Control position (0..1) → engineering value. + #[must_use] + pub fn to_real(&self, normalized: f64) -> f64 { + let n = self.quantize(if normalized.is_nan() { + 0.0 + } else { + normalized.clamp(0.0, 1.0) + }); + match &self.scale { + Scale::Logarithmic if self.min > 0.0 && self.max > 0.0 => { + self.min * (self.max / self.min).powf(n) + } + Scale::Curve { curve } => self.min + (self.max - self.min) * curve.apply(n), + _ => self.min + (self.max - self.min) * n, + } + } + + /// Engineering value → control position (0..1). + #[must_use] + pub fn to_normalized(&self, real: f64) -> f64 { + let span = self.max - self.min; + let n = match &self.scale { + Scale::Logarithmic if self.min > 0.0 && self.max > 0.0 && real > 0.0 => { + (real / self.min).ln() / (self.max / self.min).ln() + } + Scale::Curve { curve } if span != 0.0 => curve.invert((real - self.min) / span), + _ if span != 0.0 => (real - self.min) / span, + _ => 0.0, + }; + self.quantize(n.clamp(0.0, 1.0)) + } + + /// Human readout: `"440 Hz"`, `"-6.0 dB"`, `"2.50 s"`, `"35 %"`. A + /// stepped linear range prints as many decimals as its step needs + /// (`0.5`-dB clicks read `-1.5 dB`; whole steps read whole). + #[must_use] + pub fn format(&self, real: f64) -> String { + self.format_as(real, &ValueFormat::default()) + } + + /// [`Self::format`] under an explicit [`ValueFormat`]: fixed decimals, + /// another unit (or none), the note a pitch is, or one label per step. + #[must_use] + pub fn format_as(&self, real: f64, f: &ValueFormat) -> String { + if !f.marks.is_empty() { + let plain = ValueFormat { + marks: Vec::new(), + ..f.clone() + }; + let text = self.format_as(real, &plain); + return f + .marks + .iter() + .find(|m| self.format_as(m.at, &plain) == text) + .map_or(text, |m| m.label.clone()); + } + match &f.style { + FormatStyle::Labels { labels } if !labels.is_empty() => { + let last = labels.len() - 1; + let i = (self.to_normalized(real) * last as f64).round() as usize; + return labels[i.min(last)].clone(); + } + FormatStyle::Note => { + let midi = match self.unit { + Unit::Hz if real > 0.0 => 69.0 + 12.0 * (real / 440.0).log2(), + _ => real, + }; + return note_name(midi); + } + _ => {} + } + let (value, unit, converted) = match (&f.unit, &self.unit) { + (Some(u), _) => (real, u.clone(), false), + (None, Unit::Hz) if real.abs() >= 1000.0 => (real / 1000.0, "kHz".to_string(), true), + (None, Unit::Seconds) if real.abs() < 1.0 => (real * 1000.0, "ms".to_string(), true), + (None, u) => (real, u.suffix().to_string(), false), + }; + let decimals = f.decimals.map(usize::from).or_else(|| { + if converted { + None + } else { + self.step_decimals() + } + }); + let text = match decimals { + Some(d) => format!("{value:.d$}"), + None if self.steps.is_some() => format!("{value:.0}"), + None if value.abs() >= 100.0 => format!("{value:.0}"), + None if value.abs() >= 10.0 => format!("{value:.1}"), + None => format!("{value:.2}"), + }; + // `-0.0` reads as a glitch on a panel. + let text = match text.strip_prefix('-') { + Some(rest) if rest.chars().all(|c| c == '0' || c == '.') => rest.to_string(), + _ => text, + }; + let text = if f.sign + && !text.starts_with('-') + && text.chars().any(|c| c.is_ascii_digit() && c != '0') + { + format!("+{text}") + } else { + text + }; + if unit.is_empty() { + text + } else { + format!("{text} {unit}") + } + } + + /// Decimals a stepped range's step needs (`None` = not stepped). Only a + /// linear range has one step size; a stepped curve prints whole values. + fn step_decimals(&self) -> Option { + let n = self.steps.filter(|s| *s >= 2)?; + if !matches!(self.scale, Scale::Linear) { + return Some(0); + } + let step = ((self.max - self.min) / f64::from(n - 1)).abs(); + let whole_at = |d: i32| { + let k = step * 10f64.powi(d); + (k - k.round()).abs() < 1e-6 * k.max(1.0) + }; + Some((0..=3).find(|d| whole_at(*d)).unwrap_or(3) as usize) + } +} + +/// `"A4"`, `"C#3"` for a MIDI note number (rounded to the nearest note). +#[must_use] +pub fn note_name(midi: f64) -> String { + const NAMES: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + let n = midi.round() as i64; + format!( + "{}{}", + NAMES[n.rem_euclid(12) as usize], + n.div_euclid(12) - 1 + ) +} + +/// How a readout prints a value. Carried on a surface control +/// (`Control::format`), so one param can read `-6 dB` on a knob and +/// `-6.0` in a window. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct ValueFormat { + /// Fixed decimals (`None` = the range's own: its step, else by size). + pub decimals: Option, + /// A unit suffix instead of the range's (`Some("")` prints none). + pub unit: Option, + pub style: FormatStyle, + /// A number reads `+` before a positive value (a gain, a pitch offset). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + #[facet(default)] + pub sign: bool, + /// Words that replace particular values: `OFF` at a filter's stop, + /// `C` at a pan's centre. A mark prints when the value reads the same + /// as the mark's value would. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[facet(default)] + pub marks: Vec, +} + +/// A word a readout prints in place of one value (see [`ValueFormat::mark`]). +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +pub struct FormatMark { + /// The value, in the range's units. + pub at: f64, + pub label: String, +} + +/// What a readout prints. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Facet)] +#[repr(u8)] +pub enum FormatStyle { + /// The number (and unit). + #[default] + Number, + /// The note it is: a `Hz` value as its nearest note, anything else as + /// a MIDI note number (`69` → `A4`). + Note, + /// One label per position, evenly over the range: note divisions, a + /// stepped knob whose positions have names but isn't a choice param. + Labels { labels: Vec }, +} + +impl ValueFormat { + #[must_use] + pub fn decimals(d: u8) -> Self { + Self { + decimals: Some(d), + ..Self::default() + } + } + + #[must_use] + pub fn note() -> Self { + Self { + style: FormatStyle::Note, + ..Self::default() + } + } + + #[must_use] + pub fn labels(labels: &[&str]) -> Self { + Self { + style: FormatStyle::Labels { + labels: labels.iter().map(ToString::to_string).collect(), + }, + ..Self::default() + } + } + + /// Print `unit` instead of the range's own (`""` = none). + #[must_use] + pub fn unit(mut self, unit: impl Into) -> Self { + self.unit = Some(unit.into()); + self + } + + #[must_use] + pub fn with_decimals(mut self, d: u8) -> Self { + self.decimals = Some(d); + self + } + + /// Print `+` before a positive number. + #[must_use] + pub fn signed(mut self) -> Self { + self.sign = true; + self + } + + /// Print `label` where the value reads as `at` does (`OFF` at a + /// filter's stop, `C` at a pan's centre). + #[must_use] + pub fn mark(mut self, at: f64, label: impl Into) -> Self { + self.marks.push(FormatMark { + at, + label: label.into(), + }); + self + } + + /// A level in dB (a meter's readout): `decimals` (default 1) and this + /// format's unit (default `dB`). + #[must_use] + pub fn db(&self, db: f64) -> String { + let f = Self { + decimals: Some(self.decimals.unwrap_or(1)), + style: FormatStyle::Number, + unit: self.unit.clone(), + sign: self.sign, + marks: Vec::new(), + }; + Range::linear(-1.0e9, 1.0e9, Unit::Db).format_as(db, &f) + } +} diff --git a/crates/frame/src/figma/import/mod.rs b/crates/frame/src/figma/import/mod.rs new file mode 100644 index 00000000..084f4ffa --- /dev/null +++ b/crates/frame/src/figma/import/mod.rs @@ -0,0 +1,686 @@ +//! Figma → frame document. +//! +//! Figma is an import source, never frame's storage format. This crate reads +//! Figma data (REST `GetFile` JSON and the FTS plugin/bridge exports, via +//! `figma-model`) and translates it — once — into a [`crate::document::proto::Scene`]. +//! Every Figma quirk (v1/v2 key spellings, `relativeTransform` rows, paint +//! opacity, placeholder images…) is resolved here so nothing downstream ever +//! reads Figma JSON. +//! +//! Node ids are preserved (`crate::figma::model::NodeId` and `crate::document::proto::NodeId` +//! wrap the same UUID), and each node keeps a [`SourceRef`] with its Figma id +//! so a re-import can update in place. + +use std::collections::BTreeMap; + +use crate::document::proto::{ + AlignSelf, AutoLayout, BlendMode, BooleanOp, Color, CornerRadii, CrossAxisAlign, Effect, + EffectKind, GradientStop, ImageScaleMode, LayoutChild, LayoutDirection, MainAxisAlign, + MaskKind, Node, NodeId, NodeKind, Paint, PaintKind, Rect, Scene, Size, SourceRef, Stroke, + StrokeAlign, StrokeCap, StrokeJoin, TextCase, TextData, TextRun, Transform, +}; +use crate::figma::model::{ + AutoLayoutAlignSelf, AutoLayoutCounterAlign, AutoLayoutMode, AutoLayoutPositioning, + AutoLayoutPrimaryAlign, AutoLayoutWrap, FrameDocument, +}; +use base64::Engine as _; +use serde_json::Value; + +pub use crate::figma::model::document::FrameDocumentError as ImportError; + +/// Parse any supported Figma source (REST `GetFile` JSON, FTS export v1/v2, +/// bridge JSON, `.fig` zip container) and import it. +/// +/// # Errors +/// When the bytes aren't a Figma source `figma-model` understands. +pub fn import_bytes(bytes: &[u8]) -> Result { + Ok(import_document(&FrameDocument::from_source_bytes(bytes)?)) +} + +/// Import an already-parsed Figma document. +#[must_use] +pub fn import_document(doc: &FrameDocument) -> Scene { + let mut scene = Scene { + root: NodeId(doc.root.0), + nodes: BTreeMap::new(), + assets: BTreeMap::new(), + variables: crate::document::proto::Variables::default(), + }; + for id in doc.walk_subtree(doc.root) { + let Some(fnode) = doc.get_node(id) else { + continue; + }; + let node = import_node(doc, fnode, &mut scene); + scene.nodes.insert(node.id, node); + } + scene +} + +/// The document node for a Figma node id (`"12:34"`), if it was imported. +#[must_use] +pub fn node_for_figma_id(scene: &Scene, figma_id: &str) -> Option { + scene.by_source_id(figma_id).map(|n| n.id) +} + +fn import_node( + doc: &FrameDocument, + fnode: &crate::figma::model::FrameNode, + scene: &mut Scene, +) -> Node { + let raw = &fnode.raw; + let kind = node_kind(doc, fnode); + let mut node = Node::new(kind, fnode.name.clone()); + node.id = NodeId(fnode.id.0); + node.parent = fnode.parent.map(|p| NodeId(p.0)); + node.children = fnode.children.iter().map(|c| NodeId(c.0)).collect(); + node.visible = fnode.visible; + node.locked = fnode.locked; + node.source = Some(SourceRef { + tool: "figma".into(), + id: fnode.figma_id.clone(), + node_type: fnode.figma_type.clone(), + bounds: absolute_bounds(raw), + }); + + node.transform = relative_transform(raw); + node.size = size(raw); + node.opacity = f64_at(raw, "opacity").unwrap_or(1.0); + node.blend = blend_mode(raw); + node.corner_radii = corner_radii(raw); + node.clips_content = bool_at(raw, "clipsContent").unwrap_or(false); + node.mask = bool_at(raw, "isMask") + .unwrap_or(false) + .then(|| match str_at(raw, "maskType") { + Some(t) if t.eq_ignore_ascii_case("LUMINANCE") => MaskKind::Luminance, + Some(t) if t.eq_ignore_ascii_case("VECTOR") => MaskKind::Vector, + _ => MaskKind::Alpha, + }); + + node.fills = paints(raw, raw, "fills", scene); + node.stroke = stroke(raw, scene); + node.effects = effects(raw); + node.fill_geometry = path_strings(raw, "fillGeometry"); + node.stroke_geometry = path_strings(raw, "strokeGeometry"); + node.svg_snapshot = raw + .get("exports") + .and_then(|v| v.get("svgBase64")) + .and_then(Value::as_str) + .and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok()) + .map(|bytes| scene.add_asset("image/svg+xml", bytes)); + + let (layout, layout_child) = layout(raw); + node.layout = layout; + node.layout_child = layout_child; + + if let NodeKind::Text(text) = &mut node.kind { + text.runs = text_runs(raw, scene); + } + node +} + +fn node_kind(doc: &FrameDocument, fnode: &crate::figma::model::FrameNode) -> NodeKind { + let raw = &fnode.raw; + match fnode.figma_type.as_str() { + "DOCUMENT" => NodeKind::Document, + "CANVAS" => NodeKind::Page, + "FRAME" | "SECTION" | "TABLE" | "TABLE_CELL" | "TRANSFORM_GROUP" => NodeKind::Frame, + "GROUP" => NodeKind::Group, + "COMPONENT" => NodeKind::Component, + "COMPONENT_SET" => NodeKind::ComponentSet, + "INSTANCE" => NodeKind::Instance { + component: str_at(raw, "componentId") + .and_then(|cid| doc.get_by_figma_id(cid)) + .map(|c| NodeId(c.id.0)), + }, + "RECTANGLE" => NodeKind::Rectangle, + "ELLIPSE" => NodeKind::Ellipse, + "LINE" => NodeKind::Line, + "REGULAR_POLYGON" => NodeKind::Polygon, + "STAR" => NodeKind::Star, + "VECTOR" | "CONNECTOR" | "WASHI_TAPE" => NodeKind::Vector, + "BOOLEAN_OPERATION" => NodeKind::BooleanOperation { + op: match str_at(raw, "booleanOperation") { + Some("UNION") => Some(BooleanOp::Union), + Some("SUBTRACT") => Some(BooleanOp::Subtract), + Some("INTERSECT") => Some(BooleanOp::Intersect), + Some("EXCLUDE") => Some(BooleanOp::Exclude), + _ => None, + }, + }, + "TEXT" | "TEXT_PATH" | "SHAPE_WITH_TEXT" | "STICKY" => NodeKind::Text(text_data(raw)), + "SLICE" | "EMBED" | "LINK_UNFURL" | "WIDGET" => NodeKind::Utility, + other => NodeKind::Other { + tag: other.to_string(), + }, + } +} + +// ── scalar helpers ─────────────────────────────────────────────────────── + +fn f64_at(raw: &Value, key: &str) -> Option { + raw.get(key).and_then(Value::as_f64) +} + +fn bool_at(raw: &Value, key: &str) -> Option { + raw.get(key).and_then(Value::as_bool) +} + +fn str_at<'a>(raw: &'a Value, key: &str) -> Option<&'a str> { + raw.get(key).and_then(Value::as_str) +} + +/// v2 plugin exports put text style keys at the top level; v1 REST under `style`. +fn style_f64(raw: &Value, top: &str, nested: &str) -> Option { + f64_at(raw, top).or_else(|| raw.get("style").and_then(|s| f64_at(s, nested))) +} + +/// A Figma `gradientTransform` as frame's gradient transform. Figma's maps +/// the node's unit box *to* the gradient's own space (a linear gradient runs +/// (0, ½) → (1, ½) there, a radial one is centred at (½, ½)); frame's maps +/// the gradient's space into the node's box, where its handles are drawn. +/// So it is the inverse (a singular one is kept as is). +fn gradient_matrix(value: Option<&Value>) -> Option { + let t = matrix(value)?; + let det = t.a * t.d - t.b * t.c; + if det.abs() < 1e-12 { + return Some(t); + } + Some(Transform { + a: t.d / det, + b: -t.b / det, + c: -t.c / det, + d: t.a / det, + tx: (t.b * t.ty - t.d * t.tx) / det, + ty: (t.c * t.tx - t.a * t.ty) / det, + }) +} + +fn matrix(value: Option<&Value>) -> Option { + let t = value?.as_array()?; + if t.len() < 2 { + return None; + } + let r0 = t[0].as_array()?; + let r1 = t[1].as_array()?; + if r0.len() < 3 || r1.len() < 3 { + return None; + } + Some(Transform { + a: r0[0].as_f64().unwrap_or(1.0), + b: r0[1].as_f64().unwrap_or(0.0), + tx: r0[2].as_f64().unwrap_or(0.0), + c: r1[0].as_f64().unwrap_or(0.0), + d: r1[1].as_f64().unwrap_or(1.0), + ty: r1[2].as_f64().unwrap_or(0.0), + }) +} + +fn relative_transform(raw: &Value) -> Transform { + let Some(t) = raw.get("relativeTransform").and_then(Value::as_array) else { + return Transform::IDENTITY; + }; + if t.len() < 2 { + return Transform::IDENTITY; + } + let at = |row: usize, col: usize, default: f64| { + t[row] + .as_array() + .and_then(|r| r.get(col)) + .and_then(Value::as_f64) + .unwrap_or(default) + }; + Transform { + a: at(0, 0, 1.0), + b: at(0, 1, 0.0), + tx: at(0, 2, 0.0), + c: at(1, 0, 0.0), + d: at(1, 1, 1.0), + ty: at(1, 2, 0.0), + } +} + +fn size(raw: &Value) -> Size { + let mut w = raw.get("size").and_then(|v| f64_at(v, "x")).unwrap_or(0.0); + let mut h = raw.get("size").and_then(|v| f64_at(v, "y")).unwrap_or(0.0); + if (w <= 0.0 || h <= 0.0) + && let Some(bb) = raw.get("absoluteBoundingBox") + { + if w <= 0.0 { + w = f64_at(bb, "width").unwrap_or(w); + } + if h <= 0.0 { + h = f64_at(bb, "height").unwrap_or(h); + } + } + Size { + width: w, + height: h, + } +} + +fn corner_radii(raw: &Value) -> CornerRadii { + if let Some(arr) = raw.get("rectangleCornerRadii").and_then(Value::as_array) + && arr.len() >= 4 + { + let r = |i: usize| arr[i].as_f64().unwrap_or(0.0).max(0.0); + return CornerRadii { + top_left: r(0), + top_right: r(1), + bottom_right: r(2), + bottom_left: r(3), + }; + } + let uniform = f64_at(raw, "cornerRadius").unwrap_or(0.0).max(0.0); + if uniform > 0.0 { + return CornerRadii::uniform(uniform); + } + // Mixed radii: plugin exports drop `cornerRadius` and carry each corner. + let r = |key: &str| f64_at(raw, key).unwrap_or(0.0).max(0.0); + CornerRadii { + top_left: r("topLeftRadius"), + top_right: r("topRightRadius"), + bottom_right: r("bottomRightRadius"), + bottom_left: r("bottomLeftRadius"), + } +} + +fn absolute_bounds(raw: &Value) -> Option { + let bb = raw.get("absoluteBoundingBox")?; + Some(Rect { + x: f64_at(bb, "x")?, + y: f64_at(bb, "y")?, + width: f64_at(bb, "width")?, + height: f64_at(bb, "height")?, + }) +} + +fn blend_mode(raw: &Value) -> BlendMode { + match str_at(raw, "blendMode") { + Some("PASS_THROUGH") => BlendMode::PassThrough, + Some("MULTIPLY") => BlendMode::Multiply, + Some("SCREEN") => BlendMode::Screen, + Some("OVERLAY") => BlendMode::Overlay, + Some("DARKEN") => BlendMode::Darken, + Some("LIGHTEN") => BlendMode::Lighten, + Some("COLOR_DODGE") => BlendMode::ColorDodge, + Some("COLOR_BURN") => BlendMode::ColorBurn, + Some("HARD_LIGHT") => BlendMode::HardLight, + Some("SOFT_LIGHT") => BlendMode::SoftLight, + Some("DIFFERENCE") => BlendMode::Difference, + Some("EXCLUSION") => BlendMode::Exclusion, + Some("HUE") => BlendMode::Hue, + Some("SATURATION") => BlendMode::Saturation, + Some("COLOR") => BlendMode::Color, + Some("LUMINOSITY") => BlendMode::Luminosity, + Some("LINEAR_BURN") => BlendMode::LinearBurn, + Some("LINEAR_DODGE") => BlendMode::LinearDodge, + _ => BlendMode::Normal, + } +} + +fn color(value: &Value) -> Option { + Some(Color { + r: value.get("r")?.as_f64()?, + g: value.get("g")?.as_f64()?, + b: value.get("b")?.as_f64()?, + a: value.get("a").and_then(Value::as_f64).unwrap_or(1.0), + }) +} + +// ── paints ─────────────────────────────────────────────────────────────── + +/// A paint stack. `node_raw` carries the image asset table (`imageFillAssets`) +/// for image paints found in `holder_raw[key]`. +fn paints(node_raw: &Value, holder_raw: &Value, key: &str, scene: &mut Scene) -> Vec { + let Some(arr) = holder_raw.get(key).and_then(Value::as_array) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|p| paint(node_raw, p, scene)) + .collect() +} + +fn paint(node_raw: &Value, p: &Value, scene: &mut Scene) -> Option { + let stops = || -> Option> { + let stops = p.get("gradientStops")?.as_array()?; + let out: Option> = stops + .iter() + .map(|s| { + Some(GradientStop { + position: f64_at(s, "position").unwrap_or(0.0), + color: color(s.get("color")?)?, + }) + }) + .collect(); + out.filter(|v| !v.is_empty()) + }; + let kind = match str_at(p, "type")? { + "SOLID" => PaintKind::Solid { + color: color(p.get("color")?)?, + }, + "GRADIENT_LINEAR" => PaintKind::LinearGradient { + stops: stops()?, + transform: gradient_matrix(p.get("gradientTransform")), + }, + "GRADIENT_RADIAL" => PaintKind::RadialGradient { + stops: stops()?, + transform: gradient_matrix(p.get("gradientTransform")), + }, + "GRADIENT_ANGULAR" => PaintKind::AngularGradient { + stops: stops()?, + transform: gradient_matrix(p.get("gradientTransform")), + }, + "IMAGE" => { + let source_ref = str_at(p, "imageHash").map(ToString::to_string); + let bytes = image_bytes(node_raw, source_ref.as_deref()); + let mime = sniff_image_mime(&bytes); + PaintKind::Image { + asset: scene.add_asset(mime, bytes), + source_ref, + scale_mode: match str_at(p, "scaleMode") { + Some("FIT") => ImageScaleMode::Fit, + Some("CROP") => ImageScaleMode::Crop, + Some("TILE") => ImageScaleMode::Tile, + Some("STRETCH") => ImageScaleMode::Stretch, + _ => ImageScaleMode::Fill, + }, + // A tile is placed by its scaling factor (image px → node + // px); the other modes by Figma's image transform. + transform: if str_at(p, "scaleMode") == Some("TILE") { + f64_at(p, "scalingFactor").map(|s| Transform { + a: s, + b: 0.0, + c: 0.0, + d: s, + tx: 0.0, + ty: 0.0, + }) + } else { + matrix(p.get("imageTransform")) + }, + } + } + _ => return None, + }; + Some(Paint { + kind, + opacity: f64_at(p, "opacity").unwrap_or(1.0), + blend: blend_mode(p), + visible: bool_at(p, "visible").unwrap_or(true), + }) +} + +/// 1×1 placeholders used when an export carries an image reference but not +/// the bytes, so image-fill structure survives. One parity fixture pins a +/// distinct placeholder to its hash. +const IMAGE_PLACEHOLDER_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const IMAGE_SAMPLING_PLACEHOLDER_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4MDD4DwAE/gH/5pEq9AAAAABJRU5ErkJggg=="; + +fn image_bytes(node_raw: &Value, hash: Option<&str>) -> Vec { + let embedded = node_raw + .get("imageFillAssets") + .and_then(Value::as_object) + .and_then(|assets| { + let v = assets.get(hash?)?; + v.as_str() + .or_else(|| v.get("base64").and_then(Value::as_str)) + }); + let b64 = embedded.unwrap_or(match hash { + Some("b6f8e2adf979577a15f43ba46ba26289a0ef23f3") => IMAGE_SAMPLING_PLACEHOLDER_B64, + _ => IMAGE_PLACEHOLDER_B64, + }); + base64::engine::general_purpose::STANDARD + .decode(b64) + .unwrap_or_default() +} + +fn sniff_image_mime(bytes: &[u8]) -> &'static str { + match bytes { + [0x89, b'P', b'N', b'G', ..] => "image/png", + [0xFF, 0xD8, ..] => "image/jpeg", + [b'G', b'I', b'F', ..] => "image/gif", + [ + b'R', + b'I', + b'F', + b'F', + _, + _, + _, + _, + b'W', + b'E', + b'B', + b'P', + .., + ] => "image/webp", + _ => "application/octet-stream", + } +} + +fn stroke(raw: &Value, scene: &mut Scene) -> Stroke { + let enum_at = |key: &str| raw.get(key).cloned(); + Stroke { + paints: paints(raw, raw, "strokes", scene), + weight: f64_at(raw, "strokeWeight").unwrap_or(1.0), + cap: enum_at("strokeCap") + .and_then(|v| { + serde_json::from_value::(v).ok() + }) + .map_or(StrokeCap::None, map_cap), + join: match str_at(raw, "strokeJoin") { + Some("BEVEL") => StrokeJoin::Bevel, + Some("ROUND") => StrokeJoin::Round, + _ => StrokeJoin::Miter, + }, + align: match str_at(raw, "strokeAlign") { + Some("INSIDE") => StrokeAlign::Inside, + Some("OUTSIDE") => StrokeAlign::Outside, + _ => StrokeAlign::Center, + }, + miter_limit: f64_at(raw, "strokeMiterLimit").unwrap_or(4.0), + dash_pattern: raw + .get("dashPattern") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_f64).collect()) + .unwrap_or_default(), + dash_offset: raw + .get("strokeDashes") + .and_then(|v| f64_at(v, "offset")) + .unwrap_or(0.0), + } +} + +fn map_cap(cap: figma_api::models::has_geometry_trait::StrokeCap) -> StrokeCap { + use figma_api::models::has_geometry_trait::StrokeCap as F; + match cap { + F::None => StrokeCap::None, + F::Round => StrokeCap::Round, + F::Square => StrokeCap::Square, + F::LineArrow => StrokeCap::LineArrow, + F::TriangleArrow => StrokeCap::TriangleArrow, + F::DiamondFilled => StrokeCap::DiamondFilled, + F::CircleFilled => StrokeCap::CircleFilled, + F::TriangleFilled => StrokeCap::TriangleFilled, + F::WashiTape1 => StrokeCap::WashiTape1, + F::WashiTape2 => StrokeCap::WashiTape2, + F::WashiTape3 => StrokeCap::WashiTape3, + F::WashiTape4 => StrokeCap::WashiTape4, + F::WashiTape5 => StrokeCap::WashiTape5, + F::WashiTape6 => StrokeCap::WashiTape6, + } +} + +fn effects(raw: &Value) -> Vec { + let Some(arr) = raw.get("effects").and_then(Value::as_array) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|e| { + let kind = match str_at(e, "type")? { + "DROP_SHADOW" => EffectKind::DropShadow, + "INNER_SHADOW" => EffectKind::InnerShadow, + "LAYER_BLUR" => EffectKind::LayerBlur, + "BACKGROUND_BLUR" => EffectKind::BackgroundBlur, + _ => return None, + }; + // Effect colors carry their own `opacity`; fold it in here. + let color = e + .get("color") + .and_then(color) + .map(|c| { + let opacity = f64_at(e, "opacity").unwrap_or(1.0).clamp(0.0, 1.0); + Color { + r: c.r.clamp(0.0, 1.0), + g: c.g.clamp(0.0, 1.0), + b: c.b.clamp(0.0, 1.0), + a: (c.a.clamp(0.0, 1.0) * opacity).clamp(0.0, 1.0), + } + }) + .unwrap_or(Color::rgba(0.0, 0.0, 0.0, 0.25)); + Some(Effect { + kind, + visible: bool_at(e, "visible").unwrap_or(true), + color, + offset_x: e.get("offset").and_then(|o| f64_at(o, "x")).unwrap_or(0.0), + offset_y: e.get("offset").and_then(|o| f64_at(o, "y")).unwrap_or(0.0), + radius: f64_at(e, "radius").unwrap_or(0.0), + spread: f64_at(e, "spread").unwrap_or(0.0), + }) + }) + .collect() +} + +fn path_strings(raw: &Value, key: &str) -> Vec { + raw.get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| str_at(entry, "path").or_else(|| str_at(entry, "data"))) + .filter(|s| !s.trim().is_empty()) + .map(ToString::to_string) + .collect() +} + +// ── layout ─────────────────────────────────────────────────────────────── + +fn layout(raw: &Value) -> (Option, LayoutChild) { + let Some(al) = crate::figma::model::get_auto_layout_projection(raw) else { + return (None, LayoutChild::default()); + }; + let container = al + .mode + .and_then(|m| match m { + AutoLayoutMode::Horizontal => Some(LayoutDirection::Horizontal), + AutoLayoutMode::Vertical => Some(LayoutDirection::Vertical), + AutoLayoutMode::None => None, + }) + .map(|direction| AutoLayout { + direction, + gap: al.item_spacing.unwrap_or(0.0), + padding_left: al.padding_left.unwrap_or(0.0), + padding_right: al.padding_right.unwrap_or(0.0), + padding_top: al.padding_top.unwrap_or(0.0), + padding_bottom: al.padding_bottom.unwrap_or(0.0), + wrap: matches!(al.wrap, Some(AutoLayoutWrap::Wrap)), + main_align: al.primary_axis_align_items.map(|a| match a { + AutoLayoutPrimaryAlign::Min => MainAxisAlign::Start, + AutoLayoutPrimaryAlign::Center => MainAxisAlign::Center, + AutoLayoutPrimaryAlign::Max => MainAxisAlign::End, + AutoLayoutPrimaryAlign::SpaceBetween => MainAxisAlign::SpaceBetween, + }), + hug_main: matches!( + al.primary_axis_sizing_mode, + Some(crate::figma::model::AutoLayoutSizingMode::Hug) + ), + hug_cross: matches!( + al.counter_axis_sizing_mode, + Some(crate::figma::model::AutoLayoutSizingMode::Hug) + ), + cross_align: al.counter_axis_align_items.map(|a| match a { + AutoLayoutCounterAlign::Min => CrossAxisAlign::Start, + AutoLayoutCounterAlign::Center => CrossAxisAlign::Center, + AutoLayoutCounterAlign::Max => CrossAxisAlign::End, + AutoLayoutCounterAlign::Baseline => CrossAxisAlign::Baseline, + }), + }); + let child = LayoutChild { + grow: al.grow, + align_self: al.align_self.and_then(|a| match a { + AutoLayoutAlignSelf::Auto | AutoLayoutAlignSelf::Inherit => None, + AutoLayoutAlignSelf::Min => Some(AlignSelf::Start), + AutoLayoutAlignSelf::Center => Some(AlignSelf::Center), + AutoLayoutAlignSelf::Max => Some(AlignSelf::End), + AutoLayoutAlignSelf::Stretch => Some(AlignSelf::Stretch), + }), + absolute: matches!(al.positioning, Some(AutoLayoutPositioning::Absolute)), + min_width: al.min_width, + min_height: al.min_height, + max_width: al.max_width, + max_height: al.max_height, + resize_x: None, + resize_y: None, + }; + (container, child) +} + +// ── text ───────────────────────────────────────────────────────────────── + +fn text_data(raw: &Value) -> TextData { + let text_case = str_at(raw, "textCase") + .or_else(|| raw.get("style").and_then(|s| str_at(s, "textCase"))) + .map_or(TextCase::Original, |c| match c { + "UPPER" => TextCase::Upper, + "LOWER" => TextCase::Lower, + "TITLE" => TextCase::Title, + "SMALL_CAPS" => TextCase::SmallCaps, + "SMALL_CAPS_FORCED" => TextCase::SmallCapsForced, + _ => TextCase::Original, + }); + TextData { + characters: str_at(raw, "characters").unwrap_or_default().to_string(), + font_family: str_at(raw, "fontFamily") + .or_else(|| raw.get("style").and_then(|s| str_at(s, "fontFamily"))) + .map(ToString::to_string), + font_size: style_f64(raw, "fontSize", "fontSize").unwrap_or(14.0), + font_weight: style_f64(raw, "fontWeight", "fontWeight"), + font_style: None, + font_width: None, + line_height: style_f64(raw, "lineHeightPx", "lineHeightPx").filter(|v| *v > 0.0), + letter_spacing: style_f64(raw, "letterSpacingPx", "letterSpacing").unwrap_or(0.0), + text_case, + align: crate::document::proto::TextAlign::default(), + runs: Vec::new(), + } +} + +fn text_runs(raw: &Value, scene: &mut Scene) -> Vec { + let Some(segments) = raw.get("styledTextSegments").and_then(Value::as_array) else { + return Vec::new(); + }; + segments + .iter() + .filter_map(|seg| { + let text = str_at(seg, "characters").unwrap_or_default(); + if text.is_empty() { + return None; + } + Some(TextRun { + text: text.to_string(), + fills: paints(seg, seg, "fills", scene), + font_family: seg + .get("fontName") + .and_then(|f| str_at(f, "family")) + .map(ToString::to_string), + font_size: f64_at(seg, "fontSize"), + font_weight: f64_at(seg, "fontWeight"), + font_style: str_at(seg, "fontStyle") + .or_else(|| seg.get("fontName").and_then(|f| str_at(f, "style"))) + .map(ToString::to_string), + font_width: None, + letter_spacing: f64_at(seg, "letterSpacingPx") + .or_else(|| seg.get("letterSpacing").and_then(|l| f64_at(l, "value"))), + }) + }) + .collect() +} diff --git a/crates/frame/src/figma/mod.rs b/crates/frame/src/figma/mod.rs new file mode 100644 index 00000000..a081a063 --- /dev/null +++ b/crates/frame/src/figma/mod.rs @@ -0,0 +1,10 @@ +//! Figma: the REST client, its model and the importer into frame documents. + +#[cfg(feature = "figma")] +pub mod model; + +#[cfg(feature = "figma")] +pub mod rest; + +#[cfg(feature = "figma")] +pub mod import; diff --git a/crates/frame-proto/src/color.rs b/crates/frame/src/figma/model/color.rs similarity index 100% rename from crates/frame-proto/src/color.rs rename to crates/frame/src/figma/model/color.rs diff --git a/crates/frame-proto/src/diff.rs b/crates/frame/src/figma/model/diff.rs similarity index 92% rename from crates/frame-proto/src/diff.rs rename to crates/frame/src/figma/model/diff.rs index a2d80444..a04f7e33 100644 --- a/crates/frame-proto/src/diff.rs +++ b/crates/frame/src/figma/model/diff.rs @@ -1,4 +1,4 @@ -use crate::id::NodeId; +use crate::figma::model::id::NodeId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/frame-proto/src/document.rs b/crates/frame/src/figma/model/document.rs similarity index 92% rename from crates/frame-proto/src/document.rs rename to crates/frame/src/figma/model/document.rs index 166ab78d..f19b824f 100644 --- a/crates/frame-proto/src/document.rs +++ b/crates/frame/src/figma/model/document.rs @@ -1,6 +1,6 @@ -use crate::figma::{FigmaDocumentMeta, FigmaPayload}; -use crate::id::{DocumentId, NodeId}; -use crate::node::FrameNode; +use crate::figma::model::figma::{FigmaDocumentMeta, FigmaPayload}; +use crate::figma::model::id::{DocumentId, NodeId}; +use crate::figma::model::node::FrameNode; use figma_api::models; use serde::{Deserialize, Serialize}; use std::io::Read; @@ -80,10 +80,10 @@ impl FrameDocument { } pub fn from_source_bytes(bytes: &[u8]) -> Result { - if let Ok(text) = std::str::from_utf8(bytes) { - if let Ok(value) = serde_json::from_str::(text) { - return Self::from_source_value(value); - } + if let Ok(text) = std::str::from_utf8(bytes) + && let Ok(value) = serde_json::from_str::(text) + { + return Self::from_source_value(value); } Self::from_fig_zip_bytes(bytes) } @@ -93,10 +93,10 @@ impl FrameDocument { pub fn from_source_value(value: serde_json::Value) -> Result { if let Some(obj) = value.as_object() { // FTS export: "schema" starting with "fts.figma.export" - if let Some(schema) = obj.get("schema").and_then(|v| v.as_str()) { - if schema.starts_with("fts.figma.export") { - return Self::from_fts_export_value(value); - } + if let Some(schema) = obj.get("schema").and_then(|v| v.as_str()) + && schema.starts_with("fts.figma.export") + { + return Self::from_fts_export_value(value); } // Grida bridge: "pages" array + "source" as string if obj.contains_key("pages") && obj.get("source").and_then(|v| v.as_str()).is_some() { @@ -144,11 +144,6 @@ impl FrameDocument { } impl FrameDocument { - fn from_fts_export_json(json: &str) -> Result { - let export: FtsExportDocument = serde_json::from_str(json)?; - Self::build_from_fts_export(export) - } - fn from_fts_export_value(value: serde_json::Value) -> Result { let export: FtsExportDocument = serde_json::from_value(value)?; Self::build_from_fts_export(export) @@ -248,11 +243,6 @@ impl FrameDocument { }) } - fn from_grida_bridge_json(json: &str) -> Result { - let bridge: GridaBridgeDocument = serde_json::from_str(json)?; - Self::build_from_grida_bridge(bridge) - } - fn from_grida_bridge_value(value: serde_json::Value) -> Result { let bridge: GridaBridgeDocument = serde_json::from_value(value)?; Self::build_from_grida_bridge(bridge) @@ -503,10 +493,10 @@ fn flatten_synthetic_subtree( } fn ensure_object_field(target: &mut serde_json::Value, key: &str, default: serde_json::Value) { - if let Some(obj) = target.as_object_mut() { - if obj.get(key).is_none() { - obj.insert(key.to_string(), default); - } + if let Some(obj) = target.as_object_mut() + && obj.get(key).is_none() + { + obj.insert(key.to_string(), default); } } @@ -609,30 +599,28 @@ fn hydrate_node_assets_recursive( .cloned() .unwrap_or_default(); - if exports_obj.get("svgBase64").is_none() { - if let Some(asset_id) = svg_asset_id { - if let Some(asset) = assets.get(asset_id) { - if asset.encoding == "base64" && !asset.data.is_empty() { - exports_obj.insert( - "svgBase64".to_string(), - serde_json::Value::String(asset.data.clone()), - ); - } - } - } + if exports_obj.get("svgBase64").is_none() + && let Some(asset_id) = svg_asset_id + && let Some(asset) = assets.get(asset_id) + && asset.encoding == "base64" + && !asset.data.is_empty() + { + exports_obj.insert( + "svgBase64".to_string(), + serde_json::Value::String(asset.data.clone()), + ); } - if exports_obj.get("pngBase64").is_none() { - if let Some(asset_id) = png_asset_id { - if let Some(asset) = assets.get(asset_id) { - if asset.encoding == "base64" && !asset.data.is_empty() { - exports_obj.insert( - "pngBase64".to_string(), - serde_json::Value::String(asset.data.clone()), - ); - } - } - } + if exports_obj.get("pngBase64").is_none() + && let Some(asset_id) = png_asset_id + && let Some(asset) = assets.get(asset_id) + && asset.encoding == "base64" + && !asset.data.is_empty() + { + exports_obj.insert( + "pngBase64".to_string(), + serde_json::Value::String(asset.data.clone()), + ); } if !exports_obj.is_empty() { diff --git a/crates/frame-proto/src/effect.rs b/crates/frame/src/figma/model/effect.rs similarity index 100% rename from crates/frame-proto/src/effect.rs rename to crates/frame/src/figma/model/effect.rs diff --git a/crates/frame-proto/src/figma.rs b/crates/frame/src/figma/model/figma.rs similarity index 100% rename from crates/frame-proto/src/figma.rs rename to crates/frame/src/figma/model/figma.rs diff --git a/crates/frame-proto/src/geometry.rs b/crates/frame/src/figma/model/geometry.rs similarity index 100% rename from crates/frame-proto/src/geometry.rs rename to crates/frame/src/figma/model/geometry.rs diff --git a/crates/frame-proto/src/history.rs b/crates/frame/src/figma/model/history.rs similarity index 88% rename from crates/frame-proto/src/history.rs rename to crates/frame/src/figma/model/history.rs index 76516984..b6e8f9a9 100644 --- a/crates/frame-proto/src/history.rs +++ b/crates/frame/src/figma/model/history.rs @@ -1,4 +1,4 @@ -use crate::diff::DocumentDiff; +use crate::figma::model::diff::DocumentDiff; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/frame-proto/src/id.rs b/crates/frame/src/figma/model/id.rs similarity index 100% rename from crates/frame-proto/src/id.rs rename to crates/frame/src/figma/model/id.rs diff --git a/crates/frame-proto/src/layout.rs b/crates/frame/src/figma/model/layout.rs similarity index 100% rename from crates/frame-proto/src/layout.rs rename to crates/frame/src/figma/model/layout.rs diff --git a/crates/frame-proto/src/lib.rs b/crates/frame/src/figma/model/mod.rs similarity index 88% rename from crates/frame-proto/src/lib.rs rename to crates/frame/src/figma/model/mod.rs index 45fcd952..a63ace45 100644 --- a/crates/frame-proto/src/lib.rs +++ b/crates/frame/src/figma/model/mod.rs @@ -33,8 +33,8 @@ pub use node::{ }; pub use paint::*; pub use projection::{ - classify_node_type, get_auto_layout_projection, project_node, RenderNodeClass, - RenderNodeProjection, RenderTextProjection, + RenderNodeClass, RenderNodeProjection, RenderTextProjection, classify_node_type, + get_auto_layout_projection, project_node, }; pub use typography::*; pub use vector::*; diff --git a/crates/frame-proto/src/node.rs b/crates/frame/src/figma/model/node.rs similarity index 98% rename from crates/frame-proto/src/node.rs rename to crates/frame/src/figma/model/node.rs index ceb379ef..ef738678 100644 --- a/crates/frame-proto/src/node.rs +++ b/crates/frame/src/figma/model/node.rs @@ -1,5 +1,5 @@ -use crate::figma::FigmaNodeData; -use crate::id::NodeId; +use crate::figma::model::figma::FigmaNodeData; +use crate::figma::model::id::NodeId; use figma_api::models; use serde::{Deserialize, Serialize}; diff --git a/crates/frame-proto/src/paint.rs b/crates/frame/src/figma/model/paint.rs similarity index 100% rename from crates/frame-proto/src/paint.rs rename to crates/frame/src/figma/model/paint.rs diff --git a/crates/frame-proto/src/projection.rs b/crates/frame/src/figma/model/projection.rs similarity index 80% rename from crates/frame-proto/src/projection.rs rename to crates/frame/src/figma/model/projection.rs index 00ace8f8..f1c2d952 100644 --- a/crates/frame-proto/src/projection.rs +++ b/crates/frame/src/figma/model/projection.rs @@ -1,10 +1,10 @@ -use crate::document::FrameDocument; -use crate::id::NodeId; -use crate::layout::{ +use crate::figma::model::document::FrameDocument; +use crate::figma::model::id::NodeId; +use crate::figma::model::layout::{ AutoLayout, AutoLayoutAlignSelf, AutoLayoutCounterAlign, AutoLayoutMode, AutoLayoutPositioning, AutoLayoutPrimaryAlign, AutoLayoutSizingMode, AutoLayoutWrap, }; -use crate::node::FrameNode; +use crate::figma::model::node::FrameNode; use figma_api::models; use serde::{Deserialize, Serialize}; @@ -569,120 +569,3 @@ where .filter_map(|entry| serde_json::from_value(entry.clone()).ok()) .collect() } - -#[cfg(test)] -mod tests { - use super::*; - use crate::node::FrameNode; - - #[test] - fn projects_typed_stroke_and_blend_fields() { - let node = FrameNode::new_synthetic(serde_json::json!({ - "id": "10:10", - "name": "Shape", - "type": "RECTANGLE", - "visible": true, - "locked": false, - "blendMode": "MULTIPLY", - "strokeWeight": 2.5, - "strokeAlign": "OUTSIDE", - "strokeJoin": "ROUND", - "strokeCap": "SQUARE", - "strokeMiterLimit": 12.0, - "dashPattern": [4, 2], - "isMask": true, - "maskType": "ALPHA", - "clipsContent": true, - "effects": [ - { "type": "DROP_SHADOW", "visible": true }, - { "type": "INNER_SHADOW", "visible": true }, - { "type": "LAYER_BLUR", "visible": true }, - { "type": "BACKGROUND_BLUR", "visible": true } - ], - "fills": [ - { "type": "IMAGE", "visible": true }, - { "type": "GRADIENT_LINEAR", "visible": true } - ], - "exports": { "svgBase64": "Zm9v" }, - "assetRefs": { "svg": "svg:abc" }, - "imageFillAssets": { "img1": "Zm9v" }, - "componentId": "comp:1", - "componentSetId": "set:1", - "variantProperties": { "Size": "Large" }, - "componentPropertyReferences": { "label": "text" }, - "layoutSizingHorizontal": "HUG", - "layoutSizingVertical": "FILL", - "cornerSmoothing": 0.42, - "explicitVariableModes": { "col": "mode1" }, - "boundVariables": { "fills": [{ "id": "var1" }] }, - "pluginData": { "fts": "ok" }, - "sharedPluginData": { "org": { "k": "v" } } - })); - - let p = project_node(&node); - assert_eq!(p.blend_mode, Some(models::BlendMode::Multiply)); - assert!(p.feature_flags.has_non_normal_blend); - assert!(p.feature_flags.has_image_fill); - assert!(p.feature_flags.has_gradient_fill); - assert!(p.feature_flags.has_drop_shadow); - assert!(p.feature_flags.has_inner_shadow); - assert!(p.feature_flags.has_layer_blur); - assert!(p.feature_flags.has_background_blur); - assert!(p.feature_flags.has_mask); - assert!(p.feature_flags.has_alpha_mask); - assert!(!p.feature_flags.has_luminance_mask); - assert!(p.feature_flags.has_clip); - assert!(p.feature_flags.has_svg_export); - let stroke = p.stroke_style.expect("stroke style projected"); - assert_eq!(stroke.weight, Some(2.5)); - assert_eq!( - stroke.align, - Some(models::has_geometry_trait::StrokeAlign::Outside) - ); - assert_eq!( - stroke.join, - Some(models::has_geometry_trait::StrokeJoin::Round) - ); - assert_eq!( - stroke.cap, - Some(models::has_geometry_trait::StrokeCap::Square) - ); - assert_eq!(stroke.miter_limit, Some(12.0)); - assert_eq!(stroke.dash_pattern, vec![4.0, 2.0]); - - let comp = p.component.expect("component projection"); - assert_eq!(comp.component_id.as_deref(), Some("comp:1")); - assert_eq!(comp.component_set_id.as_deref(), Some("set:1")); - assert_eq!( - comp.variant_properties.get("Size").map(String::as_str), - Some("Large") - ); - assert_eq!( - comp.component_property_references - .get("label") - .map(String::as_str), - Some("text") - ); - - let assets = p.asset_refs.expect("asset refs projection"); - assert_eq!(assets.svg.as_deref(), Some("svg:abc")); - assert_eq!( - assets.image_fill_assets.get("img1").map(String::as_str), - Some("Zm9v") - ); - assert_eq!(p.corner_smoothing, Some(0.42)); - assert_eq!(p.layout_sizing_horizontal.as_deref(), Some("HUG")); - assert_eq!(p.layout_sizing_vertical.as_deref(), Some("FILL")); - assert!(p.explicit_variable_modes.is_some()); - assert!(p.bound_variables.is_some()); - assert!(p.plugin_data.is_some()); - assert!(p.shared_plugin_data.is_some()); - } - - #[test] - fn pass_through_blend_is_not_treated_as_non_normal() { - assert!(!is_non_normal_blend_mode("PASS_THROUGH")); - assert!(!is_non_normal_blend_mode("NORMAL")); - assert!(is_non_normal_blend_mode("MULTIPLY")); - } -} diff --git a/crates/frame-proto/src/typography.rs b/crates/frame/src/figma/model/typography.rs similarity index 100% rename from crates/frame-proto/src/typography.rs rename to crates/frame/src/figma/model/typography.rs diff --git a/crates/frame-proto/src/vector.rs b/crates/frame/src/figma/model/vector.rs similarity index 100% rename from crates/frame-proto/src/vector.rs rename to crates/frame/src/figma/model/vector.rs diff --git a/crates/frame-import/src/converter.rs b/crates/frame/src/figma/rest/converter.rs similarity index 53% rename from crates/frame-import/src/converter.rs rename to crates/frame/src/figma/rest/converter.rs index b65e888c..cc4be773 100644 --- a/crates/frame-import/src/converter.rs +++ b/crates/frame/src/figma/rest/converter.rs @@ -1,6 +1,6 @@ -use crate::error::ImportError; +use crate::figma::model::FrameDocument; +use crate::figma::rest::error::ImportError; use figma_api::models; -use frame_proto::FrameDocument; use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -40,12 +40,12 @@ pub fn import_get_file(file: &models::GetFile) -> Result Result { - if let Ok(text) = std::str::from_utf8(bytes) { - if let Ok(mut value) = serde_json::from_str::(text) { - let mut _rewrites = 0; - normalize_layout_compat_value(&mut value, &mut _rewrites); - return Ok(FrameDocument::from_source_value(value)?); - } + if let Ok(text) = std::str::from_utf8(bytes) + && let Ok(mut value) = serde_json::from_str::(text) + { + let mut _rewrites = 0; + normalize_layout_compat_value(&mut value, &mut _rewrites); + return Ok(FrameDocument::from_source_value(value)?); } Ok(FrameDocument::from_source_bytes(bytes)?) } @@ -55,21 +55,21 @@ pub fn import_figma_bytes(bytes: &[u8]) -> Result { pub fn import_figma_bytes_with_diagnostics( bytes: &[u8], ) -> Result<(FrameDocument, ImportDiagnostics), ImportError> { - if let Ok(text) = std::str::from_utf8(bytes) { - if let Ok(mut value) = serde_json::from_str::(text) { - let mut normalized_aliases = 0; - normalize_layout_compat_value(&mut value, &mut normalized_aliases); - let diagnostics = ImportDiagnostics { - source_schema: value - .get("schema") - .and_then(|s| s.as_str()) - .map(ToString::to_string), - normalized_aliases, - unsupported_node_keys: collect_unsupported_node_keys(&value), - }; - let doc = FrameDocument::from_source_value(value)?; - return Ok((doc, diagnostics)); - } + if let Ok(text) = std::str::from_utf8(bytes) + && let Ok(mut value) = serde_json::from_str::(text) + { + let mut normalized_aliases = 0; + normalize_layout_compat_value(&mut value, &mut normalized_aliases); + let diagnostics = ImportDiagnostics { + source_schema: value + .get("schema") + .and_then(|s| s.as_str()) + .map(ToString::to_string), + normalized_aliases, + unsupported_node_keys: collect_unsupported_node_keys(&value), + }; + let doc = FrameDocument::from_source_value(value)?; + return Ok((doc, diagnostics)); } let doc = FrameDocument::from_source_bytes(bytes)?; Ok((doc, ImportDiagnostics::default())) @@ -276,175 +276,3 @@ fn is_supported_projection_key(key: &str) -> bool { | "rectangleCornerRadii" ) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn imports_minimal_get_file_json() { - let json = r#"{ - "name": "Test File", - "role": "viewer", - "lastModified": "2024-01-01T00:00:00Z", - "editorType": "figma", - "version": "1", - "document": { - "id": "0:0", - "name": "Document", - "type": "DOCUMENT", - "scrollBehavior": "SCROLLS", - "children": [ - { - "id": "0:1", - "name": "Page 1", - "type": "CANVAS", - "scrollBehavior": "SCROLLS", - "children": [], - "backgroundColor": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 }, - "prototypeStartNodeID": null, - "flowStartingPoints": [], - "prototypeDevice": { "type": "NONE", "rotation": "NONE" } - } - ] - }, - "components": {}, - "componentSets": {}, - "schemaVersion": 0, - "styles": {} - }"#; - - let doc = import_figma_file(json).expect("import should succeed"); - assert_eq!(doc.name, "Test File"); - assert_eq!(doc.pages.len(), 1); - assert!(doc.node_count() >= 2); - } - - #[test] - fn imports_from_json_bytes() { - let json = r#"{ - "name": "Bytes Test", - "role": "viewer", - "lastModified": "2024-01-01T00:00:00Z", - "editorType": "figma", - "version": "1", - "document": { - "id": "0:0", - "name": "Document", - "type": "DOCUMENT", - "scrollBehavior": "SCROLLS", - "children": [ - { - "id": "0:1", - "name": "Page 1", - "type": "CANVAS", - "scrollBehavior": "SCROLLS", - "children": [], - "backgroundColor": { "r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0 }, - "prototypeStartNodeID": null, - "flowStartingPoints": [], - "prototypeDevice": { "type": "NONE", "rotation": "NONE" } - } - ] - }, - "components": {}, - "componentSets": {}, - "schemaVersion": 0, - "styles": {} - }"#; - - let doc = import_figma_bytes(json.as_bytes()).expect("import from bytes should succeed"); - assert_eq!(doc.name, "Bytes Test"); - assert_eq!(doc.pages.len(), 1); - } - - #[test] - fn rejects_non_json_binary_fig_bytes_for_now() { - let err = import_figma_bytes(&[0x89, b'F', b'I', b'G']).expect_err("should fail"); - let message = err.to_string(); - assert!(message.contains("not a supported .fig ZIP container")); - } - - #[test] - fn imports_fts_export_v2_with_asset_refs() { - let json = r#"{ - "schema": "fts.figma.export/v2", - "generatedAt": "2026-02-15T00:00:00.000Z", - "source": { - "editorType": "figma", - "fileKey": "abc", - "page": { "id": "0:1", "name": "Canvas" } - }, - "assets": [ - { - "id": "svg:deadbeef", - "kind": "svg", - "mime": "image/svg+xml", - "encoding": "base64", - "data": "PHN2Zy8+", - "byteLength": 6 - } - ], - "nodes": [ - { - "id": "10:1", - "name": "Main", - "type": "FRAME", - "visible": true, - "assetRefs": { "svg": "svg:deadbeef" }, - "children": [] - } - ] - }"#; - - let doc = import_figma_bytes(json.as_bytes()).expect("import from v2 should succeed"); - let main = doc - .get_by_figma_id("10:1") - .expect("main frame should exist in figma index"); - let svg_base64 = main - .raw - .get("exports") - .and_then(|v| v.get("svgBase64")) - .and_then(|v| v.as_str()) - .unwrap_or_default(); - assert_eq!(svg_base64, "PHN2Zy8+"); - } - - #[test] - fn diagnostics_reports_alias_rewrites_and_unknown_keys() { - let json = r#"{ - "schema": "fts.figma.export/v2", - "nodes": [{ - "id": "1:1", - "name": "Node", - "type": "FRAME", - "layout_mode": "HORIZONTAL", - "custom_we_dont_support_yet": true, - "children": [] - }], - "source": { "editorType": "figma", "page": { "id": "0:1", "name": "Canvas" } }, - "selection": { "ids": [], "names": [], "totalRoots": 0, "totalNodes": 0 }, - "options": { "includeSvg": true, "includePng": false, "maxDepth": 4 }, - "generatedAt": "2026-02-15T00:00:00.000Z" - }"#; - - let (_doc, diagnostics) = - import_figma_bytes_with_diagnostics(json.as_bytes()).expect("import should succeed"); - assert_eq!( - diagnostics.source_schema.as_deref(), - Some("fts.figma.export/v2") - ); - assert!(diagnostics.normalized_aliases >= 1); - assert_eq!( - diagnostics - .unsupported_node_keys - .get("custom_we_dont_support_yet") - .copied(), - Some(1) - ); - assert_eq!( - diagnostics.top_unsupported_keys(1), - vec![("custom_we_dont_support_yet".to_string(), 1)] - ); - } -} diff --git a/crates/frame-import/src/error.rs b/crates/frame/src/figma/rest/error.rs similarity index 79% rename from crates/frame-import/src/error.rs rename to crates/frame/src/figma/rest/error.rs index 5bbad2b6..870a96c3 100644 --- a/crates/frame-import/src/error.rs +++ b/crates/frame/src/figma/rest/error.rs @@ -1,4 +1,4 @@ -use frame_proto::document::FrameDocumentError; +use crate::figma::model::document::FrameDocumentError; use thiserror::Error; #[derive(Debug, Error)] diff --git a/crates/frame-import/src/lib.rs b/crates/frame/src/figma/rest/mod.rs similarity index 70% rename from crates/frame-import/src/lib.rs rename to crates/frame/src/figma/rest/mod.rs index 9ef3b66b..4e261eba 100644 --- a/crates/frame-import/src/lib.rs +++ b/crates/frame/src/figma/rest/mod.rs @@ -7,7 +7,7 @@ pub mod converter; pub mod error; pub use converter::{ - import_figma_bytes, import_figma_bytes_with_diagnostics, import_figma_file, import_get_file, - ImportDiagnostics, + ImportDiagnostics, import_figma_bytes, import_figma_bytes_with_diagnostics, import_figma_file, + import_get_file, }; pub use error::ImportError; diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index 135aed66..a976fbb6 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -1,5 +1,20 @@ -//! Frame — Figma-compatible design system. +//! Frame: the design and control-surface engine. //! -//! Facade crate that re-exports the frame sub-crates. +//! The document, surface, motion and feed protocols are always here; the +//! runtime, rendering, themes, file IO, libraries, Figma import, design +//! tools and the live and embedded hosts are each behind a feature. -pub use frame_proto; +pub mod design; +pub mod document; +#[cfg(feature = "embed")] +pub mod embed; +pub mod feed; +pub mod figma; +pub mod library; +#[cfg(feature = "live")] +pub mod live; +pub mod motion; +#[cfg(feature = "render")] +pub mod render; +pub mod surface; +pub mod theme; diff --git a/crates/frame/src/library/fs/mod.rs b/crates/frame/src/library/fs/mod.rs new file mode 100644 index 00000000..e4838215 --- /dev/null +++ b/crates/frame/src/library/fs/mod.rs @@ -0,0 +1,834 @@ +//! A [`SurfaceStore`] over a folder: every `.frame` markup file (the older +//! `.fm` too) and `.frame` bundle (a zip) under the root is a surface, `*.catalog.json` files are the engine +//! catalogs they were designed for. +//! +//! ```text +//! root/ +//! comp/07-vari-mu.frame surface `comp/07-vari-mu` +//! comp/07-vari-mu.frame.assets/ its textures and fonts (see `document-io`) +//! comp/07-vari-mu.meta.json optional: author, description, tags, license, forked_from, version +//! comp/07-vari-mu.png optional: the preview (else rendered by the previewer, if any) +//! comp/comp.catalog.json a catalog (served by `catalogs()`, tags surfaces that fit it) +//! ``` +//! +//! The index lives in memory, built by [`FsLibrary::rescan`]. [`FsLibrary::watch`] +//! rescans on a short interval, so an edit on disk (an agent's MCP autosave, +//! a person's editor) becomes a new version pushed to every subscriber. A +//! poll rather than OS notifications: it needs no platform backend, sees +//! atomic write-then-rename saves as one change, and a stat of a few +//! hundred files every 200 ms costs nothing. A markup surface's stamp +//! covers its `.frame.assets/` sidecar too, so replacing a texture is an edit +//! like any other. A file keeps its path when it is rewritten, so a library +//! of `.fm` files stays `.fm` while its surfaces stay markup; republishing in +//! a different format rewrites the same `.frame` path (a `.fm` moves to +//! `.frame` when it becomes a bundle). A file's content hash (markup and asset ids) decides +//! whether it changed, so a publish (which writes the file) doesn't come +//! back as a second update. + +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{Duration, SystemTime}; + +use crate::document::proto::Scene; +pub use crate::library::proto::*; +use crate::surface::proto::Catalog; +use architect::dispatch::CurrentThreadDispatcher; +use architect::{HasDispatcher, PubSub}; +use serde::{Deserialize, Serialize}; + +/// Renders a scene's thumbnail as PNG bytes (`None` = can't). +pub type Previewer = Arc Option> + Send + Sync>; + +/// Past versions kept per surface for `get(id, Some(version))`. +const HISTORY: usize = 16; +/// How often [`FsLibrary::watch`] looks at the folder. +pub const WATCH_INTERVAL: Duration = Duration::from_millis(200); + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +type Stamp = Option<(SystemTime, u64)>; + +fn stamp(path: &Path) -> Stamp { + let m = std::fs::metadata(path).ok()?; + Some((m.modified().ok()?, m.len())) +} + +fn hash(bytes: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + h.finish() +} + +/// `.meta.json`: what a file can't say about itself. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +struct Sidecar { + name: String, + author: String, + description: String, + tags: Vec, + license: String, + forked_from: Option, + version: u64, +} + +impl Sidecar { + fn is_empty(&self) -> bool { + self.author.is_empty() + && self.description.is_empty() + && self.tags.is_empty() + && self.license.is_empty() + && self.forked_from.is_none() + } +} + +struct Entry { + path: PathBuf, + /// [`crate::document::io::fingerprint`]: the file and its assets sidecar. + stamp: Option, + hash: u64, + meta: SurfaceMeta, + content: Content, + /// Older versions, newest last. + history: VecDeque<(u64, Content)>, +} + +impl Entry { + fn surface(&self) -> Surface { + Surface { + meta: self.meta.clone(), + content: self.content.clone(), + } + } + + fn update(&self) -> SurfaceUpdate { + SurfaceUpdate { + id: self.meta.id.clone(), + version: self.meta.version, + surface: Some(self.surface()), + } + } +} + +#[derive(Default)] +struct State { + entries: BTreeMap, + catalogs: BTreeMap, +} + +struct Inner { + root: PathBuf, + state: Mutex, + hubs: Mutex>>, + changes: PubSub, + previewer: Option, +} + +/// A folder of surfaces as a [`SurfaceStore`]. +#[derive(Clone, HasDispatcher)] +#[dispatch(CurrentThreadDispatcher)] +pub struct FsLibrary { + inner: Arc, +} + +/// What one scan found, published after the index lock is released. +#[derive(Default)] +struct Changes { + updates: Vec, + index: Vec, +} + +impl FsLibrary { + /// A library over `root` (scanned now). + /// + /// # Errors + /// When `root` isn't a readable directory. + pub fn open(root: impl Into) -> Result { + Self::open_with(root, None) + } + + /// [`Self::open`] with a previewer that renders thumbnails of surfaces + /// without a `.png` next to them. + /// + /// # Errors + /// When `root` isn't a readable directory. + pub fn open_with( + root: impl Into, + previewer: Option, + ) -> Result { + let root = root.into(); + if !root.is_dir() { + return Err(LibraryError::Io(format!( + "{} is not a directory", + root.display() + ))); + } + let lib = Self { + inner: Arc::new(Inner { + root, + state: Mutex::new(State::default()), + hubs: Mutex::new(HashMap::new()), + changes: PubSub::sliding(256), + previewer, + }), + }; + lib.rescan(); + Ok(lib) + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.inner.root + } + + /// Where a surface's file is (for downloads). + #[must_use] + pub fn path_of(&self, id: &str) -> Option { + lock(&self.inner.state) + .entries + .get(id) + .map(|e| e.path.clone()) + } + + /// An asset some surface carries (for downloads). Ids are content + /// hashes, so any surface's copy is the asset. + #[must_use] + pub fn asset(&self, id: &str) -> Option { + lock(&self.inner.state) + .entries + .values() + .find_map(|e| e.content.assets.iter().find(|a| a.id.0 == id).cloned()) + } + + /// Look at the folder once: new, changed and removed files become + /// versions and updates. Returns how many surfaces changed. + pub fn rescan(&self) -> usize { + let changes = self.scan(); + let n = changes.index.len(); + self.emit(changes); + n + } + + /// Rescan every [`WATCH_INTERVAL`] on a background thread until the + /// returned handle (or the library) is dropped. + #[must_use] + pub fn watch(&self) -> Watcher { + self.watch_every(WATCH_INTERVAL) + } + + /// [`Self::watch`] at a custom interval. + #[must_use] + pub fn watch_every(&self, interval: Duration) -> Watcher { + let weak: Weak = Arc::downgrade(&self.inner); + let stop = Arc::new(AtomicBool::new(false)); + let flag = stop.clone(); + let thread = std::thread::Builder::new() + .name("library-fs-watch".into()) + .spawn(move || { + while !flag.load(Ordering::Relaxed) { + std::thread::sleep(interval); + let Some(inner) = weak.upgrade() else { return }; + FsLibrary { inner }.rescan(); + } + }) + .ok(); + Watcher { stop, thread } + } + + fn emit(&self, changes: Changes) { + let hubs = lock(&self.inner.hubs); + for u in changes.updates { + if let Some(hub) = hubs.get(&u.id) { + hub.publish(u); + } + } + drop(hubs); + for c in changes.index { + self.inner.changes.publish(c); + } + } + + fn scan(&self) -> Changes { + let mut files = Vec::new(); + walk(&self.inner.root, 0, &mut files); + let mut state = lock(&self.inner.state); + let catalogs_changed = self.scan_catalogs(&mut state, &files); + let engines: Vec<(String, Signature)> = state + .catalogs + .values() + .map(|(_, c)| (c.id.clone(), Signature::of_catalog(c))) + .collect(); + + // Surface files by id; a `.frame` wins over a legacy `.fm` of the + // same name. + let mut found: BTreeMap = BTreeMap::new(); + for path in &files { + if format_of(path).is_none() { + continue; + } + let Some(id) = self.id_of(path) else { continue }; + if found + .get(&id) + .is_none_or(|have| is_legacy(have) && !is_legacy(path)) + { + found.insert(id, path.clone()); + } + } + + let mut out = Changes::default(); + let gone: Vec = state + .entries + .keys() + .filter(|id| !found.contains_key(*id)) + .cloned() + .collect(); + for id in gone { + state.entries.remove(&id); + out.updates.push(SurfaceUpdate { + id: id.clone(), + version: 0, + surface: None, + }); + out.index.push(IndexChange { id, meta: None }); + } + for (id, path) in found { + let st = crate::document::io::fingerprint(&path); + let entry = state.entries.get_mut(&id); + if let Some(e) = &entry + && e.path == path + && e.stamp == st + && !catalogs_changed + { + continue; + } + let format = format_of(&path).unwrap_or(Format::Markup); + // Unreadable (or a sidecar mid-write): the next scan retries. + let Some(content) = read_content(&path, format) else { + continue; + }; + let h = content_hash(&content); + if let Some(e) = entry + && e.path == path + && e.hash == h + { + e.stamp = st; + if catalogs_changed { + e.meta.tags = tags(&id, &read_sidecar(&path).tags, &e.meta.fits, &engines); + } + continue; + } + let old = state.entries.remove(&id); + let entry = self.build(&id, path, st, h, content, old, None, &engines); + out.updates.push(entry.update()); + out.index.push(IndexChange { + id: id.clone(), + meta: Some(entry.meta.clone()), + }); + state.entries.insert(id, entry); + } + out + } + + /// Reload changed `*.catalog.json` files; true when any changed. + fn scan_catalogs(&self, state: &mut State, files: &[PathBuf]) -> bool { + let paths: BTreeSet<&PathBuf> = files.iter().filter(|p| is_catalog(p)).collect(); + let before = state.catalogs.len(); + state.catalogs.retain(|p, _| paths.contains(p)); + let mut changed = state.catalogs.len() != before; + for path in paths { + let st = stamp(path); + if state.catalogs.get(path).is_some_and(|(s, _)| *s == st) { + continue; + } + match std::fs::read_to_string(path) + .map_err(|e| e.to_string()) + .and_then(|t| serde_json::from_str::(&t).map_err(|e| e.to_string())) + { + Ok(c) => { + state.catalogs.insert(path.clone(), (st, c)); + changed = true; + } + Err(e) => eprintln!("library-fs: skipping catalog {}: {e}", path.display()), + } + } + changed + } + + /// A fresh index entry for `content` at `path`, carrying over what the + /// previous version knew when the new content doesn't decode. + #[allow(clippy::too_many_arguments)] + fn build( + &self, + id: &str, + path: PathBuf, + stamp: Option, + hash: u64, + content: Content, + old: Option, + preview: Option>, + engines: &[(String, Signature)], + ) -> Entry { + let side = read_sidecar(&path); + let decoded = content.decode(); + let (fits, name, preview) = match (&decoded, &old) { + (Ok(scene), _) => { + let name = if side.name.is_empty() { + content + .title() + .or_else(|| scene_name(scene)) + .unwrap_or_else(|| stem(id)) + } else { + side.name.clone() + }; + let preview = preview + .filter(|p| !p.is_empty()) + .or_else(|| std::fs::read(path.with_extension("png")).ok()) + .or_else(|| { + self.inner + .previewer + .as_ref() + .and_then(|render| render(scene)) + }) + .unwrap_or_default(); + (Signature::of_scene(scene), name, preview) + } + // Broken on disk (mid-edit): keep what the last good version said; + // the content itself goes out as is and hosts reject it. + (Err(_), Some(o)) => ( + o.meta.fits.clone(), + o.meta.name.clone(), + o.meta.preview_png.clone(), + ), + (Err(_), None) => (Signature::default(), stem(id), Vec::new()), + }; + let (version, mut history) = match old { + Some(o) => { + let mut h = o.history; + h.push_back((o.meta.version, o.content)); + while h.len() > HISTORY { + h.pop_front(); + } + (o.meta.version + 1, h) + } + None => (side.version.max(1), VecDeque::new()), + }; + history.retain(|(v, _)| *v < version); + let meta = SurfaceMeta { + id: id.to_owned(), + name, + author: side.author.clone(), + description: side.description.clone(), + tags: tags(id, &side.tags, &fits, engines), + fits, + preview_png: preview, + version, + license: side.license.clone(), + forked_from: side.forked_from.clone(), + }; + Entry { + path, + stamp, + hash, + meta, + content, + history, + } + } + + /// `path`'s id: relative to the root, no extension, `/`-separated. + fn id_of(&self, path: &Path) -> Option { + let rel = path.strip_prefix(&self.inner.root).ok()?.with_extension(""); + let parts: Vec = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + (!parts.is_empty()).then(|| parts.join("/")) + } + + fn fresh_id(state: &State, name: &str) -> SurfaceId { + let base: String = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + let base = if base.is_empty() { + "surface".to_owned() + } else { + base + }; + if !state.entries.contains_key(&base) { + return base; + } + (2..) + .map(|n| format!("{base}-{n}")) + .find(|id| !state.entries.contains_key(id)) + .unwrap_or(base) + } +} + +/// Stops a [`FsLibrary::watch`] thread when dropped. +pub struct Watcher { + stop: Arc, + thread: Option>, +} + +impl Drop for Watcher { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +fn is_catalog(p: &Path) -> bool { + p.file_name() + .is_some_and(|n| n.to_string_lossy().ends_with(".catalog.json")) +} + +/// A surface file's format: a `.frame` is a bundle when its content is a +/// zip, markup otherwise; a `.fm` is markup. +fn format_of(path: &Path) -> Option { + crate::document::io::is_frame_document(path).then(|| { + match crate::document::io::Format::of(path) { + Some(crate::document::io::Format::Bundle) => Format::Frame, + _ => Format::Markup, + } + }) +} + +/// Whether `path` is a legacy `.fm` file. +fn is_legacy(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()) == Some(crate::document::io::LEGACY_EXTENSION) +} + +/// A surface file's content: a bundle's bytes, or markup text plus the +/// assets in its sidecar. +fn read_content(path: &Path, format: Format) -> Option { + match format { + Format::Frame => std::fs::read(path).ok().map(|bytes| Content { + format, + bytes, + assets: Vec::new(), + }), + Format::Markup => match crate::document::io::read_markup(path) { + Ok(m) => Some(Content::markup_with(m.text, m.assets.into_values())), + Err(e) => { + eprintln!("library-fs: skipping {e}"); + None + } + }, + } +} + +/// What decides whether a surface changed: its bytes and its assets' ids +/// (content hashes themselves). +fn content_hash(content: &Content) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + hash(&content.bytes).hash(&mut h); + for a in &content.assets { + a.id.hash(&mut h); + a.mime.hash(&mut h); + } + h.finish() +} + +/// Every file under `dir` (hidden dirs, `target`, `node_modules` and +/// markup assets sidecars skipped). +fn walk(dir: &Path, depth: usize, out: &mut Vec) { + if depth > 6 { + return; + } + let Ok(read) = std::fs::read_dir(dir) else { + return; + }; + let mut paths: Vec = read.filter_map(|e| e.ok().map(|e| e.path())).collect(); + paths.sort(); + for p in paths { + let name = p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if name.starts_with('.') { + continue; + } + if p.is_dir() { + if name != "target" && name != "node_modules" && !crate::document::io::is_assets_dir(&p) + { + walk(&p, depth + 1, out); + } + } else { + out.push(p); + } + } +} + +fn sidecar_path(path: &Path) -> PathBuf { + path.with_extension("meta.json") +} + +fn read_sidecar(path: &Path) -> Sidecar { + std::fs::read_to_string(sidecar_path(path)) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default() +} + +/// Write-then-rename, so the watcher (or any reader) never sees half a file. +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + // A dot-less tmp name would be scanned as a surface; `.tmp` isn't one. + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path) +} + +/// The id's folder names, the sidecar's tags, and every known catalog the +/// surface fits. +fn tags( + id: &str, + extra: &[String], + fits: &Signature, + engines: &[(String, Signature)], +) -> Vec { + let mut out: Vec = id.split('/').rev().skip(1).map(str::to_owned).collect(); + out.reverse(); + for t in extra { + if !out.contains(t) { + out.push(t.clone()); + } + } + if !fits.is_empty() { + for (cid, sig) in engines { + if fits.fits(sig) && !out.contains(cid) { + out.push(cid.clone()); + } + } + } + out +} + +fn stem(id: &str) -> String { + id.rsplit('/').next().unwrap_or(id).to_owned() +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() + && !id.starts_with('/') + && id.split('/').all(|p| { + !p.is_empty() + && !p.starts_with('.') + && p.chars() + .all(|c| c.is_ascii_alphanumeric() || "-_ ".contains(c)) + }) +} + +impl SurfaceStore for FsLibrary { + fn list(&self, query: ListQuery) -> Vec { + lock(&self.inner.state) + .entries + .values() + .filter(|e| query.matches(&e.meta)) + .map(|e| { + let mut meta = e.meta.clone(); + if !query.previews { + meta.preview_png = Vec::new(); + } + meta + }) + .collect() + } + + fn preview(&self, id: String) -> Vec { + lock(&self.inner.state) + .entries + .get(&id) + .map(|e| e.meta.preview_png.clone()) + .unwrap_or_default() + } + + fn get(&self, id: String, version: Option) -> Result { + let state = lock(&self.inner.state); + let e = state + .entries + .get(&id) + .ok_or_else(|| LibraryError::NotFound(id.clone()))?; + match version { + None => Ok(e.surface()), + Some(v) if v == e.meta.version => Ok(e.surface()), + Some(v) => e + .history + .iter() + .find(|(hv, _)| *hv == v) + .map(|(_, content)| Surface { + meta: SurfaceMeta { + version: v, + ..e.meta.clone() + }, + content: content.clone(), + }) + .ok_or_else(|| LibraryError::NotFound(format!("{id}@{v}"))), + } + } + + fn publish(&self, content: Content, meta: PublishMeta) -> Result { + content.decode().map_err(LibraryError::BadContent)?; + let mut state = lock(&self.inner.state); + let id = if meta.id.is_empty() { + Self::fresh_id(&state, &meta.name) + } else if valid_id(&meta.id) { + meta.id.clone() + } else { + return Err(LibraryError::BadId(meta.id)); + }; + let old = state.entries.remove(&id); + // Rewriting a surface keeps its file, in whatever format: a legacy + // `.fm` stays `.fm` while it is markup (no duplicate beside it) and + // moves to `.frame` when it becomes a bundle. + let path = match &old { + Some(o) if !is_legacy(&o.path) || content.format == Format::Markup => o.path.clone(), + _ => self + .inner + .root + .join(format!("{id}.{}", content.format.extension())), + }; + let io = |e: std::io::Error| LibraryError::Io(format!("{}: {e}", path.display())); + let result = (|| { + match content.format { + Format::Markup => { + let text = std::str::from_utf8(&content.bytes) + .map_err(|e| LibraryError::BadContent(e.to_string()))?; + crate::document::io::write_markup(&path, text, &content.assets) + .map_err(|e| LibraryError::Io(e.to_string()))?; + } + Format::Frame => { + write_atomic(&path, &content.bytes).map_err(io)?; + // A markup file's assets sidecar doesn't belong to a bundle. + crate::document::io::prune_assets(&path) + .map_err(|e| LibraryError::Io(e.to_string()))?; + } + } + let version = old.as_ref().map_or(1, |o| o.meta.version + 1); + let side = Sidecar { + name: meta.name.clone(), + author: meta.author.clone(), + description: meta.description.clone(), + tags: meta.tags.clone(), + license: meta.license.clone(), + forked_from: meta.forked_from.clone(), + version, + }; + if !side.is_empty() { + let json = serde_json::to_vec_pretty(&side) + .map_err(|e| LibraryError::Io(e.to_string()))?; + write_atomic(&sidecar_path(&path), &json).map_err(io)?; + } + Ok(()) + })(); + if let Err(e) = result { + if let Some(o) = old { + state.entries.insert(id, o); + } + return Err(e); + } + // A format change leaves the other file (and its sidecar) behind: + // remove it. + if let Some(o) = &old + && o.path != path + { + let _ = crate::document::io::remove_surface(&o.path); + } + let engines: Vec<(String, Signature)> = state + .catalogs + .values() + .map(|(_, c)| (c.id.clone(), Signature::of_catalog(c))) + .collect(); + let (st, h) = ( + crate::document::io::fingerprint(&path), + content_hash(&content), + ); + let mut entry = self.build( + &id, + path, + st, + h, + content, + old, + Some(meta.preview_png), + &engines, + ); + if !meta.name.is_empty() { + entry.meta.name = meta.name; + } + let published = Published { + id: id.clone(), + version: entry.meta.version, + }; + let changes = Changes { + updates: vec![entry.update()], + index: vec![IndexChange { + id: id.clone(), + meta: Some(entry.meta.clone()), + }], + }; + state.entries.insert(id, entry); + drop(state); + self.emit(changes); + Ok(published) + } + + fn catalogs(&self) -> Vec { + lock(&self.inner.state) + .catalogs + .values() + .map(|(_, c)| c.clone()) + .collect() + } +} + +impl SurfaceStoreStreamSource for FsLibrary { + fn updates_attach(&self, id: String, sink: architect::EventSink) { + let hub = lock(&self.inner.hubs) + .entry(id.clone()) + .or_insert_with(|| PubSub::sliding(8)) + .clone(); + // Buffer from now, snapshot, then open the tap: nothing is lost + // between the snapshot and the first live update. + let pending = hub.begin_attach(sink); + let intro = lock(&self.inner.state).entries.get(&id).map_or_else( + || SurfaceUpdate { + id: id.clone(), + version: 0, + surface: None, + }, + Entry::update, + ); + hub.complete_attach(pending, Some(intro)); + } + + fn changes_hub(&self) -> &PubSub { + &self.inner.changes + } +} + +impl architect::Services for FsLibrary { + fn layers() -> impl architect::Layer { + architect::layers![ + crate::library::proto::store::Service, + crate::library::proto::store::StreamService + ] + } +} diff --git a/crates/frame/src/library/link/mod.rs b/crates/frame/src/library/link/mod.rs new file mode 100644 index 00000000..7661cf22 --- /dev/null +++ b/crates/frame/src/library/link/mod.rs @@ -0,0 +1,458 @@ +//! The surface library across a WebSocket. +//! +//! - [`serve_ws`]: serve a store's router (`FsLibrary::into_router()`) as a +//! vox endpoint — what `frame-library-server` runs. +//! - [`connect`] / [`connect_stream`]: the async clients. A vox link +//! carries one service, so the call side and the stream side each open +//! their own socket. +//! - [`Blocking`]: the call side for synchronous hosts (the MCP stdio loop, +//! the desktop player's key handlers). +//! - [`Follow`]: a background thread that subscribes to one surface's +//! updates and the index changes, reconnecting with backoff, and hands +//! them to a UI thread that drains them each frame without blocking. + +use std::sync::mpsc; +use std::time::Duration; + +pub use crate::library::proto::*; +use crate::surface::proto::Catalog; +use architect::Backoff; +use architect::vox::{self, SelfRef}; +use tokio::sync::mpsc as tmpsc; + +/// Connect to a library's call side (`list`, `get`, `publish`, `catalogs`). +/// +/// # Errors +/// When the socket can't be opened or the vox handshake fails. +pub async fn connect(url: &str) -> Result { + let link = vox_websocket::WsLink::connect(url) + .await + .map_err(|e| format!("ws connect {url}: {e:?}"))?; + vox_core::initiator_on(link) + .establish() + .await + .map_err(|e| format!("vox handshake with {url}: {e:?}")) +} + +/// Connect to a library's stream side (`updates`, `changes`). +/// +/// # Errors +/// When the socket can't be opened or the vox handshake fails. +pub async fn connect_stream(url: &str) -> Result { + let link = vox_websocket::WsLink::connect(url) + .await + .map_err(|e| format!("ws connect {url}: {e:?}"))?; + vox_core::initiator_on(link) + .establish() + .await + .map_err(|e| format!("vox handshake with {url}: {e:?}")) +} + +/// Serve `router` as a WebSocket vox endpoint on `listener`. Runs until the +/// listener fails; connections live on the current tokio runtime. +pub async fn serve_ws(listener: tokio::net::TcpListener, router: architect::LayerRouter) { + while let Ok((tcp, _)) = listener.accept().await { + let router = router.clone(); + tokio::spawn(async move { + let Ok(link) = vox_websocket::WsLink::server(tcp).await else { + return; + }; + if let Ok(connection) = vox_core::acceptor_on(link) + .on_lane(router.acceptor()) + .establish_connection() + .await + { + let _connection = connection; + std::future::pending::<()>().await; + } + }); + } +} + +/// A router served on its own thread (tests, and hosts that embed a +/// library next to a synchronous UI). Dropping it stops the server and +/// drops every connection, like a crashed server. +pub struct Served { + pub url: String, + stop: Option>, + thread: Option>, +} + +impl Served { + /// Serve `router` on `listener` (e.g. `127.0.0.1:0`). + /// + /// # Errors + /// When the listener can't be used or the runtime can't start. + pub fn start( + router: architect::LayerRouter, + listener: std::net::TcpListener, + ) -> Result { + listener.set_nonblocking(true).map_err(|e| e.to_string())?; + let url = format!( + "ws://{}/vox", + listener.local_addr().map_err(|e| e.to_string())? + ); + let rt = tokio::runtime::Runtime::new().map_err(|e| format!("tokio: {e}"))?; + let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); + let thread = std::thread::Builder::new() + .name("library-serve".into()) + .spawn(move || { + rt.block_on(async move { + let Ok(listener) = tokio::net::TcpListener::from_std(listener) else { + return; + }; + tokio::select! { + () = serve_ws(listener, router) => {} + _ = stopped => {} + } + }); + rt.shutdown_background(); + }) + .map_err(|e| e.to_string())?; + Ok(Self { + url, + stop: Some(stop), + thread: Some(thread), + }) + } + + /// The `host:port` it listens on. + #[must_use] + pub fn addr(&self) -> &str { + self.url + .trim_start_matches("ws://") + .trim_end_matches("/vox") + } +} + +impl Drop for Served { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// Move an owned value out of a received `SelfRef`. +fn owned(item: SelfRef) -> T { + let mut out = None; + let _ = item.map(|v| out = Some(v)); + out.expect("SelfRef::map runs its closure") +} + +/// The call side of a library, for synchronous code: owns a small tokio +/// runtime and (re)opens the link on demand. +pub struct Blocking { + rt: tokio::runtime::Runtime, + url: String, + client: Option, +} + +impl Blocking { + /// # Errors + /// When the runtime can't start. + pub fn new(url: &str) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio: {e}"))?; + Ok(Self { + rt, + url: url.to_owned(), + client: None, + }) + } + + #[must_use] + pub fn url(&self) -> &str { + &self.url + } + + /// Run `f` against the client. A cached link may have died with a + /// server restart: a transport failure gets one retry on a fresh link. + fn call(&mut self, f: F) -> Result + where + F: Fn(SurfaceStoreClient) -> Fut, + Fut: Future>, + { + let url = self.url.clone(); + let cached = self.client.take(); + let (client, result) = self.rt.block_on(async move { + if let Some(c) = cached { + match f(c.clone()).await { + Err(Call::Transport(_)) => {} + other => return (Some(c), other), + } + } + match connect(&url).await { + Ok(c) => { + let r = f(c.clone()).await; + (Some(c), r) + } + Err(e) => (None, Err(Call::Transport(e))), + } + }); + self.client = client; + result.map_err(|e| match e { + Call::Transport(e) | Call::Refused(e) => e, + }) + } + + /// # Errors + /// The library is unreachable. + pub fn list(&mut self, query: &ListQuery) -> Result, String> { + self.call(|c| { + let q = query.clone(); + async move { c.list(q).await.map_err(Call::transport) } + }) + } + + /// # Errors + /// The library is unreachable. + pub fn preview(&mut self, id: &str) -> Result, String> { + self.call(|c| { + let id = id.to_owned(); + async move { c.preview(id).await.map_err(Call::transport) } + }) + } + + /// # Errors + /// Unreachable, or no such surface. + pub fn get(&mut self, id: &str, version: Option) -> Result { + self.call(|c| { + let id = id.to_owned(); + async move { c.get(id, version).await.map_err(Call::refused) } + }) + } + + /// # Errors + /// Unreachable, or the library refused the content. + pub fn publish(&mut self, content: &Content, meta: &PublishMeta) -> Result { + self.call(|c| { + let (content, meta) = (content.clone(), meta.clone()); + async move { c.publish(content, meta).await.map_err(Call::refused) } + }) + } + + /// # Errors + /// The library is unreachable. + pub fn catalogs(&mut self) -> Result, String> { + self.call(|c| async move { c.catalogs().await.map_err(Call::transport) }) + } +} + +/// How a blocking call failed: the link (retry on a fresh one) or the +/// library (don't). +enum Call { + Transport(String), + Refused(String), +} + +impl Call { + fn transport(e: impl std::fmt::Debug) -> Self { + Self::Transport(format!("{e:?}")) + } + + /// A refusal carries the library's error; anything else is the link. + fn refused(e: impl std::fmt::Debug) -> Self { + let text = format!("{e:?}"); + if ["NotFound", "BadContent", "BadId", "Io("] + .iter() + .any(|k| text.contains(k)) + { + Self::Refused(text) + } else { + Self::Transport(text) + } + } +} + +/// What a [`Follow`] hands the UI thread. +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + /// (Re)connected to the library. + Connected, + /// The followed surface changed (the first one after following is its + /// current snapshot). + Update(SurfaceUpdate), + /// The library's index changed. + Index(IndexChange), + /// The link dropped (or never came up); reconnecting. + Lost(String), +} + +/// Follows one surface's updates (and the index) on a background thread. +pub struct Follow { + url: String, + cmds: tmpsc::UnboundedSender>, + events: mpsc::Receiver, + thread: Option>, +} + +impl Follow { + /// Start following `id` (or only the index) on `url`. Returns at once. + /// + /// # Errors + /// When the background runtime can't start. + pub fn start(url: &str, id: Option) -> Result { + let (cmd_tx, cmd_rx) = tmpsc::unbounded_channel(); + let (event_tx, event_rx) = mpsc::channel(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio: {e}"))?; + let u = url.to_owned(); + let thread = std::thread::Builder::new() + .name("library-follow".into()) + .spawn(move || rt.block_on(supervise(u, id, cmd_rx, event_tx))) + .map_err(|e| format!("library thread: {e}"))?; + Ok(Self { + url: url.to_owned(), + cmds: cmd_tx, + events: event_rx, + thread: Some(thread), + }) + } + + #[must_use] + pub fn url(&self) -> &str { + &self.url + } + + /// Follow another surface (`None` = only the index). + pub fn follow(&self, id: Option) { + let _ = self.cmds.send(id); + } + + /// Every event since the last drain, without blocking. + #[must_use] + pub fn drain(&self) -> Vec { + self.events.try_iter().collect() + } + + /// The next event, waiting up to `timeout`. + #[must_use] + pub fn recv_timeout(&self, timeout: Duration) -> Option { + self.events.recv_timeout(timeout).ok() + } +} + +impl Drop for Follow { + fn drop(&mut self) { + // Closing the command channel stops the thread. + let (dead, _) = tmpsc::unbounded_channel(); + self.cmds = dead; + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +enum Ended { + UiGone, + Lost { connected: bool, reason: String }, +} + +async fn supervise( + url: String, + mut id: Option, + mut cmds: tmpsc::UnboundedReceiver>, + events: mpsc::Sender, +) { + let mut backoff = Backoff::default(); + let mut reported = false; + loop { + match session(&url, &mut id, &mut cmds, &events).await { + Ended::UiGone => return, + Ended::Lost { connected, reason } => { + if connected { + backoff.reset(); + reported = false; + } + if !reported { + if events.send(Event::Lost(reason)).is_err() { + return; + } + reported = true; + } + } + } + let sleep = tokio::time::sleep(backoff.next_delay()); + tokio::pin!(sleep); + loop { + tokio::select! { + () = &mut sleep => break, + cmd = cmds.recv() => match cmd { + None => return, + Some(new) => id = new, + }, + } + } + } +} + +async fn session( + url: &str, + id: &mut Option, + cmds: &mut tmpsc::UnboundedReceiver>, + events: &mpsc::Sender, +) -> Ended { + let stream = match connect_stream(url).await { + Ok(s) => s, + Err(reason) => { + return Ended::Lost { + connected: false, + reason, + }; + } + }; + if events.send(Event::Connected).is_err() { + return Ended::UiGone; + } + let lost = |reason: String| Ended::Lost { + connected: true, + reason, + }; + loop { + let (tx, mut rx) = vox::channel::(); + let (ctx, mut crx) = vox::channel::(); + let following = id.clone(); + let has_target = following.is_some(); + let sub = async { + match following { + Some(target) => format!("{:?}", stream.updates(target, tx).await), + None => { + drop(tx); + std::future::pending::().await + } + } + }; + let idx = stream.changes(ctx); + tokio::pin!(sub); + tokio::pin!(idx); + loop { + tokio::select! { + r = &mut sub => return lost(format!("updates subscription ended: {r}")), + r = &mut idx => return lost(format!("changes subscription ended: {r:?}")), + item = rx.recv(), if has_target => match item { + Ok(Some(u)) => if events.send(Event::Update(owned(u))).is_err() { return Ended::UiGone }, + other => return lost(format!("updates stream ended: {:?}", other.err())), + }, + item = crx.recv() => match item { + Ok(Some(c)) => if events.send(Event::Index(owned(c))).is_err() { return Ended::UiGone }, + other => return lost(format!("changes stream ended: {:?}", other.err())), + }, + cmd = cmds.recv() => match cmd { + None => return Ended::UiGone, + Some(new) => { + *id = new; + break; + } + }, + } + } + } +} diff --git a/crates/frame/src/library/mod.rs b/crates/frame/src/library/mod.rs new file mode 100644 index 00000000..20ff3e15 --- /dev/null +++ b/crates/frame/src/library/mod.rs @@ -0,0 +1,10 @@ +//! Libraries: shared surface libraries — protocol, filesystem store and the live link. + +#[cfg(feature = "library")] +pub mod proto; + +#[cfg(feature = "library")] +pub mod fs; + +#[cfg(feature = "library")] +pub mod link; diff --git a/crates/frame/src/library/proto/codec.rs b/crates/frame/src/library/proto/codec.rs new file mode 100644 index 00000000..cdeb36aa --- /dev/null +++ b/crates/frame/src/library/proto/codec.rs @@ -0,0 +1,97 @@ +//! [`Content`] ⇄ [`Scene`]: markup text (plus its assets) or a `.frame` bundle. + +use crate::document::proto::{Asset, Scene}; + +use crate::library::proto::{Content, Format}; + +impl Content { + /// `scene` as frame markup, with the assets it carries. + #[must_use] + pub fn markup(scene: &Scene) -> Self { + Self::markup_with( + crate::document::text::print(scene), + scene.assets.values().cloned(), + ) + } + + /// Markup text as content, unparsed, without assets. + #[must_use] + pub fn markup_text(text: impl Into) -> Self { + Self::markup_with(text, []) + } + + /// Markup text as content, unparsed, with the assets it names (as read + /// from a `.frame` and its `.frame.assets/` sidecar). + #[must_use] + pub fn markup_with(text: impl Into, assets: impl IntoIterator) -> Self { + let mut assets: Vec = assets.into_iter().collect(); + assets.sort_by(|a, b| a.id.cmp(&b.id)); + assets.dedup_by(|a, b| a.id == b.id); + Self { + format: Format::Markup, + bytes: text.into().into_bytes(), + assets, + } + } + + /// `scene` as a `.frame` bundle titled `title`. + /// + /// # Errors + /// When the bundle can't be written (an asset without bytes). + pub fn frame(scene: &Scene, title: &str) -> Result { + let bytes = crate::document::bundle::to_bytes( + scene, + &crate::document::bundle::Manifest::new(title), + ) + .map_err(|e| e.to_string())?; + Ok(Self { + format: Format::Frame, + bytes, + assets: Vec::new(), + }) + } + + /// Decode to a scene. Errors are one line; markup errors are + /// `line:col: message`. + /// + /// # Errors + /// Not UTF-8, a markup parse error, or a broken bundle. + pub fn decode(&self) -> Result { + match self.format { + Format::Markup => { + let text = + std::str::from_utf8(&self.bytes).map_err(|e| format!("not UTF-8: {e}"))?; + let assets = self + .assets + .iter() + .map(|a| (a.id.clone(), a.clone())) + .collect(); + crate::document::text::parse(text, assets).map_err(|e| e.to_string()) + } + Format::Frame => crate::document::bundle::from_bytes(&self.bytes) + .map(|(_, doc)| doc.scene()) + .map_err(|e| e.to_string()), + } + } + + /// The bundle's manifest title (`.frame` only). + #[must_use] + pub fn title(&self) -> Option { + match self.format { + Format::Frame => crate::document::bundle::from_bytes(&self.bytes) + .ok() + .map(|(m, _)| m.title), + Format::Markup => None, + } + } +} + +/// A display name for a surface: its first page's first top-level node +/// (`Vari-Mu`), else `None`. +#[must_use] +pub fn scene_name(scene: &Scene) -> Option { + let page = *scene.pages().first()?; + let top = *scene.node(page)?.children.first()?; + let name = scene.node(top)?.name.trim().to_owned(); + (!name.is_empty()).then_some(name) +} diff --git a/crates/frame/src/library/proto/mod.rs b/crates/frame/src/library/proto/mod.rs new file mode 100644 index 00000000..4f4a4e29 --- /dev/null +++ b/crates/frame/src/library/proto/mod.rs @@ -0,0 +1,432 @@ +//! The `library` contract: storing, finding and live-following surfaces. +//! +//! - [`Signature`]: the engine addresses a surface binds (what it *needs*) +//! or an engine offers (what it *has*). A surface fits an engine when its +//! signature is a subset of the engine's, so it only offers itself where +//! every binding resolves. +//! - [`SurfaceMeta`], [`Content`], [`Surface`]: what the library holds. +//! - [`store::SurfaceStore`]: the service any library backend implements +//! (`library-fs`, a shared community server). Hosts list, fetch, publish, +//! and subscribe to a surface's updates for live reload. +//! +//! ## Updates are whole snapshots, not Loro deltas +//! +//! [`SurfaceUpdate`] carries the surface's full content with its version. +//! A surface is small (tens of KB of markup or a zipped Loro snapshot), the +//! main writers are external (an agent saving a `.frame`, a person's editor) +//! and share no CRDT history with the subscribers, so a delta would first +//! need a canonical replica per surface on the server to diff against. A +//! snapshot is idempotent, needs no ordering or catch-up protocol (a late +//! or reconnecting subscriber just takes the latest), and a host can +//! validate the whole document before swapping it in. When several editors +//! share one live Loro document, deltas can ride a second variant of +//! [`Content`] without changing the rpc. + +use std::collections::BTreeSet; + +use crate::document::proto::{Asset, Scene}; +use crate::surface::proto::{Binding, Catalog, Control, ControlKind, InputSource, Source}; +use facet::Facet; +use serde::{Deserialize, Serialize}; + +/// A surface's id in a library: its path under the library root without +/// the extension, `/`-separated (`comp/07-vari-mu`). +pub type SurfaceId = String; + +/// The engine addresses a surface binds, or an engine offers. Each list is +/// sorted and deduplicated. +#[derive(Debug, Clone, Default, PartialEq, Eq, Facet, Serialize, Deserialize)] +pub struct Signature { + pub params: Vec, + pub actions: Vec, + pub streams: Vec, + pub collections: Vec, +} + +impl Signature { + /// What `scene`'s controls bind: params (incl. XY second axes and meter + /// switches), actions, streams (incl. switched meter taps), collections. + #[must_use] + pub fn of_scene(scene: &Scene) -> Self { + let mut sets: [BTreeSet; 4] = Default::default(); + for node in scene.nodes.values() { + let Some(control) = &node.control else { + continue; + }; + // A templated control (`{n}`) binds every item of its repeat — + // the enclosing template's, else the one its context names. + if control.is_templated() { + let (first, count) = repeat_of(scene, node.id, control).unwrap_or((1, 1)); + for n in first..first.saturating_add(count) { + collect(&control.instantiate(n, |s| s), &mut sets); + } + } else { + collect(control, &mut sets); + } + } + Self::from_sets(sets) + } + + /// Everything `catalog` offers. + #[must_use] + pub fn of_catalog(catalog: &Catalog) -> Self { + Self::from_sets([ + catalog.params.iter().map(|p| p.addr.0.clone()).collect(), + catalog.actions.iter().map(|a| a.id.0.clone()).collect(), + catalog.streams.iter().map(|s| s.id.0.clone()).collect(), + catalog.collections.iter().map(|c| c.id.0.clone()).collect(), + ]) + } + + /// Both signatures' addresses (an engine serving several catalogs). + #[must_use] + pub fn union(&self, other: &Self) -> Self { + let join = |a: &[String], b: &[String]| a.iter().chain(b).cloned().collect::>(); + Self::from_sets([ + join(&self.params, &other.params), + join(&self.actions, &other.actions), + join(&self.streams, &other.streams), + join(&self.collections, &other.collections), + ]) + } + + /// Whether every address this signature needs is in `engine`. + #[must_use] + pub fn fits(&self, engine: &Self) -> bool { + let sub = + |need: &[String], have: &[String]| need.iter().all(|n| have.binary_search(n).is_ok()); + sub(&self.params, &engine.params) + && sub(&self.actions, &engine.actions) + && sub(&self.streams, &engine.streams) + && sub(&self.collections, &engine.collections) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.params.is_empty() + && self.actions.is_empty() + && self.streams.is_empty() + && self.collections.is_empty() + } + + fn from_sets([p, a, s, c]: [BTreeSet; 4]) -> Self { + Self { + params: p.into_iter().collect(), + actions: a.into_iter().collect(), + streams: s.into_iter().collect(), + collections: c.into_iter().collect(), + } + } +} + +/// The `(first, count)` of the repeat a node's `{n}` refers to: its +/// enclosing template, or the template its context popover / menu names. +fn repeat_of( + scene: &Scene, + id: crate::document::proto::NodeId, + control: &Control, +) -> Option<(u32, u32)> { + let range = |c: &Control| match &c.kind { + ControlKind::Repeat { first, count, .. } => Some((*first, *count)), + _ => None, + }; + let mut cur = Some(id); + while let Some(n) = cur.and_then(|c| scene.node(c)) { + if let Some(r) = n.control.as_ref().and_then(range) { + return Some(r); + } + if let Some( + ControlKind::Popover { + context: Some(t), .. + } + | ControlKind::Menu { + context: Some(t), .. + }, + ) = n.control.as_ref().map(|c| &c.kind) + { + return scene + .node(crate::document::proto::NodeId(*t)) + .and_then(|t| t.control.as_ref()) + .and_then(range); + } + cur = n.parent; + } + let _ = control; + None +} + +fn collect(control: &Control, [params, actions, streams, collections]: &mut [BTreeSet; 4]) { + // A curve's inputs and a handle's axes bind too (an EQ graph needs + // every band's params). + let mut extra: Vec<&Binding> = Vec::new(); + // Rules, commands, conditions, zooms name params too. + if !matches!( + control.kind, + ControlKind::Curve { .. } | ControlKind::Handle { .. } + ) { + for addr in control.extra_params() { + params.insert(addr.0.clone()); + } + } + match &control.kind { + ControlKind::Curve { inputs, .. } => { + for input in inputs { + match &input.source { + InputSource::Param { addr } => { + params.insert(addr.0.clone()); + } + InputSource::Stream { id, .. } => { + streams.insert(id.0.clone()); + } + InputSource::Const { .. } => {} + } + } + } + ControlKind::Handle { + x, + y, + wheel, + x_map, + y_map, + } => { + extra.extend(x.iter().chain(y).chain(wheel)); + for addr in x_map.after.iter().chain(&y_map.after) { + params.insert(addr.0.clone()); + } + } + _ => {} + } + for binding in control + .binding + .iter() + .chain(&control.y_binding) + .chain(extra) + { + // `if let` rather than a `match`: new source kinds (feeds) are + // additive and needn't break this. + if let Source::Param { addr } = &binding.source { + params.insert(addr.0.clone()); + } else if let Source::Action { id } = &binding.source { + actions.insert(id.0.clone()); + } else if let Source::Stream { id, .. } = &binding.source { + streams.insert(id.0.clone()); + } else if let Source::Collection { id } = &binding.source { + collections.insert(id.0.clone()); + } + } + if let Some(select) = &control.select { + params.insert(select.param.0.clone()); + for tap in select.taps.iter().flatten() { + streams.insert(tap.stream.0.clone()); + } + } +} + +/// How a surface's bytes are encoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet, Serialize, Deserialize)] +#[repr(u8)] +pub enum Format { + /// A `.frame` bundle (zip: manifest, Loro snapshot, assets). + Frame, + /// frame markup text (`.frame`, or the legacy `.fm`), UTF-8. + Markup, +} + +impl Format { + /// The file extension, without the dot: `frame` for both formats (a + /// bundle is told from markup by its content, not its name). + #[must_use] + pub fn extension(self) -> &'static str { + "frame" + } +} + +/// A surface document, as stored. +/// +/// Markup names its images and fonts by id only, so a markup surface +/// carries their bytes alongside (what `.frame.assets/` holds on disk): +/// a surface shared through a library brings its textures and fonts. A +/// bundle holds its own; its `assets` stay empty. +#[derive(Debug, Clone, PartialEq, Eq, Facet, Serialize, Deserialize)] +pub struct Content { + pub format: Format, + pub bytes: Vec, + /// The assets markup `bytes` reference (empty for a bundle). + #[serde(default)] + pub assets: Vec, +} + +/// What the library knows about one surface. +#[derive(Debug, Clone, Default, PartialEq, Facet, Serialize, Deserialize)] +pub struct SurfaceMeta { + pub id: SurfaceId, + pub name: String, + pub author: String, + pub description: String, + pub tags: Vec, + /// What the surface binds; it fits engines offering all of it. + pub fits: Signature, + /// A PNG thumbnail (empty when the backend can't render one). + pub preview_png: Vec, + /// Bumped on every change (publish, or an edit on disk). + pub version: u64, + /// SPDX id or free text. + pub license: String, + /// `id@version` this one was forked from. + pub forked_from: Option, +} + +/// A surface: its metadata and its content. +#[derive(Debug, Clone, PartialEq, Facet, Serialize, Deserialize)] +pub struct Surface { + pub meta: SurfaceMeta, + pub content: Content, +} + +/// A change to one surface, pushed to its subscribers: the whole new +/// snapshot (see the crate docs for why), or `None` when it was removed. +#[derive(Debug, Clone, PartialEq, Facet, Serialize, Deserialize)] +pub struct SurfaceUpdate { + pub id: SurfaceId, + pub version: u64, + pub surface: Option, +} + +/// A change to the library's index (for browsers): the new metadata, or +/// `None` when the surface was removed. +#[derive(Debug, Clone, PartialEq, Facet, Serialize, Deserialize)] +pub struct IndexChange { + pub id: SurfaceId, + pub meta: Option, +} + +/// What to list. Empty fields match everything. +#[derive(Debug, Clone, Default, PartialEq, Facet, Serialize, Deserialize)] +pub struct ListQuery { + /// Case-insensitive substring of id, name, description or a tag. + pub text: String, + /// Every one of these tags must be present. + pub tags: Vec, + /// Only surfaces that fit this engine signature. + pub fits: Option, + /// Include each surface's preview PNG. Off by default: a list of a big + /// library with previews inline outgrows one message — fetch them with + /// `preview` as they're shown. + #[serde(default)] + #[facet(default)] + pub previews: bool, +} + +impl ListQuery { + /// Whether `meta` matches. + #[must_use] + pub fn matches(&self, meta: &SurfaceMeta) -> bool { + let text = self.text.to_lowercase(); + let has_text = text.is_empty() + || [&meta.id, &meta.name, &meta.description] + .iter() + .any(|s| s.to_lowercase().contains(&text)) + || meta.tags.iter().any(|t| t.to_lowercase().contains(&text)); + let has_tags = self + .tags + .iter() + .all(|t| meta.tags.iter().any(|m| m.eq_ignore_ascii_case(t))); + let fits = self + .fits + .as_ref() + .is_none_or(|engine| meta.fits.fits(engine)); + has_text && has_tags && fits + } +} + +/// What a publisher says about a surface. The library computes `fits`, +/// the version and (when it can render) the preview itself. +#[derive(Debug, Clone, Default, PartialEq, Facet, Serialize, Deserialize)] +pub struct PublishMeta { + /// The id to publish under (a new version when it exists); empty = a + /// fresh id from the name. + pub id: SurfaceId, + pub name: String, + pub author: String, + pub description: String, + pub tags: Vec, + pub license: String, + pub forked_from: Option, + /// A thumbnail to use instead of rendering one (empty = render). + pub preview_png: Vec, +} + +/// Where a publish landed. +#[derive(Debug, Clone, PartialEq, Eq, Facet, Serialize, Deserialize)] +pub struct Published { + pub id: SurfaceId, + pub version: u64, +} + +/// Why a library refused a call. +#[derive(Debug, Clone, PartialEq, Eq, Facet, Serialize, Deserialize)] +#[repr(u8)] +pub enum LibraryError { + /// No such surface (or version). + NotFound(String), + /// The content doesn't decode (markup parse error, broken bundle). + BadContent(String), + /// The id isn't a valid relative path. + BadId(String), + /// Storage failed. + Io(String), +} + +impl std::fmt::Display for LibraryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(what) => write!(f, "not found: {what}"), + Self::BadContent(e) => write!(f, "bad content: {e}"), + Self::BadId(id) => write!(f, "bad surface id `{id}`"), + Self::Io(e) => write!(f, "storage: {e}"), + } + } +} + +impl std::error::Error for LibraryError {} + +pub mod store { + //! The library service. `SurfaceStore` → `SurfaceStoreClient` / + //! `SurfaceStoreService`, plus the stream sibling + //! (`SurfaceStoreStreamClient`, `SurfaceStoreStreamService`, the + //! `SurfaceStoreStreamSource` backend contract). + use super::{ + Catalog, Content, IndexChange, LibraryError, ListQuery, PublishMeta, Published, Surface, + SurfaceMeta, SurfaceUpdate, + }; + + /// A store of surfaces. + #[architect::rpc] + pub trait SurfaceStore { + /// Every surface matching `query`, by id. + fn list(&self, query: ListQuery) -> Vec; + /// A surface at `version` (`None` = the latest). + fn get(&self, id: String, version: Option) -> Result; + /// A surface's preview PNG (empty when it has none). + fn preview(&self, id: String) -> Vec; + /// Store `content` as a new surface, or a new version of `meta.id`. + fn publish(&self, content: Content, meta: PublishMeta) -> Result; + /// The engine catalogs the library knows (`*.catalog.json` next to + /// its surfaces) — so a host without an engine can mock them. + fn catalogs(&self) -> Vec; + /// One surface's changes: its current snapshot on attach, then every + /// new version (live reload). + #[subscribe] + fn updates(&self, id: String) -> SurfaceUpdate; + /// Every index change (surfaces added, changed, removed). + #[subscribe] + fn changes(&self) -> IndexChange; + } +} + +pub use store::*; + +#[cfg(feature = "codec")] +mod codec; +#[cfg(feature = "codec")] +pub use codec::*; diff --git a/crates/frame/src/live/mod.rs b/crates/frame/src/live/mod.rs new file mode 100644 index 00000000..aa4d6dd3 --- /dev/null +++ b/crates/frame/src/live/mod.rs @@ -0,0 +1,1011 @@ +//! A frame surface running live inside a host's renderer. +//! +//! [`LiveSurface`] is what a host with a GPU reaches for: it loads a page +//! from `.frame` markup (plus its catalog), watches both files and swaps a new +//! design in place when either changes — values and a drag in progress +//! survive — runs the surface's controls, and renders through the effects +//! stage on the host's own wgpu device into a texture the host composites. +//! +//! Coordinates in are the host's element pixels (logical); the surface is +//! fitted into the element uniformly and centred, like the desktop player +//! fits it into its window. +//! +//! Edits come out as [`EngineCall`]s ([`LiveSurface::take_calls`]); with no +//! engine answering, each edit is echoed back so the control follows the +//! hand. A host with an engine feeds the engine's values in with +//! [`LiveSurface::apply_values`]. + +#[cfg(feature = "anyrender-0-11")] +mod vectors; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::document::proto::{NodeId, PaintKind, Scene}; +use crate::surface::proto::{Catalog, ParamAddr, ParamValue, Source as Binding, TextValue}; +use crate::surface::runtime::{Bounds, EngineCall, Input, Runtime, Surface}; +use vello::wgpu; +use vello::{AaConfig, Renderer, RendererOptions}; + +pub use crate::render::fx::Mode; +pub use crate::surface::runtime::EngineCall as Call; +pub use crate::surface::runtime::Modifiers; + +/// How often to look at the files. +const POLL: Duration = Duration::from_millis(250); +/// Max gap and travel between two presses of a double-click. +const DOUBLE_CLICK_MS: f64 = 350.0; +const DOUBLE_CLICK_PX: f64 = 4.0; +/// Under a page with no solid fill: nothing — the host's panel shows +/// through (the texture is straight alpha). +const DARK: peniko::Color = peniko::Color::from_rgba8(0, 0, 0, 0); + +/// Fit of the surface's content into the element: surface → element px. +#[derive(Debug, Clone, Copy, PartialEq)] +struct View { + scale: f64, + /// The vertical scale where it differs (a stretched picture). + scale_y: f64, + tx: f64, + ty: f64, +} + +impl View { + fn fit(content: Bounds, width: f64, height: f64) -> Self { + if content.width <= 0.0 || content.height <= 0.0 || width <= 0.0 || height <= 0.0 { + return Self { + scale: 1.0, + scale_y: 1.0, + tx: -content.x, + ty: -content.y, + }; + } + let scale = (width / content.width).min(height / content.height); + Self { + scale, + scale_y: scale, + tx: (width - content.width * scale) / 2.0 - content.x * scale, + ty: (height - content.height * scale) / 2.0 - content.y * scale, + } + } + + /// Stretched to fill `width × height` exactly (a picture, not a unit). + fn fill(content: Bounds, width: f64, height: f64) -> Self { + if content.width <= 0.0 || content.height <= 0.0 || width <= 0.0 || height <= 0.0 { + return Self::fit(content, width, height); + } + let (sx, sy) = (width / content.width, height / content.height); + Self { + scale: sx, + scale_y: sy, + tx: -content.x * sx, + ty: -content.y * sy, + } + } + + fn to_surface(self, x: f64, y: f64) -> (f64, f64) { + ((x - self.tx) / self.scale, (y - self.ty) / self.scale_y) + } + + fn affine(self) -> kurbo::Affine { + kurbo::Affine::new([self.scale, 0.0, 0.0, self.scale_y, self.tx, self.ty]) + } +} + +thread_local! { + /// One vello renderer per device for every surface on this thread: its + /// pipelines and buffers are the heavy part, and a host drawing twenty + /// faces needs them once, not twenty times. + static SHARED: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// A device's renderer, and its effects stages' shared pipelines (each +/// effect compiled once, not once a face). +type Shared = ( + std::rc::Rc>, + crate::render::fx::SharedPasses, +); + +/// This thread's [`Shared`] for `device`, made on first use. +fn shared_renderer(device: &wgpu::Device) -> Result { + SHARED.with(|slot| { + let mut slot = slot.borrow_mut(); + if let Some((d, (r, passes))) = slot.as_ref() + && d == device + { + return Ok((std::rc::Rc::clone(r), passes.clone())); + } + let renderer = Renderer::new( + device, + RendererOptions { + use_cpu: false, + antialiasing_support: [AaConfig::Area].into_iter().collect(), + num_init_threads: None, + pipeline_cache: None, + }, + ) + .map_err(|e| format!("renderer: {e}"))?; + let r = std::rc::Rc::new(std::cell::RefCell::new(renderer)); + let passes = crate::render::fx::SharedPasses::new(device); + *slot = Some((device.clone(), (std::rc::Rc::clone(&r), passes.clone()))); + Ok((r, passes)) + }) +} + +/// The GPU half, on the host's device. +struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, + renderer: std::rc::Rc>, + /// The next frame checks vello's buffers (the first, and after a + /// resize or a reload); the rest render straight through. + check_next: bool, + /// A deferred check found an overflow: checks wait (and band) from + /// then on, since the surface will overflow again at that size. + banded: bool, + stage: crate::render::fx::Stage, + /// The host's texture, which the frame is copied into. + target: Option<(wgpu::Texture, (u32, u32))>, +} + +/// One surface, live. +pub struct LiveSurface { + markup: PathBuf, + catalog_path: Option, + stamps: (Option, Option), + next_check: Instant, + rt: Runtime, + page: NodeId, + content: Bounds, + background: peniko::Color, + mode: Mode, + gpu: Option, + born: Instant, + /// Element px → surface, as of the last render (logical px). + view: View, + cursor: Option<(f64, f64)>, + last_press: Option<(f64, f64, f64)>, + dragging: Option, + calls: Vec, + /// Something changed since the last render. + dirty: bool, + time_dependent: bool, + /// When the last render began, to pace clock-driven redraws. + rendered_at: Option, + /// The pace of clock-driven renders. + clock_frame: std::time::Duration, + /// Whether motion may run on its own (see [`LiveSurface::set_animate`]). + animate: bool, + /// Held still, motion still runs until then after a change, so a + /// needle's spring finishes its move rather than freezing mid-way. + settle_until: Option, + error: Option, + /// Fill the element exactly rather than fit it (pictures). + stretch: bool, + /// Lay the face out for the element's proportions (see + /// [`LiveSurface::set_responsive`]). + responsive: bool, + /// The face as drawn, to lay out afresh for each width. + design: Option, + /// The width it is laid out at now (its drawn width until a + /// responsive render changes it). + laid_width: f64, +} + +/// How often a face that only moves with the clock is rendered unless the +/// host says otherwise ([`LiveSurface::set_max_fps`]): 30 fps. +pub const CLOCK_FRAME: std::time::Duration = std::time::Duration::from_millis(33); + +/// How long motion keeps running after a change on a face held still. +const SETTLE: std::time::Duration = std::time::Duration::from_millis(600); + +fn stamp(path: &Path) -> Option { + crate::document::io::fingerprint(path) +} + +fn load(markup: &Path, catalog: Option<&Path>) -> Result<(Surface, Catalog), String> { + let scene = crate::document::io::load_surface(markup) + .map_err(|e| format!("{}: {e}", markup.display()))?; + let catalog = match catalog { + Some(p) => { + let text = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?; + serde_json::from_str::(&text).map_err(|e| format!("{}: {e}", p.display()))? + } + None => Catalog::default(), + }; + Ok((Surface::from_scene(scene), catalog)) +} + +fn bounds(b: &crate::render::LayoutBox) -> Bounds { + Bounds { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + } +} + +fn union(boxes: impl Iterator) -> Bounds { + boxes + .reduce(|a, b| { + let (x, y) = (a.x.min(b.x), a.y.min(b.y)); + Bounds { + x, + y, + width: (a.x + a.width).max(b.x + b.width) - x, + height: (a.y + a.height).max(b.y + b.height) - y, + } + }) + .unwrap_or_default() +} + +fn solid_fill(scene: &Scene, id: NodeId) -> Option { + scene + .node(id)? + .fills + .iter() + .rev() + .find(|p| p.visible) + .and_then(|p| match p.kind { + PaintKind::Solid { color: c } => { + let u8 = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Some(peniko::Color::from_rgb8(u8(c.r), u8(c.g), u8(c.b))) + } + _ => None, + }) +} + +impl LiveSurface { + /// Open `markup` (a `.frame` file with its `.frame.assets/`) against the + /// catalog at `catalog` (its params' ranges and defaults). + /// + /// # Errors + /// When either file cannot be read, or the surface has no page. + pub fn open(markup: impl Into, catalog: Option) -> Result { + let markup = markup.into(); + let (surface, cat) = load(&markup, catalog.as_deref())?; + let stamps = (stamp(&markup), catalog.as_deref().and_then(stamp)); + let mut me = Self { + markup, + catalog_path: catalog, + stamps, + next_check: Instant::now() + POLL, + rt: Runtime::new(Surface::from_scene(Scene::new("")), Catalog::default()), + page: NodeId::default(), + content: Bounds::default(), + background: DARK, + mode: Mode::Gpu, + gpu: None, + born: Instant::now(), + view: View { + scale: 1.0, + scale_y: 1.0, + tx: 0.0, + ty: 0.0, + }, + stretch: false, + responsive: false, + design: Some(surface.clone()), + laid_width: 0.0, + cursor: None, + last_press: None, + dragging: None, + calls: Vec::new(), + dirty: true, + time_dependent: false, + rendered_at: None, + clock_frame: CLOCK_FRAME, + animate: true, + settle_until: None, + error: None, + }; + me.install(Runtime::new(surface, cat))?; + Ok(me) + } + + /// Lay out a runtime and make it the one running. + fn install(&mut self, mut rt: Runtime) -> Result<(), String> { + rt.set_text_measure(std::sync::Arc::new( + |data: &crate::document::proto::TextData, text: &str| { + crate::render::text_advance( + crate::render::FontBook::bundled(), + &crate::render::fonts::text_font(data), + text, + data.font_size, + data.letter_spacing, + ) + }, + )); + let scene = &rt.surface().scene; + let page = *scene.pages().first().ok_or("surface has no ")?; + let boxes = crate::render::build_scene_layout_boxes(scene, page); + let tops = scene + .node(page) + .map(|p| p.children.clone()) + .unwrap_or_default(); + self.content = union(tops.iter().filter_map(|id| boxes.get(id)).map(bounds)); + self.background = tops + .first() + .and_then(|id| solid_fill(scene, *id)) + .unwrap_or(DARK); + rt.set_boxes( + boxes + .iter() + .map(|(id, b)| (*id, bounds(b))) + .collect::>(), + ); + self.page = page; + self.rt = rt; + self.dirty = true; + Ok(()) + } + + /// Stretch to fill the element exactly instead of fitting it whole and + /// centred — for a picture (a filter's response, a visualiser), never + /// a unit whose proportions are its look. + pub fn set_stretch(&mut self, stretch: bool) { + self.stretch = stretch; + self.dirty = true; + } + + /// Lay the face out for the element's proportions instead of fitting + /// it as drawn: kept at the element's height, it is made as wide as + /// the element (within 0.6–2.4× its drawn width), its parts following + /// their [`ResizeX`](crate::document::proto::ResizeX) — windows and grounds + /// stretch, knobs and labels keep their size and spread or close up. + pub fn set_responsive(&mut self, responsive: bool) { + self.responsive = responsive; + self.dirty = true; + } + + /// Relay the design for an element `width × height`, when its + /// proportions call for another width than the one laid out. + fn respond(&mut self, width: u32, height: u32) { + let Some(design) = self.design.as_ref() else { + return; + }; + let scene = &design.scene; + let Some(page) = scene.pages().first().copied() else { + return; + }; + let Some(root) = scene.node(page).and_then(|p| p.children.first().copied()) else { + return; + }; + let Some((dw, dh)) = scene.node(root).map(|n| (n.size.width, n.size.height)) else { + return; + }; + if dw <= 0.0 || dh <= 0.0 || width == 0 || height == 0 { + return; + } + let want = (dh * f64::from(width) / f64::from(height)).clamp(dw * 0.6, dw * 2.4); + let now = if self.laid_width > 0.0 { + self.laid_width + } else { + dw + }; + if (want - now).abs() <= dw * 0.005 { + return; + } + let mut surface = design.clone(); + surface.scene.resize_width(root, want); + let placeholder = Runtime::new(Surface::from_scene(Scene::new("")), Catalog::default()); + let mut rt = std::mem::replace(&mut self.rt, placeholder); + rt.swap_surface(surface); + if self.install(rt).is_ok() { + self.laid_width = want; + } + } + + /// The effects mode (GPU by default). + pub fn set_mode(&mut self, mode: Mode) { + self.mode = mode; + self.dirty = true; + } + + /// The surface's content size (its page's frames), surface units. + #[must_use] + pub fn content_size(&self) -> (f64, f64) { + (self.content.width, self.content.height) + } + + /// Why the last reload failed, while it stays failed. + #[must_use] + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + /// Look at the files (at most every 250 ms) and swap in a changed + /// design, keeping values and a drag on a control that is still there. + /// True when something was swapped in. + pub fn poll_reload(&mut self) -> bool { + let now = Instant::now(); + if now < self.next_check { + return false; + } + self.next_check = now + POLL; + let stamps = ( + stamp(&self.markup), + self.catalog_path.as_deref().and_then(stamp), + ); + if stamps == self.stamps || stamps.0.is_none() { + return false; + } + self.stamps = stamps; + match load(&self.markup, self.catalog_path.as_deref()) { + Ok((surface, catalog)) => { + if surface.scene.pages().is_empty() { + self.error = Some("surface has no ".into()); + return false; + } + self.design = Some(surface.clone()); + self.laid_width = 0.0; + let placeholder = + Runtime::new(Surface::from_scene(Scene::new("")), Catalog::default()); + let mut rt = std::mem::replace(&mut self.rt, placeholder); + if catalog != *rt.catalog() { + // New params (a regenerated page): start from the old + // values where the addresses carry over. + let values = rt.values(); + rt = Runtime::new(surface, catalog); + rt.apply_values(values); + } else { + rt.swap_surface(surface); + } + match self.install(rt) { + Ok(()) => { + self.error = None; + if let Some(g) = self.gpu.as_mut() { + g.check_next = true; + } + true + } + Err(e) => { + self.error = Some(e); + false + } + } + } + Err(e) => { + self.error = Some(e); + false + } + } + } + + /// Values from the engine (normalized 0..1). A param being dragged + /// keeps the hand's value. + pub fn apply_values(&mut self, values: impl IntoIterator) { + let dragging = self.dragging.clone(); + self.rt.apply_values( + values + .into_iter() + .filter(|v| dragging.as_ref() != Some(&v.addr)), + ); + self.dirty = true; + } + + /// Text values from the engine, by address (a preset's name on its + /// nameplate): whatever control is bound to the text param shows it. + pub fn apply_texts(&mut self, texts: impl IntoIterator) { + self.rt + .apply_texts(texts.into_iter().map(|(addr, text)| TextValue { + addr: ParamAddr::new(addr), + text, + })); + self.dirty = true; + } + + /// The calls the surface made since the last take (param edits, + /// actions), for the host to send. + pub fn take_calls(&mut self) -> Vec { + std::mem::take(&mut self.calls) + } + + /// Values from the engine in their own units (Hz, dB, 0..1…), by + /// address; each is normalized through the surface catalog's range. + /// Addresses the catalog does not know are ignored. + pub fn apply_real(&mut self, values: impl IntoIterator) { + let normalized: Vec = values + .into_iter() + .filter_map(|(addr, real)| { + let addr = ParamAddr::new(addr); + let info = self.rt.catalog().param(&addr)?; + Some(ParamValue { + value: info.range.to_normalized(real), + addr, + }) + }) + .collect(); + self.apply_values(normalized); + } + + /// Live readings from the engine, by stream id (`/spectrum`, a + /// level's `/in`, a gain reduction's `/gr`, a trace's + /// `/in_history`): the plots, meters and lamps bound to them move. + /// A history the catalog calls a series is taken whole; any other + /// reading is pushed onto its plot's ring. + pub fn apply_stream(&mut self, id: &str, values: Vec) { + let t = self.t(); + self.rt.apply_frame( + crate::surface::proto::StreamFrame { + id: crate::surface::proto::EntryId::new(id), + values, + }, + t, + ); + self.dirty = true; + } + + /// The param edits made since the last take, as (address, value in the + /// param's own units) — the catalog's range turns the surface's + /// normalized value back into Hz, dB, 0..1… + pub fn take_edits(&mut self) -> Vec<(String, f64)> { + let calls = std::mem::take(&mut self.calls); + calls + .into_iter() + .filter_map(|c| match c { + EngineCall::SetParam { addr, value, .. } => { + let real = self + .rt + .catalog() + .param(&addr) + .map_or(value, |p| p.range.to_real(value)); + Some((addr.to_string(), real)) + } + _ => None, + }) + .collect() + } + + /// The params the surface's catalog knows, by address. + #[must_use] + pub fn addresses(&self) -> Vec { + self.rt + .catalog() + .params + .iter() + .map(|p| p.addr.to_string()) + .collect() + } + + /// Pace clock-driven renders to at most `fps` a second (the display's + /// rate, say); 0 leaves them unpaced. + pub fn set_max_fps(&mut self, fps: u32) { + self.clock_frame = if fps == 0 { + std::time::Duration::ZERO + } else { + std::time::Duration::from_secs_f64(1.0 / f64::from(fps)) + }; + } + + /// Let the face move on its own (LFOs, springs settling, clock-driven + /// shaders) or hold it still: a host with nothing running (its audio + /// stopped) holds it, and the face redraws only when something changes. + /// A control in the hand still moves. + pub fn set_animate(&mut self, animate: bool) { + if animate != self.animate { + self.animate = animate; + self.dirty = true; + } + } + + /// Whether the face has something new to show or is moving: what a + /// host asks to decide whether to draw another frame at all. Unlike + /// [`Self::needs_redraw`] it ignores the clock pacing — a face whose + /// LFO is between frames is still moving. + #[must_use] + pub fn is_moving(&self) -> bool { + let moving = self.animate + || self + .settle_until + .is_some_and(|at| std::time::Instant::now() < at); + self.dirty || (moving && (self.time_dependent || self.rt.animating())) + } + + /// Whether the host should render again: something changed (an edit, + /// the pointer), or the face moves by itself — a shader reading the + /// clock, a motion running — and a frame's time (its + /// [max fps](Self::set_max_fps)) has passed. What moves by itself (an + /// LFO lamp, a reverb's wavefront, a scope) is paced, not re-rendered + /// at whatever rate the window happens to redraw; what the hand does is + /// not. + #[must_use] + pub fn needs_redraw(&self) -> bool { + let due = self + .rendered_at + .is_none_or(|at| at.elapsed() >= self.clock_frame); + let moving = self.animate + || self + .settle_until + .is_some_and(|at| std::time::Instant::now() < at); + // A deferred check still out is read by the next render. + let checking = self.gpu.as_ref().is_some_and(|g| g.stage.checking()); + self.dirty || checking || (moving && due && (self.time_dependent || self.rt.animating())) + } + + fn t(&self) -> f64 { + self.born.elapsed().as_secs_f64() * 1000.0 + } + + fn dispatch(&mut self, calls: Vec) { + if calls.is_empty() { + return; + } + // No engine answers here: echo the edits so the controls follow. + let echoed: Vec = calls + .iter() + .filter_map(|c| match c { + EngineCall::SetParam { addr, value, .. } => Some(ParamValue { + addr: addr.clone(), + value: *value, + }), + _ => None, + }) + .collect(); + self.rt.apply_values(echoed); + self.calls.extend(calls); + self.dirty = true; + } + + fn param_of(&self, id: NodeId) -> Option { + let c = self.rt.surface().controls.get(&id)?; + match c.binding.as_ref().map(|b| &b.source) { + Some(Binding::Param { addr }) => Some(addr.clone()), + _ => None, + } + } + + // ── input, in element px (logical) ────────────────────────────────── + + pub fn pointer_down(&mut self, x: f64, y: f64, mods: Modifiers) { + let t = self.t(); + let at = self.view.to_surface(x, y); + self.cursor = Some(at); + let double = self.last_press.is_some_and(|(lx, ly, lt)| { + t - lt <= DOUBLE_CLICK_MS && (x - lx).hypot(y - ly) <= DOUBLE_CLICK_PX + }); + self.last_press = if double { None } else { Some((x, y, t)) }; + let hit = self.rt.hit(at.0, at.1); + self.dragging = hit.and_then(|id| self.param_of(id)); + let mut calls = self.rt.pointer( + Input::Down { + x: at.0, + y: at.1, + t, + mods, + }, + t, + ); + if double { + calls.extend(self.rt.pointer(Input::DoubleClick, t)); + } + self.dispatch(calls); + self.dirty = true; + } + + pub fn pointer_move(&mut self, x: f64, y: f64, mods: Modifiers) { + let t = self.t(); + let at = self.view.to_surface(x, y); + self.cursor = Some(at); + let calls = self.rt.pointer( + Input::Move { + x: at.0, + y: at.1, + t, + mods, + }, + t, + ); + if self.dragging.is_some() || !calls.is_empty() { + self.dirty = true; + } + self.dispatch(calls); + } + + pub fn pointer_up(&mut self) { + let t = self.t(); + let (x, y) = self.cursor.unwrap_or_default(); + let calls = self.rt.pointer(Input::Up { x, y, t }, t); + self.dragging = None; + self.dispatch(calls); + self.dirty = true; + } + + pub fn cancel(&mut self) { + let t = self.t(); + let calls = self.rt.pointer(Input::Cancel { t }, t); + self.dragging = None; + self.dispatch(calls); + } + + /// Wheel over the control under the cursor (notches: + is up). + pub fn wheel(&mut self, x: f64, y: f64, notches: f64, mods: Modifiers) { + let t = self.t(); + let at = self.view.to_surface(x, y); + self.cursor = Some(at); + let Some(id) = self.rt.hit(at.0, at.1) else { + return; + }; + let calls = self.rt.feed(id, Input::Wheel { notches, mods }, t); + self.dispatch(calls); + } + + // ── drawing ───────────────────────────────────────────────────────── + + /// Take the host's device (once it has one). + /// + /// # Errors + /// When vello cannot build its pipelines on it. + pub fn attach_gpu(&mut self, device: wgpu::Device, queue: wgpu::Queue) -> Result<(), String> { + let (renderer, passes) = shared_renderer(&device)?; + let stage = crate::render::fx::Stage::with_passes(&passes); + self.gpu = Some(Gpu { + device, + queue, + renderer, + check_next: true, + banded: false, + stage, + target: None, + }); + self.dirty = true; + Ok(()) + } + + /// The host's device went away (drop the textures). + pub fn detach_gpu(&mut self) { + self.gpu = None; + } + + /// Whether a device is attached. + #[must_use] + pub fn has_gpu(&self) -> bool { + self.gpu.is_some() + } + + /// Paint the surface's vectors at `width × height` physical px + /// (`scale` physical px per element px) into `scene`, a host's anyrender + /// 0.11 scene — the path for a host whose renderer gives no Vello GPU to + /// [`render`](Self::render) with (vello-hybrid on the iOS simulator, + /// whose Metal has no indirect execution). The same frame as `render`, + /// painted by the same painter; the GPU effects stage is left out (a + /// glow is drawn as a soft copy, as frame's fallback mode draws it). + #[cfg(feature = "anyrender-0-11")] + pub fn paint_vectors( + &mut self, + scene: &mut S, + width: u32, + height: u32, + scale: f64, + ) { + if self.responsive { + self.respond(width, height); + } + self.rendered_at = Some(std::time::Instant::now()); + if self.dirty { + self.settle_until = self.rendered_at.map(|at| at + SETTLE); + } + let t = self.t(); + let calls = self.rt.poll(t); + self.dispatch(calls); + self.rt.set_clock(t / 1000.0); + let (w, h) = (width.max(1), height.max(1)); + let scale = if scale > 0.0 { scale } else { 1.0 }; + let physical = if self.stretch { + View::fill(self.content, f64::from(w), f64::from(h)) + } else { + View::fit(self.content, f64::from(w), f64::from(h)) + }; + self.view = View { + scale: physical.scale / scale, + scale_y: physical.scale_y / scale, + tx: physical.tx / scale, + ty: physical.ty / scale, + }; + let doc = self.rt.frame(t); + let mut target = vectors::AnyRender011(scene); + let bounds = kurbo::Rect::new(0.0, 0.0, f64::from(w), f64::from(h)); + crate::render::PaintTarget::fill( + &mut target, + peniko::Fill::NonZero, + kurbo::Affine::IDENTITY, + self.background, + None, + &bounds, + ); + let prims = crate::render::build_scene_primitives_with( + &doc, + self.page, + crate::render::CollectOptions { + emission_glow: true, + }, + ); + crate::render::paint_primitives_into_scene_with( + &mut target, + &prims, + physical.affine(), + Some(crate::render::FontBook::bundled()), + ); + self.time_dependent = false; + self.dirty = false; + } + + /// Paint the surface's vectors fitted to a `width × height` box placed + /// by `transform` (the box's units to the scene's, as an affine's six + /// coefficients) into a host's scene, with nothing behind it — a face + /// drawn over a host's own picture, as one block of its grid, its + /// transparent margin showing what is under it. The same frame as + /// [`paint_vectors`](Self::paint_vectors), by the same painter. + #[cfg(feature = "anyrender-0-11")] + pub fn paint_vectors_at( + &mut self, + scene: &mut S, + width: f64, + height: f64, + transform: [f64; 6], + ) { + self.rendered_at = Some(std::time::Instant::now()); + if self.dirty { + self.settle_until = self.rendered_at.map(|at| at + SETTLE); + } + let t = self.t(); + let calls = self.rt.poll(t); + self.dispatch(calls); + self.rt.set_clock(t / 1000.0); + let view = View::fit(self.content, width.max(1.0), height.max(1.0)); + self.view = view; + let doc = self.rt.frame(t); + let mut target = vectors::AnyRender011(scene); + let prims = crate::render::build_scene_primitives_with( + &doc, + self.page, + crate::render::CollectOptions { + emission_glow: true, + }, + ); + crate::render::paint_primitives_into_scene_with( + &mut target, + &prims, + kurbo::Affine::new(transform) * view.affine(), + Some(crate::render::FontBook::bundled()), + ); + self.time_dependent = false; + self.dirty = false; + } + + /// Render at `width × height` physical px (`scale` physical px per + /// element px) into the texture this keeps for the host, and return + /// it with whether it was (re)made — a new texture must be registered + /// again. `Rgba8Unorm`, straight alpha, usable as a texture binding and + /// a copy source. + /// + /// # Errors + /// With no device attached, or when vello fails. + pub fn render( + &mut self, + width: u32, + height: u32, + scale: f64, + ) -> Result<(wgpu::Texture, bool), String> { + if self.responsive { + self.respond(width, height); + } + self.rendered_at = Some(std::time::Instant::now()); + if self.dirty { + self.settle_until = self.rendered_at.map(|at| at + SETTLE); + } + let t = self.t(); + let calls = self.rt.poll(t); + self.dispatch(calls); + self.rt.set_clock(t / 1000.0); + let (w, h) = (width.max(1), height.max(1)); + let scale = if scale > 0.0 { scale } else { 1.0 }; + let physical = if self.stretch { + View::fill(self.content, f64::from(w), f64::from(h)) + } else { + View::fit(self.content, f64::from(w), f64::from(h)) + }; + self.view = View { + scale: physical.scale / scale, + scale_y: physical.scale_y / scale, + tx: physical.tx / scale, + ty: physical.ty / scale, + }; + let doc = self.rt.frame(t); + let values = self + .rt + .surface() + .controls + .iter() + .map(|(id, c)| (*id, self.rt.position(*id, c))) + .collect(); + let inputs = self.rt.shader_inputs(); + let gpu = self.gpu.as_mut().ok_or("no GPU attached")?; + // The last check, deferred so a first frame does not wait on the + // GPU: an overflow drew nothing, so this frame renders again, + // checked and waited for, which bands it. + if gpu.stage.check(&gpu.device) == crate::render::fx::Check::Overflowed { + gpu.banded = true; + gpu.check_next = true; + } + let frame = crate::render::fx::Frame { + transform: physical.affine(), + base: self.background, + fonts: Some(crate::render::FontBook::bundled()), + mode: self.mode, + time_s: t / 1000.0, + values: Some(&values), + inputs: Some(&inputs), + overlay: None, + antialiasing: AaConfig::Area, + checked: gpu.check_next || gpu.target.as_ref().is_none_or(|(_, size)| *size != (w, h)), + deferred: !gpu.banded, + ..crate::render::fx::Frame::new(&doc, self.page, w, h) + }; + let rendered = gpu.stage.render( + &gpu.device, + &gpu.queue, + &mut gpu.renderer.borrow_mut(), + &frame, + )?; + gpu.check_next = false; + self.time_dependent = gpu.stage.time_dependent(); + let fresh = !gpu.target.as_ref().is_some_and(|(_, size)| *size == (w, h)); + if fresh { + let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("frame-live target"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + gpu.target = Some((texture, (w, h))); + } + let (target, _) = gpu.target.as_ref().expect("target made above"); + let mut encoder = gpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame-live copy"), + }); + encoder.copy_texture_to_texture( + rendered.as_image_copy(), + target.as_image_copy(), + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + gpu.queue.submit(Some(encoder.finish())); + self.dirty = false; + Ok((target.clone(), fresh)) + } +} + +/// Where a design lives: `/.frame` with `/.catalog.json` +/// beside it, as frame's plugin-faces generator writes them (an older +/// `.fm` is used when there is no `.frame`). +#[must_use] +pub fn face_paths(dir: &Path, name: &str) -> (PathBuf, Option) { + let markup = dir.join(format!("{name}.{}", crate::document::io::EXTENSION)); + let legacy = dir.join(format!("{name}.{}", crate::document::io::LEGACY_EXTENSION)); + let markup = if !markup.exists() && legacy.exists() { + legacy + } else { + markup + }; + let catalog = std::fs::read_dir(dir).ok().and_then(|rd| { + rd.filter_map(Result::ok) + .map(|e| e.path()) + .find(|p| p.to_string_lossy().ends_with(".catalog.json")) + }); + (markup, catalog) +} diff --git a/crates/frame/src/live/vectors.rs b/crates/frame/src/live/vectors.rs new file mode 100644 index 00000000..93ed49b9 --- /dev/null +++ b/crates/frame/src/live/vectors.rs @@ -0,0 +1,188 @@ +//! A host's anyrender 0.11 scene as frame's paint target — Blitz's, drawn +//! by whatever renderer the host runs: vello-hybrid on the iOS simulator, +//! whose Metal has no indirect execution for Vello's compute stages. What +//! that API has no word for is approximated: a luminance mask draws its +//! content unmasked, and clips are non-zero. +//! +//! Here rather than in `render`, under the crates' own names: `render` also +//! links frame's anyrender (git main), and a renamed dependency that a host +//! redirects with `[patch]` (signal's, to the blitz fork) breaks dx's +//! dependency scan. + +use kurbo::{Affine, Rect, Shape, Stroke}; +use peniko::{BlendMode, BrushRef, Color, Fill}; + +use crate::render::{GlyphRun, PaintTarget}; + +/// An anyrender 0.11 `PaintScene` as a [`PaintTarget`]. +pub struct AnyRender011<'s, S: anyrender_0_11::PaintScene>(pub &'s mut S); + +impl PaintTarget for AnyRender011<'_, S> { + fn fill<'b>( + &mut self, + style: Fill, + transform: Affine, + brush: impl Into>, + brush_transform: Option, + shape: &impl Shape, + ) { + let brush: BrushRef<'b> = brush.into(); + // An image's own opacity goes on a layer: vello-hybrid cannot apply + // opacity to an image command (vello_common panics). + if let BrushRef::Image(image) = &brush + && image.sampler.alpha < 1.0 + { + let mut opaque = image.clone(); + opaque.sampler.alpha = 1.0; + self.0.push_layer( + BlendMode::default(), + image.sampler.alpha, + transform, + shape, + None, + None, + ); + self.0.fill( + style, + transform, + anyrender_0_11::PaintRef::Image(opaque), + brush_transform, + shape, + ); + self.0.pop_layer(); + return; + } + self.0.fill( + style, + transform, + anyrender_0_11::PaintRef::from(brush), + brush_transform, + shape, + ); + } + + fn stroke<'b>( + &mut self, + style: &Stroke, + transform: Affine, + brush: impl Into>, + brush_transform: Option, + shape: &impl Shape, + ) { + let brush: BrushRef<'b> = brush.into(); + self.0.stroke( + style, + transform, + anyrender_0_11::PaintRef::from(brush), + brush_transform, + shape, + ); + } + + fn push_layer( + &mut self, + _clip_style: Fill, + blend: impl Into, + alpha: f32, + transform: Affine, + clip: &impl Shape, + ) { + self.0.push_layer(blend, alpha, transform, clip, None, None); + } + + fn push_clip_layer(&mut self, _clip_style: Fill, transform: Affine, clip: &impl Shape) { + self.0.push_clip_layer(transform, clip); + } + + fn push_luminance_mask_layer( + &mut self, + _clip_style: Fill, + alpha: f32, + transform: Affine, + clip: &impl Shape, + ) { + // No luminance masks here: the mask's content draws unmasked. + self.0 + .push_layer(BlendMode::default(), alpha, transform, clip, None, None); + } + + fn pop_layer(&mut self) { + self.0.pop_layer(); + } + + fn draw_blurred_rounded_rect( + &mut self, + transform: Affine, + rect: Rect, + brush: Color, + radius: f64, + std_dev: f64, + ) { + self.0 + .draw_box_shadow(transform, rect, brush, radius, std_dev); + } + + fn draw_glyph_run(&mut self, run: &GlyphRun<'_>) { + let glyphs = run.glyphs.iter().map(|g| anyrender_0_11::Glyph { + id: g.id, + x: g.x, + y: g.y, + }); + self.0.draw_glyphs( + run.font, + run.font_size, + false, + run.normalized_coords, + run.embolden, + Fill::NonZero, + anyrender_0_11::PaintRef::from(run.brush.clone()), + run.brush_alpha, + run.transform, + run.glyph_transform, + glyphs, + ); + } + + fn draw_image(&mut self, image: &peniko::ImageBrush, transform: Affine) { + let size = Rect::new( + 0.0, + 0.0, + f64::from(image.image.width), + f64::from(image.image.height), + ); + PaintTarget::fill(self, Fill::NonZero, transform, image, None, &size); + } + + fn draw_svg(&mut self, svg_text: &str, transform: Affine) -> bool { + let Some(tree) = svg_tree(svg_text) else { + return false; + }; + anyrender_svg_0_12::render_svg_tree(&mut *self.0, &tree, transform); + true + } +} + +/// SVG trees parsed by anyrender_svg 0.12's usvg, by text — frame's own +/// cache holds the other usvg's. +fn svg_tree(svg_text: &str) -> Option> { + use std::collections::HashMap; + use std::hash::{Hash, Hasher}; + use std::sync::{Arc, LazyLock, Mutex}; + static TREES: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + svg_text.hash(&mut hasher); + let key = hasher.finish(); + let mut trees = TREES.lock().ok()?; + trees + .entry(key) + .or_insert_with(|| { + anyrender_svg_0_12::usvg::Tree::from_str( + svg_text, + &anyrender_svg_0_12::usvg::Options::default(), + ) + .ok() + .map(Arc::new) + }) + .clone() +} diff --git a/crates/frame/src/motion/mod.rs b/crates/frame/src/motion/mod.rs new file mode 100644 index 00000000..2e963e84 --- /dev/null +++ b/crates/frame/src/motion/mod.rs @@ -0,0 +1,6 @@ +//! Motion: animation timelines and their runtime. + +pub mod proto; + +#[cfg(feature = "runtime")] +pub mod runtime; diff --git a/crates/frame/src/motion/proto/expr.rs b/crates/frame/src/motion/proto/expr.rs new file mode 100644 index 00000000..90f08cf2 --- /dev/null +++ b/crates/frame/src/motion/proto/expr.rs @@ -0,0 +1,531 @@ +//! Motion input expressions: a small, total arithmetic language. +//! +//! ```text +//! norm(room/size) a param, normalized 0..1 +//! param(room/decay) a param in real units (seconds) +//! level(spring/in) a level feed, 0..1 over −60…0 dBFS (loudest channel; level(id, ch) for one) +//! gr(comp/gr) gain reduction, 0..1 over 0…20 dB +//! scalar(chorus/lfo) a scalar feed, 0..1 of its range +//! band(eq/spectrum, 60, 250) a spectrum's energy between two frequencies, 0..1 +//! feed(tuner/pitch, 1) a feed frame's raw value at an index +//! motion(ring) another motion's output +//! time hover press pi seconds; 1 while the pointer is over / pressing the node's control +//! + - * / ( ) arithmetic +//! clamp(x, lo, hi) min max abs lerp(a, b, t) smoothstep(e0, e1, x) step(edge, x) +//! pow sqrt sin cos floor fract sign curve(x, k) or(a, b, …) +//! ``` +//! +//! A source with no data yet (a feed that hasn't sent a frame, an unknown +//! param) is *missing*; missing propagates through arithmetic, `or(a, b)` +//! picks the first present argument, and a motion whose input is missing +//! holds its rest. Addresses are written bare (`norm(room/size)`), or +//! quoted when they contain `,` or `)`. + +use std::fmt; + +/// A parsed expression. +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Num(f64), + /// Seconds since the clock started. + Time, + Hover, + Press, + Src(Source), + /// Another motion's (mapped) output, by name. + Motion(String), + Neg(Box), + Add(Box, Box), + Sub(Box, Box), + Mul(Box, Box), + Div(Box, Box), + Call(Func, Vec), +} + +/// Something the host samples. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Source { + /// A param's value: normalized 0..1 (`norm`) or in real units (`param`). + Param { addr: String, normalized: bool }, + /// A feed read one way. + Feed { id: String, read: FeedRead }, +} + +/// How a feed frame becomes one number. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum FeedRead { + /// `feed(id, i)`: `values[i]` as sent. + Raw(u32), + /// `level(id[, ch])`: dBFS → 0..1 over −60…0; `None` = loudest channel. + Level(Option), + /// `gr(id)`: dB of reduction → 0..1 over 0…20. + Gr, + /// `scalar(id)`: 0..1 of the feed's range. + Scalar, + /// `band(id, lo, hi)`: a spectrum's level between `lo` and `hi` Hz + /// (whole Hz), 0..1 over its floor…0 dB. + Band(u32, u32), +} + +/// Built-in functions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Func { + Clamp, + Min, + Max, + Abs, + Lerp, + Smoothstep, + Step, + Pow, + Sqrt, + Sin, + Cos, + Floor, + Fract, + Sign, + Curve, + Or, +} + +impl Func { + const ALL: [(&'static str, Func, usize, usize); 16] = [ + ("clamp", Func::Clamp, 3, 3), + ("min", Func::Min, 1, usize::MAX), + ("max", Func::Max, 1, usize::MAX), + ("abs", Func::Abs, 1, 1), + ("lerp", Func::Lerp, 3, 3), + ("smoothstep", Func::Smoothstep, 3, 3), + ("step", Func::Step, 2, 2), + ("pow", Func::Pow, 2, 2), + ("sqrt", Func::Sqrt, 1, 1), + ("sin", Func::Sin, 1, 1), + ("cos", Func::Cos, 1, 1), + ("floor", Func::Floor, 1, 1), + ("fract", Func::Fract, 1, 1), + ("sign", Func::Sign, 1, 1), + ("curve", Func::Curve, 2, 2), + ("or", Func::Or, 1, usize::MAX), + ]; + + /// Apply to present arguments (`Or` is handled by the evaluator). + #[must_use] + pub fn apply(self, a: &[f64]) -> f64 { + let x = a.first().copied().unwrap_or(0.0); + let y = a.get(1).copied().unwrap_or(0.0); + let z = a.get(2).copied().unwrap_or(0.0); + match self { + Func::Clamp => x.max(y).min(z), + Func::Min => a.iter().copied().fold(f64::INFINITY, f64::min), + Func::Max => a.iter().copied().fold(f64::NEG_INFINITY, f64::max), + Func::Abs => x.abs(), + Func::Lerp => x + (y - x) * z, + Func::Smoothstep => { + let t = if (y - x).abs() < f64::EPSILON { + f64::from(u8::from(z >= y)) + } else { + ((z - x) / (y - x)).clamp(0.0, 1.0) + }; + t * t * (3.0 - 2.0 * t) + } + Func::Step => f64::from(u8::from(y >= x)), + Func::Pow => x.abs().powf(y) * x.signum(), + Func::Sqrt => x.max(0.0).sqrt(), + Func::Sin => x.sin(), + Func::Cos => x.cos(), + Func::Floor => x.floor(), + Func::Fract => x - x.floor(), + Func::Sign => { + if x > 0.0 { + 1.0 + } else if x < 0.0 { + -1.0 + } else { + 0.0 + } + } + // A skew that keeps 0 and 1: k > 1 bends toward the end, < 1 + // toward the start (sign-preserving power). + Func::Curve => x.abs().powf(y.max(1e-3)) * x.signum(), + Func::Or => x, + } + } +} + +/// Why an expression didn't parse (`at` = byte offset). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExprError { + pub at: usize, + pub message: String, +} + +impl fmt::Display for ExprError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} (at {})", self.message, self.at) + } +} + +impl std::error::Error for ExprError {} + +/// Parse an expression. +/// +/// # Errors +/// Where and why the text isn't an expression. +pub fn parse(src: &str) -> Result { + let mut p = Parser { + s: src.as_bytes(), + src, + i: 0, + }; + let e = p.sum()?; + p.ws(); + if p.i < p.s.len() { + return p.err(format!("unexpected `{}`", &src[p.i..])); + } + Ok(e) +} + +impl Expr { + /// Visit this expression and every sub-expression. + pub fn visit<'a>(&'a self, f: &mut impl FnMut(&'a Expr)) { + f(self); + match self { + Expr::Neg(a) => a.visit(f), + Expr::Add(a, b) | Expr::Sub(a, b) | Expr::Mul(a, b) | Expr::Div(a, b) => { + a.visit(f); + b.visit(f); + } + Expr::Call(_, args) => args.iter().for_each(|a| a.visit(f)), + _ => {} + } + } + + /// Every source it samples. + #[must_use] + pub fn sources(&self) -> Vec<&Source> { + let mut v = Vec::new(); + self.visit(&mut |e| { + if let Expr::Src(s) = e { + v.push(s); + } + }); + v + } + + /// Every motion it reads. + #[must_use] + pub fn motions(&self) -> Vec<&str> { + let mut v = Vec::new(); + self.visit(&mut |e| { + if let Expr::Motion(m) = e { + v.push(m.as_str()); + } + }); + v + } + + /// Whether it changes with time by itself (reads `time`). + #[must_use] + pub fn uses_time(&self) -> bool { + let mut t = false; + self.visit(&mut |e| t |= matches!(e, Expr::Time)); + t + } + + /// A constant's value. + #[must_use] + pub fn constant(&self) -> Option { + match self { + Expr::Num(v) => Some(*v), + Expr::Neg(a) => a.constant().map(|v| -v), + _ => None, + } + } +} + +struct Parser<'a> { + s: &'a [u8], + src: &'a str, + i: usize, +} + +impl Parser<'_> { + fn err(&self, message: impl Into) -> Result { + Err(ExprError { + at: self.i, + message: message.into(), + }) + } + + fn ws(&mut self) { + while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() { + self.i += 1; + } + } + + fn eat(&mut self, c: u8) -> bool { + self.ws(); + if self.s.get(self.i) == Some(&c) { + self.i += 1; + true + } else { + false + } + } + + fn sum(&mut self) -> Result { + let mut a = self.product()?; + loop { + if self.eat(b'+') { + a = Expr::Add(Box::new(a), Box::new(self.product()?)); + } else if self.eat(b'-') { + a = Expr::Sub(Box::new(a), Box::new(self.product()?)); + } else { + return Ok(a); + } + } + } + + fn product(&mut self) -> Result { + let mut a = self.unary()?; + loop { + if self.eat(b'*') { + a = Expr::Mul(Box::new(a), Box::new(self.unary()?)); + } else if self.eat(b'/') { + a = Expr::Div(Box::new(a), Box::new(self.unary()?)); + } else { + return Ok(a); + } + } + } + + fn unary(&mut self) -> Result { + if self.eat(b'-') { + return Ok(match self.unary()? { + Expr::Num(v) => Expr::Num(-v), + e => Expr::Neg(Box::new(e)), + }); + } + if self.eat(b'+') { + return self.unary(); + } + self.atom() + } + + fn atom(&mut self) -> Result { + self.ws(); + let Some(&c) = self.s.get(self.i) else { + return self.err("expected a value, found the end"); + }; + if c == b'(' { + self.i += 1; + let e = self.sum()?; + if !self.eat(b')') { + return self.err("expected `)`"); + } + return Ok(e); + } + if c.is_ascii_digit() || c == b'.' { + return self.number().map(Expr::Num); + } + if c.is_ascii_alphabetic() || c == b'_' { + let start = self.i; + while self.i < self.s.len() + && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') + { + self.i += 1; + } + let name = &self.src[start..self.i]; + if !self.eat(b'(') { + return match name { + "time" | "t" => Ok(Expr::Time), + "hover" => Ok(Expr::Hover), + "press" => Ok(Expr::Press), + "pi" => Ok(Expr::Num(std::f64::consts::PI)), + _ => Err(ExprError { + at: start, + message: format!( + "unknown name `{name}` (sources are calls: norm(addr), level(id), …; also time, hover, press, pi)" + ), + }), + }; + } + return self.call(name, start); + } + self.err(format!("unexpected `{}`", c as char)) + } + + fn number(&mut self) -> Result { + self.ws(); + let start = self.i; + while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || self.s[self.i] == b'.') { + self.i += 1; + } + if self.i < self.s.len() && (self.s[self.i] == b'e' || self.s[self.i] == b'E') { + let save = self.i; + self.i += 1; + if self.i < self.s.len() && (self.s[self.i] == b'-' || self.s[self.i] == b'+') { + self.i += 1; + } + if self.i < self.s.len() && self.s[self.i].is_ascii_digit() { + while self.i < self.s.len() && self.s[self.i].is_ascii_digit() { + self.i += 1; + } + } else { + self.i = save; + } + } + self.src[start..self.i] + .parse::() + .map_err(|_| ExprError { + at: start, + message: format!("bad number `{}`", &self.src[start..self.i]), + }) + } + + /// A bare or quoted address up to `,` or `)`. + fn address(&mut self) -> Result { + self.ws(); + let q = self.s.get(self.i).copied(); + if q == Some(b'"') || q == Some(b'\'') { + let quote = q.unwrap_or(b'"'); + self.i += 1; + let start = self.i; + while self.i < self.s.len() && self.s[self.i] != quote { + self.i += 1; + } + if self.i >= self.s.len() { + return self.err("unterminated quote"); + } + let a = self.src[start..self.i].to_string(); + self.i += 1; + return Ok(a); + } + let start = self.i; + while self.i < self.s.len() && self.s[self.i] != b',' && self.s[self.i] != b')' { + self.i += 1; + } + let a = self.src[start..self.i].trim(); + if a.is_empty() { + return Err(ExprError { + at: start, + message: "expected an address".into(), + }); + } + Ok(a.to_string()) + } + + fn close(&mut self, name: &str) -> Result<(), ExprError> { + if self.eat(b')') { + Ok(()) + } else { + self.err(format!("expected `)` to close {name}(…)")) + } + } + + fn int_arg(&mut self, name: &str) -> Result { + if !self.eat(b',') { + return self.err(format!("{name}(…) needs another argument")); + } + self.number() + } + + fn call(&mut self, name: &str, start: usize) -> Result { + let feed = |id: String, read: FeedRead| Expr::Src(Source::Feed { id, read }); + match name { + "param" | "norm" => { + let addr = self.address()?; + self.close(name)?; + Ok(Expr::Src(Source::Param { + addr, + normalized: name == "norm", + })) + } + "motion" => { + let m = self.address()?; + self.close(name)?; + Ok(Expr::Motion(m)) + } + "level" => { + let id = self.address()?; + let ch = if self.eat(b',') { + Some(self.number()? as u8) + } else { + None + }; + self.close(name)?; + Ok(feed(id, FeedRead::Level(ch))) + } + "gr" | "scalar" => { + let id = self.address()?; + self.close(name)?; + Ok(feed( + id, + if name == "gr" { + FeedRead::Gr + } else { + FeedRead::Scalar + }, + )) + } + "feed" => { + let id = self.address()?; + let i = if self.eat(b',') { + self.number()? as u32 + } else { + 0 + }; + self.close(name)?; + Ok(feed(id, FeedRead::Raw(i))) + } + "band" => { + let id = self.address()?; + let lo = self.int_arg(name)?; + let hi = self.int_arg(name)?; + self.close(name)?; + if !(lo >= 0.0 && hi > lo) { + return Err(ExprError { + at: start, + message: format!("band(…) needs 0 ≤ lo < hi, got {lo}, {hi}"), + }); + } + Ok(feed(id, FeedRead::Band(lo as u32, hi as u32))) + } + _ => { + let Some((_, f, min, max)) = Func::ALL.iter().find(|(n, ..)| *n == name).copied() + else { + return Err(ExprError { + at: start, + message: format!("unknown function `{name}`"), + }); + }; + let mut args = Vec::new(); + if !self.eat(b')') { + loop { + args.push(self.sum()?); + if self.eat(b')') { + break; + } + if !self.eat(b',') { + return self.err(format!("expected `,` or `)` in {name}(…)")); + } + } + } + if args.len() < min || args.len() > max { + let want = if min == max { + format!("{min}") + } else if max == usize::MAX { + format!("at least {min}") + } else { + format!("{min}–{max}") + }; + return Err(ExprError { + at: start, + message: format!("{name}(…) takes {want} argument(s), got {}", args.len()), + }); + } + Ok(Expr::Call(f, args)) + } + } + } +} diff --git a/crates/frame/src/motion/proto/mod.rs b/crates/frame/src/motion/proto/mod.rs new file mode 100644 index 00000000..6df22235 --- /dev/null +++ b/crates/frame/src/motion/proto/mod.rs @@ -0,0 +1,558 @@ +//! The motion contract: animation as data on a document node. +//! +//! A [`Motion`] drives one property of the node it sits on +//! (`Node::motion`). Every frame the runtime reads its **input** (an +//! [expression](expr) over params, feeds, time, gestures and other +//! motions), runs it through its **dynamics** (a spring, a struck +//! resonator, an LFO, noise, ballistics, keyframes, spin — or nothing), +//! maps the result `0 → from`, `1 → to`, and writes it into the +//! **target** (translate, rotate, scale, stretch, skew, opacity, colour, +//! emission, dash offset, a path morph or a shape provider's geometry). +//! +//! ```json +//! {"target":"rotate","input":"norm(wah/position)","dynamics":{"spring":{"stiffness":300,"damping":14}},"from":-20,"to":20} +//! {"name":"ring","target":"none","dynamics":{"oscillator":{"freq":"4 + norm(spring/tension)*6","decay":"0.4 + 3*norm(spring/dwell)","excite":"level(spring/in)"}}} +//! {"target":"rotate","input":"0.5 + 1.5*norm(tape/speed)","dynamics":"spin","to":360} +//! {"target":"emission","input":"0.6 + 0.6*norm(tube/drive)","dynamics":{"noise":{"rate":9,"amount":"0.25*norm(tube/drive)"}}} +//! ``` +//! +//! Everything here is plain data (`serde` + `facet`); the evaluator is +//! `motion-runtime`. + +use std::collections::BTreeMap; + +use facet::Facet; +use serde::{Deserialize, Serialize}; + +pub mod expr; + +pub use expr::{Expr, ExprError, FeedRead, Func, Source}; + +fn one() -> f64 { + 1.0 +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_one(v: &f64) -> bool { + *v == 1.0 +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(v: &bool) -> bool { + !*v +} + +/// One animated property of a node. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +pub struct Motion { + /// A name other motions read with `motion(name)` (looked up on the + /// same node, then its ancestors, then anywhere). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub name: Option, + /// The property it writes. + pub target: Target, + /// The input [expression](expr), e.g. `norm(room/size)`, + /// `level(spring/in)*2`, `time`. Empty = the dynamics' natural input: + /// `time` for keyframes, `1` for LFO / noise / spin, `0` otherwise. + #[serde(default, skip_serializing_if = "String::is_empty")] + #[facet(default)] + pub input: String, + /// How the output follows the input. + #[serde(default, skip_serializing_if = "Dynamics::is_follow")] + #[facet(default)] + pub dynamics: Dynamics, + /// The target value at output 0. + #[serde(default, skip_serializing_if = "is_zero")] + #[facet(default)] + pub from: f64, + /// The target value at output 1 (outputs outside 0..1 extrapolate). + #[serde(default = "one", skip_serializing_if = "is_one")] + #[facet(default = 1.0)] + pub to: f64, + /// Drive only the dynamics' deviation from the input (`output − + /// input`), added to what the node already shows: layers a spring's + /// overshoot on a part a control already moves (a needle, a treadle). + #[serde(default, skip_serializing_if = "is_false")] + #[facet(default)] + pub lag: bool, + /// Where rotate / scale / stretch / skew turn about, as fractions of + /// the node's own box (default its centre). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + pub pivot: Option, +} + +impl Motion { + /// A motion on `target` with no input, instant dynamics, output 0..1. + #[must_use] + pub fn new(target: Target) -> Self { + Self { + name: None, + target, + input: String::new(), + dynamics: Dynamics::Follow, + from: 0.0, + to: 1.0, + lag: false, + pivot: None, + } + } + + /// The same motion on another target. + #[must_use] + pub fn on(mut self, target: Target) -> Self { + self.target = target; + self + } + + #[must_use] + pub fn named(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + #[must_use] + pub fn input(mut self, expr: impl Into) -> Self { + self.input = expr.into(); + self + } + + #[must_use] + pub fn dynamics(mut self, d: Dynamics) -> Self { + self.dynamics = d; + self + } + + /// Map output 0 → `from`, 1 → `to`. + #[must_use] + pub fn range(mut self, from: f64, to: f64) -> Self { + self.from = from; + self.to = to; + self + } + + #[must_use] + pub fn lag(mut self) -> Self { + self.lag = true; + self + } + + #[must_use] + pub fn pivot(mut self, x: f64, y: f64) -> Self { + self.pivot = Some(Pivot { x, y }); + self + } + + /// The input expression the runtime evaluates (the dynamics' natural + /// input when [`Self::input`] is empty). + #[must_use] + pub fn input_expr(&self) -> &str { + if !self.input.trim().is_empty() { + return &self.input; + } + match self.dynamics { + Dynamics::Keyframes { .. } => "time", + Dynamics::Lfo { .. } | Dynamics::Noise { .. } | Dynamics::Spin => "1", + _ => "0", + } + } + + /// Every expression this motion evaluates, with where it sits (for + /// lint messages): the input, the dynamics' bound numbers, a shape's + /// inputs. + #[must_use] + pub fn expressions(&self) -> Vec<(String, &str)> { + let mut out = vec![("input".to_string(), self.input_expr())]; + for (k, n) in self.dynamics.nums() { + if let Num::Expr(e) = n { + out.push((format!("dynamics.{k}"), e.as_str())); + } + } + if let Some(e) = self.dynamics.excite() { + out.push(("dynamics.excite".into(), e)); + } + if let Target::Shape { inputs, .. } = &self.target { + for (k, n) in inputs { + if let Num::Expr(e) = n { + out.push((format!("target.inputs.{k}"), e.as_str())); + } + } + } + out + } + + /// Structural problems (bad expressions, a morph with < 2 shapes, a + /// colour mix with a bad colour, keyframes out of order) — not + /// whether the names resolve; that needs a catalog (`design-lint`). + #[must_use] + pub fn problems(&self) -> Vec { + let mut out = Vec::new(); + for (at, e) in self.expressions() { + if let Err(err) = expr::parse(e) { + out.push(format!("{at}: `{e}`: {err}")); + } + } + match &self.target { + Target::Fill { colors } | Target::Stroke { colors } => { + if colors.is_empty() { + out.push("target: a colour mix needs at least one colour".into()); + } + for c in colors { + if parse_hex(c).is_none() { + out.push(format!( + "target: `{c}` is not a colour (#rrggbb or #rrggbbaa)" + )); + } + } + } + Target::Morph { shapes } if shapes.len() < 2 => { + out.push("target: a morph needs at least two shapes".into()) + } + _ => {} + } + if let Dynamics::Keyframes { times, values, .. } = &self.dynamics { + if times.is_empty() || times.len() != values.len() { + out.push(format!( + "dynamics.keyframes: {} times but {} values", + times.len(), + values.len() + )); + } + if times.windows(2).any(|w| w[1] < w[0]) { + out.push("dynamics.keyframes: times must not decrease".into()); + } + } + out + } +} + +/// A point in a node's box, as fractions of its width and height. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Facet)] +pub struct Pivot { + pub x: f64, + pub y: f64, +} + +impl Default for Pivot { + fn default() -> Self { + Self { x: 0.5, y: 0.5 } + } +} + +/// The property a motion writes. Geometric targets compose onto the node's +/// transform (after any control has moved it), in the order the motions +/// are listed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[serde(rename_all = "snake_case")] +#[repr(u8)] +pub enum Target { + /// Writes nothing: a named signal other motions read (`motion(name)`). + None, + /// Move right by the value, px. + X, + /// Move down by the value, px. + Y, + /// Turn clockwise by the value, degrees, about the pivot. + Rotate, + /// Scale by the value (1 = as drawn) about the pivot. + Scale, + /// Stretch along x by the value (1 = as drawn), anchored at the pivot: + /// a spring or coil pulled out. + StretchX, + /// Stretch along y by the value (1 = as drawn), anchored at the pivot. + StretchY, + /// Shear x by y, degrees. + SkewX, + /// Shear y by x, degrees. + SkewY, + /// Set opacity (0..1) — overrides a control's reading unless `lag`. + Opacity, + /// Solid fill colour: the value (0..1) blends along `colors`. + Fill { colors: Vec }, + /// Stroke colour: as `Fill`. + Stroke { colors: Vec }, + /// Scale the node's emission intensity by the value (a lamp without + /// light stays dark). + Emission, + /// Shift the stroke's dash pattern by the value, px (marching ants, + /// tape moving). + DashOffset, + /// Blend between keyed outlines: value 0 = `shapes[0]`, 1 = + /// `shapes[1]`, … (paths with the same commands blend point by point; + /// others switch at the midpoint). + Morph { shapes: Vec }, + /// Geometry from a registered shape provider (`coil`, `reverb.room`, + /// `wave`, …) fed `inputs` (numbers or expressions), plus `value` — + /// this motion's output. `part` picks one of the provider's outlines. + Shape { + provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + part: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[facet(default)] + inputs: BTreeMap, + }, +} + +/// A number, or an expression evaluated each frame (a dynamics parameter +/// bound to a param: `"damping": "0.1 + norm(spring/damping)"`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet)] +#[serde(untagged)] +#[repr(u8)] +pub enum Num { + Value(f64), + Expr(String), +} + +impl From for Num { + fn from(v: f64) -> Self { + Self::Value(v) + } +} + +impl From<&str> for Num { + fn from(v: &str) -> Self { + Self::Expr(v.into()) + } +} + +impl From for Num { + fn from(v: String) -> Self { + Self::Expr(v) + } +} + +/// How a motion's output follows its input. Every parameter is a [`Num`]: +/// a constant or an expression. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Facet, Default)] +#[serde(rename_all = "snake_case")] +#[repr(u8)] +pub enum Dynamics { + /// Output = input. + #[default] + Follow, + /// A mass on a spring pulled toward the input: `stiffness` (N/m), + /// `damping` (N·s/m), `mass` (kg). The defaults (170, 26, 1) settle + /// quickly with no overshoot to speak of; lower damping bounces + /// (critical damping is `2·√(stiffness·mass)`). + Spring { + #[serde(default = "Num::stiffness")] + stiffness: Num, + #[serde(default = "Num::damping")] + damping: Num, + #[serde(default = "Num::one")] + mass: Num, + }, + /// A struck resonator resting at the input: rings at `freq` Hz when + /// `excite` rises (each rise kicks it by `gain` × the rise), dying away + /// over `decay` seconds (to −60 dB) — or with damping ratio `damping` + /// when `decay` is absent. A reverb spring hit by the input level. + Oscillator { + #[serde(default = "Num::freq")] + freq: Num, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + decay: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[facet(default)] + damping: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + #[facet(default)] + excite: String, + #[serde(default = "Num::one")] + gain: Num, + }, + /// input × a wave at `rate` Hz, 0..1 (`phase` in turns). + Lfo { + #[serde(default = "Num::one")] + rate: Num, + #[serde(default)] + #[facet(default)] + shape: Wave, + #[serde(default, skip_serializing_if = "is_zero")] + #[facet(default)] + phase: f64, + }, + /// input + `amount` × smooth noise (−1..1) changing at `rate` Hz: + /// flicker, drift, hum. + Noise { + #[serde(default = "Num::rate")] + rate: Num, + #[serde(default = "Num::amount")] + amount: Num, + }, + /// A meter's movement: rises with time constant `attack`, falls with + /// `release` (seconds). + Ballistic { + #[serde(default = "Num::attack")] + attack: Num, + #[serde(default = "Num::release")] + release: Num, + }, + /// The input (by default `time`, seconds) looked up in a keyed curve: + /// `values[i]` at `times[i]`, eased between; `loop` wraps the input. + Keyframes { + times: Vec, + values: Vec, + #[serde(default)] + #[facet(default)] + easing: Easing, + #[serde(default, rename = "loop", skip_serializing_if = "is_false")] + #[facet(default)] + looping: bool, + }, + /// Turn continuously: the output advances by the input (turns per + /// second) each second, wrapping at 1 — `to: 360` for degrees. Reels, + /// fans, a rotating speaker. + Spin, +} + +impl Num { + fn one() -> Self { + Self::Value(1.0) + } + fn stiffness() -> Self { + Self::Value(170.0) + } + fn damping() -> Self { + Self::Value(26.0) + } + fn freq() -> Self { + Self::Value(4.0) + } + fn rate() -> Self { + Self::Value(8.0) + } + fn amount() -> Self { + Self::Value(0.1) + } + fn attack() -> Self { + Self::Value(0.01) + } + fn release() -> Self { + Self::Value(0.3) + } +} + +impl Dynamics { + #[must_use] + pub fn is_follow(&self) -> bool { + matches!(self, Self::Follow) + } + + /// A spring with the default stiffness and mass and `damping`. + #[must_use] + pub fn spring(stiffness: impl Into, damping: impl Into) -> Self { + Self::Spring { + stiffness: stiffness.into(), + damping: damping.into(), + mass: Num::one(), + } + } + + /// The bound numbers, by field name. + #[must_use] + pub fn nums(&self) -> Vec<(&'static str, &Num)> { + match self { + Self::Spring { + stiffness, + damping, + mass, + } => vec![ + ("stiffness", stiffness), + ("damping", damping), + ("mass", mass), + ], + Self::Oscillator { + freq, + decay, + damping, + gain, + .. + } => { + let mut v = vec![("freq", freq), ("gain", gain)]; + if let Some(d) = decay { + v.push(("decay", d)); + } + if let Some(d) = damping { + v.push(("damping", d)); + } + v + } + Self::Lfo { rate, .. } => vec![("rate", rate)], + Self::Noise { rate, amount } => vec![("rate", rate), ("amount", amount)], + Self::Ballistic { attack, release } => vec![("attack", attack), ("release", release)], + Self::Follow | Self::Keyframes { .. } | Self::Spin => Vec::new(), + } + } + + /// An oscillator's excitation expression. + #[must_use] + pub fn excite(&self) -> Option<&str> { + match self { + Self::Oscillator { excite, .. } if !excite.trim().is_empty() => Some(excite), + _ => None, + } + } +} + +/// An LFO's wave, 0..1. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet, Default)] +#[serde(rename_all = "snake_case")] +#[repr(u8)] +pub enum Wave { + /// 0 at phase 0, 1 at ½. + #[default] + Sine, + Triangle, + /// Ramp up. + Saw, + Square, + /// A new random level each cycle. + SampleHold, +} + +/// Easing between keyframes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Facet, Default)] +#[serde(rename_all = "snake_case")] +#[repr(u8)] +pub enum Easing { + #[default] + Linear, + EaseIn, + EaseOut, + EaseInOut, + /// Hold each value until the next key. + Step, +} + +/// `#rgb`, `#rrggbb` or `#rrggbbaa` → straight RGBA 0..1. +#[must_use] +pub fn parse_hex(s: &str) -> Option<[f64; 4]> { + let h = s.strip_prefix('#')?; + let byte = |i: usize| { + u8::from_str_radix(h.get(i..i + 2)?, 16) + .ok() + .map(|b| f64::from(b) / 255.0) + }; + match h.len() { + 3 => { + let n = |i: usize| { + u8::from_str_radix(h.get(i..=i)?, 16) + .ok() + .map(|b| f64::from(b * 17) / 255.0) + }; + Some([n(0)?, n(1)?, n(2)?, 1.0]) + } + 6 => Some([byte(0)?, byte(2)?, byte(4)?, 1.0]), + 8 => Some([byte(0)?, byte(2)?, byte(4)?, byte(6)?]), + _ => None, + } +} diff --git a/crates/frame/src/motion/runtime/mod.rs b/crates/frame/src/motion/runtime/mod.rs new file mode 100644 index 00000000..5d5fc134 --- /dev/null +++ b/crates/frame/src/motion/runtime/mod.rs @@ -0,0 +1,1036 @@ +//! Runs the motions on a document's nodes. +//! +//! An [`Animator`] is compiled from a [`Scene`] (every `Node::motion`), +//! then driven by the host once per frame: +//! +//! 1. [`Animator::advance`]`(t, inputs)` — samples every source the +//! motions read once (params, feeds, gestures; the host implements +//! [`Inputs`]), evaluates each motion's input and bound dynamics +//! parameters, and integrates the dynamics with a **fixed 1 ms step** +//! (an accumulator carries the remainder, a stall is capped at 100 ms +//! of simulation), so a spring behaves the same at 30 Hz and 240 Hz and +//! an offline render at a fixed frame rate is deterministic. +//! 2. [`Animator::apply`]`(scene)` — writes each output into its node on +//! the render copy: only animated nodes change. +//! +//! [`Animator::is_animating`] says whether another frame would differ (a +//! spring still moving, an LFO running); settled springs let hosts sleep. +//! [`Animator::swap`] rebuilds from an edited document and keeps the state +//! of every motion whose node (and motion slot and dynamics kind) is still +//! there — live reload doesn't restart a ringing spring. +//! +//! Clock rules: the first frame (and a frame earlier than the last one) +//! starts every motion *at rest on its input* — a spring at its target, a +//! resonator still — and puts time-driven ones (LFO, noise, spin) at the +//! phase `rate × t`, so a single offline frame at `--time T` is +//! deterministic. A source that appears (a feed's first frame) snaps the +//! motions reading it to rest there too. + +use std::collections::{BTreeMap, HashMap}; + +use crate::document::proto::{Node, NodeId, NodeKind, Paint, PaintKind, Scene, Transform}; +use crate::motion::proto::{Dynamics, Easing, Expr, Func, Motion, Num, Source, Target, Wave, expr}; + +mod morph; +pub mod shapes; + +pub use shapes::{ShapeCtx, ShapeFn, ShapeRegistry}; + +/// The fixed integration step, seconds. +pub const STEP: f64 = 0.001; +/// The most simulated time one frame catches up on after a stall, seconds. +pub const MAX_CATCH_UP: f64 = 0.1; + +/// What the host samples for the motions. Only called for sources some +/// motion reads, once per frame each. +pub trait Inputs { + /// A param or feed reading; `None` while it has no value (a feed + /// before its first frame, an address the engine doesn't have). + fn source(&self, source: &Source) -> Option; + /// Whether the pointer is over the control `node` belongs to. + fn hover(&self, _node: NodeId) -> bool { + false + } + /// Whether the control `node` belongs to is being pressed / dragged. + fn press(&self, _node: NodeId) -> bool { + false + } +} + +/// No sources at all: every param and feed is missing. +impl Inputs for () { + fn source(&self, _: &Source) -> Option { + None + } +} + +/// Sources from a map (tests, tools). +impl Inputs for HashMap { + fn source(&self, s: &Source) -> Option { + self.get(s).copied() + } +} + +// ── compiled expressions ───────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum Gesture { + Hover, + Press, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum Key { + Source(Source), + Gesture(Gesture, NodeId), +} + +#[derive(Debug, Clone)] +enum C { + Num(f64), + Time, + Sample(usize), + /// Another motion's output (`None` = no such motion: missing). + Motion(Option), + Neg(Box), + Add(Box, Box), + Sub(Box, Box), + Mul(Box, Box), + Div(Box, Box), + Call(Func, Vec), +} + +struct Env<'a> { + time: f64, + samples: &'a [Option], + outputs: &'a [Option], +} + +impl C { + fn eval(&self, env: &Env<'_>) -> Option { + let bin = |a: &C, b: &C, f: fn(f64, f64) -> f64| Some(f(a.eval(env)?, b.eval(env)?)); + match self { + C::Num(v) => Some(*v), + C::Time => Some(env.time), + C::Sample(i) => env.samples[*i], + C::Motion(i) => i.and_then(|i| env.outputs[i]), + C::Neg(a) => a.eval(env).map(|v| -v), + C::Add(a, b) => bin(a, b, |x, y| x + y), + C::Sub(a, b) => bin(a, b, |x, y| x - y), + C::Mul(a, b) => bin(a, b, |x, y| x * y), + C::Div(a, b) => bin(a, b, |x, y| if y.abs() < 1e-12 { 0.0 } else { x / y }), + C::Call(Func::Or, args) => args.iter().find_map(|a| a.eval(env)), + C::Call(f, args) => { + let mut v = [0.0; 8]; + let n = args.len().min(8); + for (slot, a) in v.iter_mut().zip(args) { + *slot = a.eval(env)?; + } + let r = f.apply(&v[..n]); + r.is_finite().then_some(r) + } + } + } +} + +/// Compiling: sources interned into the animator's sample table, motion +/// names resolved to program indices. +struct Compiler<'a> { + keys: &'a mut Vec, + index: &'a mut HashMap, + node: NodeId, + resolve: &'a dyn Fn(NodeId, &str) -> Option, + deps: Vec, +} + +impl Compiler<'_> { + fn key(&mut self, k: Key) -> usize { + if let Some(i) = self.index.get(&k) { + return *i; + } + let i = self.keys.len(); + self.keys.push(k.clone()); + self.index.insert(k, i); + i + } + + fn compile(&mut self, e: &Expr) -> C { + let b = |c: &mut Self, e: &Expr| Box::new(c.compile(e)); + match e { + Expr::Num(v) => C::Num(*v), + Expr::Time => C::Time, + Expr::Hover => C::Sample(self.key(Key::Gesture(Gesture::Hover, self.node))), + Expr::Press => C::Sample(self.key(Key::Gesture(Gesture::Press, self.node))), + Expr::Src(s) => C::Sample(self.key(Key::Source(s.clone()))), + Expr::Motion(name) => { + let i = (self.resolve)(self.node, name); + self.deps.extend(i); + C::Motion(i) + } + Expr::Neg(a) => C::Neg(b(self, a)), + Expr::Add(x, y) => C::Add(b(self, x), b(self, y)), + Expr::Sub(x, y) => C::Sub(b(self, x), b(self, y)), + Expr::Mul(x, y) => C::Mul(b(self, x), b(self, y)), + Expr::Div(x, y) => C::Div(b(self, x), b(self, y)), + Expr::Call(f, args) => C::Call(*f, args.iter().map(|a| self.compile(a)).collect()), + } + } + + /// A bad expression compiles to "missing" (lint reports it). + fn text(&mut self, src: &str) -> (C, bool) { + match expr::parse(src) { + Ok(e) => (self.compile(&e), e.uses_time()), + Err(_) => (C::Motion(None), false), + } + } + + fn num(&mut self, n: &Num) -> (C, bool) { + match n { + Num::Value(v) => (C::Num(*v), false), + Num::Expr(s) => self.text(s), + } + } +} + +// ── programs and their state ───────────────────────────────────────────── + +struct Prog { + node: NodeId, + slot: usize, + motion: Motion, + input: C, + /// The dynamics' bound numbers, in `Dynamics::nums` order. + params: Vec, + excite: Option, + shape_inputs: Vec<(String, C)>, + /// Some expression reads `time`. + timed: bool, + seed: u64, +} + +impl Prog { + fn sig(&self) -> u8 { + dyn_sig(&self.motion.dynamics) + } +} + +fn dyn_sig(d: &Dynamics) -> u8 { + match d { + Dynamics::Follow => 0, + Dynamics::Spring { .. } => 1, + Dynamics::Oscillator { .. } => 2, + Dynamics::Lfo { .. } => 3, + Dynamics::Noise { .. } => 4, + Dynamics::Ballistic { .. } => 5, + Dynamics::Keyframes { .. } => 6, + Dynamics::Spin => 7, + } +} + +#[derive(Debug, Clone, Default)] +struct State { + started: bool, + /// Position (spring, resonator, ballistic) or phase in turns (LFO, + /// noise, spin). + x: f64, + v: f64, + input: Option, + excite: Option, + settled: bool, + shape: Vec, +} + +/// Evaluates a scene's motions. See the crate docs. +pub struct Animator { + progs: Vec, + /// Evaluation order: a motion after the motions it reads. + order: Vec, + keys: Vec, + samples: Vec>, + states: Vec, + outputs: Vec>, + /// Programs per node, in the node's motion order. + by_node: BTreeMap>, + clock: Option, + acc: f64, + animating: bool, + shapes: ShapeRegistry, + shape_cache: ShapeCache, +} + +/// Memoised provider output per program: (inputs, node size, outlines). +type ShapeCache = HashMap, (f64, f64), Vec)>; + +impl std::fmt::Debug for Animator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Animator") + .field("motions", &self.progs.len()) + .field("clock", &self.clock) + .field("animating", &self.animating) + .finish_non_exhaustive() + } +} + +impl Default for Animator { + fn default() -> Self { + Self::new(&Scene::new("empty")) + } +} + +impl Animator { + /// Compile every motion in `scene` (builtin shape providers). + #[must_use] + pub fn new(scene: &Scene) -> Self { + Self::with_shapes(scene, ShapeRegistry::builtin()) + } + + /// Compile with a shape registry of the host's. + #[must_use] + pub fn with_shapes(scene: &Scene, shapes: ShapeRegistry) -> Self { + let mut a = Self { + progs: Vec::new(), + order: Vec::new(), + keys: Vec::new(), + samples: Vec::new(), + states: Vec::new(), + outputs: Vec::new(), + by_node: BTreeMap::new(), + clock: None, + acc: 0.0, + animating: false, + shapes, + shape_cache: HashMap::new(), + }; + a.compile(scene); + a + } + + /// Register (or override) a shape provider. + pub fn register_shape(&mut self, name: impl Into, f: ShapeFn) { + self.shapes.register(name, f); + self.shape_cache.clear(); + } + + #[must_use] + pub fn shapes(&self) -> &ShapeRegistry { + &self.shapes + } + + /// How many motions there are. + #[must_use] + pub fn len(&self) -> usize { + self.progs.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.progs.is_empty() + } + + /// Whether the next frame would look different: a spring or resonator + /// still moving, an LFO / noise / spin running, an input reading + /// `time`. False before the first frame. + #[must_use] + pub fn is_animating(&self) -> bool { + self.animating + } + + /// Motion `slot` of `node`'s output (mapped), once it has one. + #[must_use] + pub fn output(&self, node: NodeId, slot: usize) -> Option { + self.by_node + .get(&node) + .and_then(|v| v.get(slot)) + .and_then(|i| self.outputs[*i]) + } + + /// Rebuild from an edited scene, keeping each motion's state when its + /// node, slot and dynamics kind are unchanged (and the clock). + pub fn swap(&mut self, scene: &Scene) { + let old: HashMap<(NodeId, usize), (u8, State, Option)> = self + .progs + .iter() + .enumerate() + .map(|(i, p)| { + ( + (p.node, p.slot), + ( + p.sig(), + std::mem::take(&mut self.states[i]), + self.outputs[i], + ), + ) + }) + .collect(); + self.compile(scene); + // A scene left with no motions is still: nothing will step it to + // say so (hosts skip an empty animator), so say it here. + if self.progs.is_empty() { + self.animating = false; + } + let mut old = old; + for (i, p) in self.progs.iter().enumerate() { + if let Some((sig, st, out)) = old.remove(&(p.node, p.slot)) + && sig == p.sig() + { + self.states[i] = st; + self.outputs[i] = out; + } + } + self.shape_cache.clear(); + } + + fn compile(&mut self, scene: &Scene) { + self.progs.clear(); + self.keys.clear(); + self.by_node.clear(); + // Every (node, slot) with a motion, in document order. + let order = scene.walk(scene.root); + let mut slots: Vec<(NodeId, usize)> = Vec::new(); + for id in &order { + if let Some(n) = scene.node(*id) { + for s in 0..n.motion.len() { + slots.push((*id, s)); + } + } + } + let index_of: HashMap<(NodeId, usize), usize> = + slots.iter().enumerate().map(|(i, k)| (*k, i)).collect(); + // Names: on the node, then up its ancestors, then anywhere. + let mut global: HashMap<&str, usize> = HashMap::new(); + for (i, (id, s)) in slots.iter().enumerate() { + if let Some(name) = scene.node(*id).and_then(|n| n.motion[*s].name.as_deref()) { + global.entry(name).or_insert(i); + } + } + let resolve = |from: NodeId, name: &str| -> Option { + let mut at = Some(from); + while let Some(id) = at { + let n = scene.node(id)?; + if let Some(s) = n + .motion + .iter() + .position(|m| m.name.as_deref() == Some(name)) + { + return index_of.get(&(id, s)).copied(); + } + at = n.parent; + } + global.get(name).copied() + }; + let mut index = HashMap::new(); + let mut deps: Vec> = Vec::new(); + for (id, s) in &slots { + let Some(node) = scene.node(*id) else { + continue; + }; + let m = node.motion[*s].clone(); + let mut c = Compiler { + keys: &mut self.keys, + index: &mut index, + node: *id, + resolve: &resolve, + deps: Vec::new(), + }; + let (input, mut timed) = c.text(m.input_expr()); + let mut params = Vec::new(); + for (_, n) in m.dynamics.nums() { + let (p, t) = c.num(n); + timed |= t; + params.push(p); + } + let excite = m.dynamics.excite().map(|e| { + let (x, t) = c.text(e); + timed |= t; + x + }); + let shape_inputs = match &m.target { + Target::Shape { inputs, .. } => inputs + .iter() + .map(|(k, n)| { + let (x, t) = c.num(n); + timed |= t; + (k.clone(), x) + }) + .collect(), + _ => Vec::new(), + }; + deps.push(std::mem::take(&mut c.deps)); + let seed = seed_of(*id, *s); + self.by_node.entry(*id).or_default().push(self.progs.len()); + self.progs.push(Prog { + node: *id, + slot: *s, + motion: m, + input, + params, + excite, + shape_inputs, + timed, + seed, + }); + } + self.order = topo(&deps); + self.samples = vec![None; self.keys.len()]; + self.states = vec![State::default(); self.progs.len()]; + self.outputs = vec![None; self.progs.len()]; + self.shape_cache.clear(); + } + + /// Advance to `t` seconds, sampling sources from `inputs`. + pub fn advance(&mut self, t: f64, inputs: &dyn Inputs) { + for (k, s) in self.keys.iter().zip(self.samples.iter_mut()) { + *s = match k { + Key::Source(src) => inputs.source(src).filter(|v| v.is_finite()), + Key::Gesture(Gesture::Hover, n) => Some(f64::from(u8::from(inputs.hover(*n)))), + Key::Gesture(Gesture::Press, n) => Some(f64::from(u8::from(inputs.press(*n)))), + }; + } + // The frame's elapsed time: full for phase-driven dynamics, fixed + // steps (capped) for integrated ones. A fresh clock (or one that + // went back) starts everything at rest. + let restart = self.clock.is_none_or(|c| t < c - 1e-6); + let dt = if restart { + 0.0 + } else { + (t - self.clock.unwrap_or(t)).max(0.0) + }; + if restart { + for s in &mut self.states { + s.started = false; + } + self.acc = 0.0; + } else if self.animating { + self.acc = (self.acc + dt).min(MAX_CATCH_UP); + } else { + // Everything was at rest: whatever changed starts moving now, + // not as if it had been moving since the last frame. + self.acc = 0.0; + } + let steps = (self.acc / STEP + 1e-9).floor(); + self.acc = (self.acc - steps * STEP).max(0.0); + let steps = steps as u32; + self.clock = Some(t); + + let mut animating = false; + let order = std::mem::take(&mut self.order); + for &i in &order { + let (y, moving, shape) = { + let env = Env { + time: t, + samples: &self.samples, + outputs: &self.outputs, + }; + let p = &self.progs[i]; + let input = p.input.eval(&env); + let params: Vec> = p.params.iter().map(|c| c.eval(&env)).collect(); + let excite = p.excite.as_ref().and_then(|c| c.eval(&env)); + let shape: Vec = p + .shape_inputs + .iter() + .map(|(_, c)| c.eval(&env).unwrap_or(f64::NAN)) + .collect(); + let st = &mut self.states[i]; + let (y, moving) = step( + &p.motion.dynamics, + st, + Step { + input, + params: ¶ms, + excite, + dt, + steps, + t, + seed: p.seed, + }, + ); + ( + y.map(|y| { + if p.motion.lag { + y - input.unwrap_or(0.0) + } else { + y + } + }), + moving || p.timed, + shape, + ) + }; + let p = &self.progs[i]; + animating |= moving; + self.states[i].shape = shape; + self.outputs[i] = y.map(|y| p.motion.from + y * (p.motion.to - p.motion.from)); + } + self.order = order; + self.animating = animating; + } + + /// Write every output into its node in `scene` (the render copy). + /// Geometric targets compose onto the node's current transform (after + /// controls), in motion order. + pub fn apply(&mut self, scene: &mut Scene) { + for (node_id, progs) in &self.by_node { + let Some(node) = scene.nodes.get_mut(node_id) else { + continue; + }; + for &i in progs { + let Some(v) = self.outputs[i] else { continue }; + let p = &self.progs[i]; + apply_target( + node, + &p.motion, + v, + i, + &self.states[i].shape, + &self.shapes, + &mut self.shape_cache, + &p.shape_inputs, + ); + } + } + } +} + +/// Topological order over "reads" edges (cycles fall back to document +/// order: the later motion reads the earlier one's previous frame). +fn topo(deps: &[Vec]) -> Vec { + let n = deps.len(); + let mut state = vec![0u8; n]; + let mut out = Vec::with_capacity(n); + fn visit(i: usize, deps: &[Vec], state: &mut [u8], out: &mut Vec) { + if state[i] != 0 { + return; + } + state[i] = 1; + for &d in &deps[i] { + if d != i && state[d] == 0 { + visit(d, deps, state, out); + } + } + state[i] = 2; + out.push(i); + } + for i in 0..n { + visit(i, deps, &mut state, &mut out); + } + out +} + +fn seed_of(id: NodeId, slot: usize) -> u64 { + let b = id.0.as_u128(); + hash64( + (b as u64) + ^ ((b >> 64) as u64).rotate_left(17) + ^ (slot as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15), + ) +} + +fn hash64(mut x: u64) -> u64 { + x ^= x >> 33; + x = x.wrapping_mul(0xff51_afd7_ed55_8ccd); + x ^= x >> 33; + x = x.wrapping_mul(0xc4ce_b9fe_1a85_ec53); + x ^ (x >> 33) +} + +/// A lattice value in −1..1. +fn lattice(seed: u64, i: i64) -> f64 { + (hash64(seed ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15)) >> 11) as f64 + / (1u64 << 53) as f64 + * 2.0 + - 1.0 +} + +/// Smooth value noise, −1..1, one new value per unit of `x`. +fn noise(seed: u64, x: f64) -> f64 { + let n1 = |x: f64, s: u64| { + let i = x.floor(); + let f = x - i; + let f = f * f * (3.0 - 2.0 * f); + let a = lattice(s, i as i64); + let b = lattice(s, i as i64 + 1); + a + (b - a) * f + }; + (0.7 * n1(x, seed) + 0.3 * n1(x * 2.7 + 13.1, seed ^ 0xabcd)).clamp(-1.0, 1.0) +} + +fn wave(shape: Wave, phase: f64, seed: u64) -> f64 { + let p = phase - phase.floor(); + match shape { + Wave::Sine => 0.5 - 0.5 * (std::f64::consts::TAU * p).cos(), + Wave::Triangle => 1.0 - (2.0 * p - 1.0).abs(), + Wave::Saw => p, + Wave::Square => f64::from(u8::from(p >= 0.5)), + Wave::SampleHold => lattice(seed, phase.floor() as i64) * 0.5 + 0.5, + } +} + +fn ease(e: Easing, t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + match e { + Easing::Linear => t, + Easing::EaseIn => t * t * t, + Easing::EaseOut => 1.0 - (1.0 - t).powi(3), + Easing::EaseInOut => { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } + } + Easing::Step => 0.0, + } +} + +fn keyframes(times: &[f64], values: &[f64], easing: Easing, looping: bool, x: f64) -> f64 { + let n = times.len().min(values.len()); + if n == 0 { + return 0.0; + } + let (t0, t1) = (times[0], times[n - 1]); + let x = if looping && t1 > t0 { + t0 + (x - t0).rem_euclid(t1 - t0) + } else { + x + }; + if x <= t0 { + return values[0]; + } + if x >= t1 { + return values[n - 1]; + } + let k = times[..n].partition_point(|t| *t <= x).max(1); + let (a, b) = (times[k - 1], times[k]); + let u = if b > a { (x - a) / (b - a) } else { 1.0 }; + values[k - 1] + (values[k] - values[k - 1]) * ease(easing, u) +} + +struct Step<'a> { + input: Option, + params: &'a [Option], + excite: Option, + /// The frame's elapsed seconds (phase-driven dynamics). + dt: f64, + /// Fixed steps to integrate (integrated dynamics). + steps: u32, + t: f64, + seed: u64, +} + +impl Step<'_> { + fn param(&self, i: usize, default: f64) -> f64 { + self.params + .get(i) + .copied() + .flatten() + .filter(|v| v.is_finite()) + .unwrap_or(default) + } +} + +const EPS: f64 = 1e-4; + +/// One frame of one motion's dynamics: its output (before lag/mapping; +/// `None` while it has never had an input) and whether it's still moving. +fn step(d: &Dynamics, st: &mut State, s: Step<'_>) -> (Option, bool) { + // A source that appears (or a fresh clock) starts at rest on it. + let fresh = !st.started || (st.input.is_none() && s.input.is_some()); + st.input = s.input.or(st.input); + let Some(input) = st.input else { + return (None, false); + }; + let h = STEP; + let n = s.steps; + match d { + Dynamics::Follow => { + st.started = true; + (Some(input), false) + } + Dynamics::Spring { .. } => { + let k = s.param(0, 170.0).max(0.0); + let c = s.param(1, 26.0).max(0.0); + let m = s.param(2, 1.0).max(1e-6); + // Keep the explicit step stable: ω·h and (c/m)·h below ~1.8. + let (k, c) = ((k / m).min((1.8 / h).powi(2)), (c / m).min(1.8 / h)); + if fresh { + (st.x, st.v, st.started) = (input, 0.0, true); + } + for _ in 0..n { + let a = k * (input - st.x) - c * st.v; + st.v += a * h; + st.x += st.v * h; + } + let scale = input.abs().max(1.0); + st.settled = (st.x - input).abs() < EPS * scale && st.v.abs() < EPS * scale; + if st.settled { + (st.x, st.v) = (input, 0.0); + } + (Some(st.x), !st.settled) + } + Dynamics::Oscillator { decay, .. } => { + let f = s.param(0, 4.0).clamp(0.0, 250.0); + let gain = s.param(1, 1.0); + let w = std::f64::consts::TAU * f; + // `decay` (T60, s) wins over `damping` (ζ); params after + // freq, gain are decay then damping, as present. + let mut idx = 2; + let zeta = match decay { + Some(_) => { + let t60 = s.param(idx, 1.0).max(1e-3); + idx += 1; + 6.907_755 / (w.max(1e-6) * t60) + } + None => s.param(idx, 0.05), + }; + let _ = idx; + let zw = (zeta * w).clamp(0.0, 0.9 / h); + if fresh { + (st.x, st.v, st.started) = (input, 0.0, true); + st.excite = s.excite; + } else if let Some(e) = s.excite { + let rise = (e - st.excite.unwrap_or(e)).max(0.0); + st.v += gain * rise * w; + st.excite = Some(e); + } + for _ in 0..n { + let a = -w * w * (st.x - input) - 2.0 * zw * st.v; + st.v += a * h; + st.x += st.v * h; + } + // Settled when the ring's energy is below the threshold. + let amp = ((st.x - input).powi(2) + (st.v / w.max(1e-6)).powi(2)).sqrt(); + st.settled = amp < EPS; + if st.settled { + (st.x, st.v) = (input, 0.0); + } + (Some(st.x), !st.settled) + } + Dynamics::Lfo { shape, phase, .. } => { + let rate = s.param(0, 1.0); + if fresh { + (st.x, st.started) = (rate * s.t, true); + } else { + st.x = (st.x + rate * s.dt).rem_euclid(1e6); + } + ( + Some(input * wave(*shape, st.x + phase, s.seed)), + rate != 0.0 && input != 0.0, + ) + } + Dynamics::Noise { .. } => { + let rate = s.param(0, 8.0); + let amount = s.param(1, 0.1); + if fresh { + (st.x, st.started) = (rate * s.t, true); + } else { + st.x = (st.x + rate * s.dt).rem_euclid(1e6); + } + ( + Some(input + amount * noise(s.seed, st.x)), + rate != 0.0 && amount != 0.0, + ) + } + Dynamics::Ballistic { .. } => { + let attack = s.param(0, 0.01).max(1e-4); + let release = s.param(1, 0.3).max(1e-4); + if fresh { + (st.x, st.started) = (input, true); + } + let (ka, kr) = (1.0 - (-h / attack).exp(), 1.0 - (-h / release).exp()); + for _ in 0..n { + let k = if input > st.x { ka } else { kr }; + st.x += (input - st.x) * k; + } + st.settled = (st.x - input).abs() < EPS * input.abs().max(1.0); + if st.settled { + st.x = input; + } + (Some(st.x), !st.settled) + } + Dynamics::Keyframes { + times, + values, + easing, + looping, + } => { + st.started = true; + ( + Some(keyframes(times, values, *easing, *looping, input)), + false, + ) + } + Dynamics::Spin => { + if fresh { + (st.x, st.started) = ((input * s.t).rem_euclid(1.0), true); + } else { + st.x = (st.x + input * s.dt).rem_euclid(1.0); + } + (Some(st.x), input != 0.0) + } + } +} + +// ── targets ────────────────────────────────────────────────────────────── + +/// `p ∘ q`: apply `q`, then `p`. +fn compose(p: &Transform, q: &Transform) -> Transform { + Transform { + a: p.a * q.a + p.b * q.c, + b: p.a * q.b + p.b * q.d, + tx: p.a * q.tx + p.b * q.ty + p.tx, + c: p.c * q.a + p.d * q.c, + d: p.c * q.b + p.d * q.d, + ty: p.c * q.tx + p.d * q.ty + p.ty, + } +} + +/// `lin` about the local point (`px`, `py`), then the node's transform. +fn about(t: &Transform, (a, b, c, d): (f64, f64, f64, f64), (px, py): (f64, f64)) -> Transform { + let l = Transform { + a, + b, + tx: px - (a * px + b * py), + c, + d, + ty: py - (c * px + d * py), + }; + compose(t, &l) +} + +fn mix(colors: &[String], v: f64) -> Option { + let cs: Vec<[f64; 4]> = colors + .iter() + .filter_map(|c| crate::motion::proto::parse_hex(c)) + .collect(); + let (first, last) = (cs.first()?, cs.last()?); + let c = if cs.len() == 1 || v <= 0.0 { + *first + } else if v >= 1.0 { + *last + } else { + let x = v * (cs.len() - 1) as f64; + let i = (x.floor() as usize).min(cs.len() - 2); + let f = x - i as f64; + let (a, b) = (cs[i], cs[i + 1]); + [ + a[0] + (b[0] - a[0]) * f, + a[1] + (b[1] - a[1]) * f, + a[2] + (b[2] - a[2]) * f, + a[3] + (b[3] - a[3]) * f, + ] + }; + Some(crate::document::proto::Color::rgba(c[0], c[1], c[2], c[3])) +} + +fn recolor(paints: &mut Vec, color: crate::document::proto::Color) { + let mut hit = false; + for p in paints.iter_mut() { + if let PaintKind::Solid { color: c } = &mut p.kind { + *c = color; + hit = true; + } + } + if !hit { + *paints = vec![Paint::solid(color)]; + } +} + +#[allow(clippy::too_many_arguments)] +fn apply_target( + node: &mut Node, + m: &Motion, + v: f64, + i: usize, + shape_args: &[f64], + shapes: &ShapeRegistry, + cache: &mut ShapeCache, + names: &[(String, C)], +) { + let pivot = m.pivot.unwrap_or_default(); + let p = (pivot.x * node.size.width, pivot.y * node.size.height); + let factor = if m.lag { 1.0 + v } else { v }; + match &m.target { + Target::None => {} + Target::X => node.transform.tx += v, + Target::Y => node.transform.ty += v, + Target::Rotate => { + let (s, c) = v.to_radians().sin_cos(); + node.transform = about(&node.transform, (c, -s, s, c), p); + } + Target::Scale => node.transform = about(&node.transform, (factor, 0.0, 0.0, factor), p), + Target::StretchX => node.transform = about(&node.transform, (factor, 0.0, 0.0, 1.0), p), + Target::StretchY => node.transform = about(&node.transform, (1.0, 0.0, 0.0, factor), p), + Target::SkewX => { + node.transform = about(&node.transform, (1.0, v.to_radians().tan(), 0.0, 1.0), p) + } + Target::SkewY => { + node.transform = about(&node.transform, (1.0, 0.0, v.to_radians().tan(), 1.0), p) + } + Target::Opacity => { + node.opacity = (if m.lag { node.opacity + v } else { v }).clamp(0.0, 1.0) + } + Target::Fill { colors } => { + if let Some(c) = mix(colors, v) { + recolor(&mut node.fills, c); + } + } + Target::Stroke { colors } => { + if let Some(c) = mix(colors, v) { + recolor(&mut node.stroke.paints, c); + } + } + Target::Emission => { + if let Some(e) = node.emission.as_mut() { + e.intensity = (if m.lag { + e.intensity + v + } else { + e.intensity * v + }) + .max(0.0); + } + } + Target::DashOffset => node.stroke.dash_offset += v, + Target::Morph { shapes: keyed } => { + if let Some(d) = morph::blend(keyed, v) { + set_geometry(node, vec![d]); + } + } + Target::Shape { provider, part, .. } => { + let size = (node.size.width, node.size.height); + let mut key: Vec = shape_args.to_vec(); + key.push(v); + if let Some((k, s, g)) = cache.get(&i) + && *k == key + && *s == size + { + let g = g.clone(); + set_geometry(node, g); + return; + } + let mut args: Vec<(&str, f64)> = names + .iter() + .map(|(k, _)| k.as_str()) + .zip(shape_args.iter().copied()) + .filter(|(_, v)| v.is_finite()) + .collect(); + args.push(("value", v)); + let ctx = ShapeCtx { + part: part.as_deref().unwrap_or(""), + width: size.0, + height: size.1, + args: &args, + }; + if let Some(g) = shapes.get(provider).and_then(|f| f(&ctx)) { + cache.insert(i, (key, size, g.clone())); + set_geometry(node, g); + } + } + } +} + +fn set_geometry(node: &mut Node, g: Vec) { + if !matches!(node.kind, NodeKind::Vector) { + node.kind = NodeKind::Vector; + } + node.fill_geometry = g; +} diff --git a/crates/frame/src/motion/runtime/morph.rs b/crates/frame/src/motion/runtime/morph.rs new file mode 100644 index 00000000..08026b43 --- /dev/null +++ b/crates/frame/src/motion/runtime/morph.rs @@ -0,0 +1,104 @@ +//! Blending keyed SVG paths. +//! +//! Paths with the same commands in the same order (only the numbers +//! differ) blend number by number; otherwise the blend switches from one +//! to the next at the midpoint. + +/// The path at `v` along `shapes` (0 = the first, 1 = the second, …). +pub(crate) fn blend(shapes: &[String], v: f64) -> Option { + let last = shapes.len().checked_sub(1)?; + if last == 0 || v <= 0.0 { + return shapes.first().cloned(); + } + if v >= last as f64 { + return shapes.last().cloned(); + } + let i = (v.floor() as usize).min(last - 1); + let f = v - i as f64; + let (a, b) = (split(&shapes[i]), split(&shapes[i + 1])); + if a.0 != b.0 || a.1.len() != b.1.len() { + return Some(if f < 0.5 { + shapes[i].clone() + } else { + shapes[i + 1].clone() + }); + } + let mut out = String::new(); + for (k, word) in a.0.iter().enumerate() { + out.push_str(word); + if let (Some(x), Some(y)) = (a.1.get(k), b.1.get(k)) { + let n = x + (y - x) * f; + out.push_str(&format!("{n:.3}")); + } + } + Some(out) +} + +/// A path as its text between numbers (normalised: runs of separators +/// become one space) and the numbers themselves: `words[k]` precedes +/// `numbers[k]`. +fn split(d: &str) -> (Vec, Vec) { + let b = d.as_bytes(); + let mut words = Vec::new(); + let mut nums = Vec::new(); + let mut word = String::new(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + let starts_num = c.is_ascii_digit() + || c == b'.' + || ((c == b'-' || c == b'+') + && b.get(i + 1) + .is_some_and(|n| n.is_ascii_digit() || *n == b'.')); + if starts_num { + let s = i; + i += 1; + let mut dot = c == b'.'; + while i < b.len() { + let d = b[i]; + if d.is_ascii_digit() { + i += 1; + } else if d == b'.' && !dot { + dot = true; + i += 1; + } else if (d == b'e' || d == b'E') + && b.get(i + 1) + .is_some_and(|n| n.is_ascii_digit() || *n == b'-' || *n == b'+') + { + i += 2; + } else { + break; + } + } + nums.push(d[s..i].parse::().unwrap_or(0.0)); + words.push(normal(&word)); + word.clear(); + } else { + word.push(c as char); + i += 1; + } + } + words.push(normal(&word)); + (words, nums) +} + +fn normal(w: &str) -> String { + let t: String = w.chars().map(|c| if c == ',' { ' ' } else { c }).collect(); + let parts: Vec<&str> = t.split_whitespace().collect(); + if parts.is_empty() { + if w.is_empty() { + String::new() + } else { + " ".into() + } + } else { + let mut s = parts.join(" "); + if w.starts_with([' ', ',']) { + s.insert(0, ' '); + } + if w.ends_with([' ', ',']) { + s.push(' '); + } + s + } +} diff --git a/crates/frame/src/motion/runtime/shapes.rs b/crates/frame/src/motion/runtime/shapes.rs new file mode 100644 index 00000000..7c926ff0 --- /dev/null +++ b/crates/frame/src/motion/runtime/shapes.rs @@ -0,0 +1,380 @@ +//! Shape providers: geometry computed from numbers. +//! +//! A `Target::Shape { provider, part, inputs }` motion replaces its node's +//! outline with `provider(inputs + value)` each time an input changes (the +//! result is memoised). Coordinates are the node's own box, so a provider +//! node usually spans the artwork it redraws. Builtins: +//! +//! | provider | parts | inputs | +//! |---|---|---| +//! | `coil` | `back`, `front`, `light` (the lit edge of the front turns) | `x0`, `x1`, `y`, `r` (half height), `pitch` (5.5), `bend` (px sideways at the middle), `ripple` (px, second mode), `squeeze` (px along the axis), `phase` (turns) | +//! | `reverb.room` | `back`, `outline`, `floor`, `edges`, `boards`, `speaker`, `cone`, `direct`, `rays`, `echoes`, `bounces`, `taps`, `tap_heads` | `size`, `decay`, `diffusion` (0..1) | +//! | `wave` | — | `cycles` (2), `amp` (0..1 of the half height, 0.8), `phase` (turns), `shape` (0 sine, 1 triangle, 2 saw, 3 square) | +//! +//! A host registers its own (an engine's exact room model) with +//! [`ShapeRegistry::register`]; a registration under a builtin's name +//! overrides it. + +use std::collections::BTreeMap; +use std::f64::consts::{PI, TAU}; +use std::fmt::Write as _; + +/// What a provider is asked for. +#[derive(Debug)] +pub struct ShapeCtx<'a> { + pub part: &'a str, + /// The node's box. + pub width: f64, + pub height: f64, + /// Named inputs (plus `value`, the motion's output). + pub args: &'a [(&'a str, f64)], +} + +impl ShapeCtx<'_> { + /// Input `name`, or `default`. + #[must_use] + pub fn get(&self, name: &str, default: f64) -> f64 { + self.args + .iter() + .find(|(k, _)| *k == name) + .map_or(default, |(_, v)| *v) + } +} + +/// Outlines (SVG path data, node-local) for a context; `None` = unknown part. +pub type ShapeFn = fn(&ShapeCtx<'_>) -> Option>; + +/// Providers by name. +#[derive(Debug, Clone, Default)] +pub struct ShapeRegistry { + map: BTreeMap, +} + +impl ShapeRegistry { + /// The builtin providers. + #[must_use] + pub fn builtin() -> Self { + let mut r = Self::default(); + r.register("coil", coil); + r.register("reverb.room", room); + r.register("wave", wave); + r + } + + pub fn register(&mut self, name: impl Into, f: ShapeFn) { + self.map.insert(name.into(), f); + } + + #[must_use] + pub fn get(&self, name: &str) -> Option { + self.map.get(name).copied() + } + + /// Registered names. + pub fn names(&self) -> impl Iterator { + self.map.keys().map(String::as_str) + } +} + +/// The builtin provider names (for lint without a runtime). +pub const BUILTIN: &[&str] = &["coil", "reverb.room", "wave"]; + +// ── path helpers ───────────────────────────────────────────────────────── + +fn polylines(lines: &[Vec<(f64, f64)>]) -> String { + let mut d = String::new(); + for line in lines { + for (i, (x, y)) in line.iter().enumerate() { + let _ = write!(d, "{} {x:.3} {y:.3} ", if i == 0 { "M" } else { "L" }); + } + } + d.truncate(d.trim_end().len()); + d +} + +fn closed(pts: &[(f64, f64)]) -> String { + let mut d = polylines(&[pts.to_vec()]); + d.push_str(" Z"); + d +} + +fn circles(cs: &[(f64, f64, f64)]) -> String { + let mut d = String::new(); + for (cx, cy, r) in cs { + let _ = write!( + d, + "M {:.3} {cy:.3} A {r:.3} {r:.3} 0 1 0 {:.3} {cy:.3} A {r:.3} {r:.3} 0 1 0 {:.3} {cy:.3} Z ", + cx - r, + cx + r, + cx - r + ); + } + d.truncate(d.trim_end().len()); + d +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +fn lerp2(a: (f64, f64), b: (f64, f64), t: f64) -> (f64, f64) { + (lerp(a.0, b.0, t), lerp(a.1, b.1, t)) +} + +fn fract(x: f64) -> f64 { + x - x.floor() +} + +/// A fixed pseudo-random 0..1 for `i`. +fn jitter(i: f64) -> f64 { + fract(((i * 12.9898).sin() * 43_758.545_3).abs()) +} + +// ── coil ───────────────────────────────────────────────────────────────── + +/// A spring seen from the side: loops of a helix between `x0..x1` at `y`, +/// displaced by its modes (`bend`: the fundamental, sideways; `ripple`: the +/// second, travelling with `phase`; `squeeze`: along the axis). The ends +/// stay on their hooks. +fn coil(c: &ShapeCtx<'_>) -> Option> { + let x0 = c.get("x0", 0.0); + let x1 = c.get("x1", c.width); + let y = c.get("y", c.height / 2.0); + let r = c.get("r", c.height / 2.0 * 0.8); + let pitch = c.get("pitch", 5.5).max(0.5); + let bend = c.get("bend", 0.0); + let ripple = c.get("ripple", 0.0); + let squeeze = c.get("squeeze", 0.0); + let phase = c.get("phase", 0.0); + let n = ((x1 - x0) / pitch).max(0.0) as usize; + let span = (x1 - x0).max(1e-9); + let (from, to, lift) = match c.part { + "back" => (PI, TAU, 0.0), + "front" => (0.0, PI, 0.0), + "light" => (0.0, PI, -0.45), + _ => return None, + }; + let mut lines = Vec::with_capacity(n); + for i in 0..n { + let cx0 = x0 + pitch * (i as f64 + 0.5); + let u = (cx0 - x0) / span; + let env = (PI * u).sin(); + let cx = cx0 + squeeze * (TAU * u).sin() * env; + let dy = bend * env + ripple * (TAU * (2.0 * u - phase)).sin() * env; + let pts: Vec<(f64, f64)> = (0..=10) + .map(|k| { + let t = from + (to - from) * f64::from(k) / 10.0; + ( + cx + pitch * 0.55 * t.cos() + pitch * 0.35 * t.sin(), + y + dy + r * t.sin() + lift, + ) + }) + .collect(); + lines.push(pts); + } + Some(vec![polylines(&lines)]) +} + +// ── reverb.room ────────────────────────────────────────────────────────── + +/// The room box's geometry for `size`: front opening, back wall. +struct Room { + front: (f64, f64, f64, f64), + back: (f64, f64, f64, f64), + src: (f64, f64), + ear: (f64, f64), +} + +impl Room { + fn new(h: f64, size: f64) -> Self { + let front = (22.0, 14.0, 318.0, h - 14.0); + let (cx, cy) = ((front.0 + front.2) / 2.0, (front.1 + front.3) / 2.0 - 2.0); + // Bigger rooms recede further: the back wall shrinks toward the + // vanishing point. + let k = 1.0 - 0.75 * (0.2 + 0.8 * size.clamp(0.0, 1.0)); + let at = |x: f64, y: f64| (cx + (x - cx) * k, cy + (y - cy) * k); + let (b0, b1) = (at(front.0, front.1), at(front.2, front.3)); + let back = (b0.0, b0.1, b1.0, b1.1); + let src = (lerp(back.0, back.2, 0.2), back.3 - 10.0 * k.max(0.4) / 0.58); + let ear = (front.0 + (front.2 - front.0) * 0.6, front.3 - 16.0); + Self { + front, + back, + src, + ear, + } + } + + /// Reflection point `i` on the walls (left, ceiling, right, back in + /// turn), placed by fixed irrational steps so it's stable per `i`. + fn bounce(&self, i: usize) -> (f64, f64) { + let (f, b) = (self.front, self.back); + let fi = i as f64; + let a = 0.15 + 0.7 * fract(0.31 + fi * 0.618_034); + let depth = 0.2 + 0.6 * fract(0.17 + fi * 0.381_966); + match i % 4 { + 0 => lerp2((f.0, lerp(f.1, f.3, a)), (b.0, lerp(b.1, b.3, a)), depth), + 1 => lerp2((lerp(f.0, f.2, a), f.1), (lerp(b.0, b.2, a), b.1), depth), + 2 => lerp2((f.2, lerp(f.1, f.3, a)), (b.2, lerp(b.1, b.3, a)), depth), + _ => (lerp(b.0, b.2, a), lerp(b.1, b.3, 0.15 + 0.5 * depth)), + } + } +} + +/// How many first reflections `diffusion` (0..1) draws. +fn reflections(diffusion: f64) -> usize { + 2 + (diffusion.clamp(0.0, 1.0) * 6.0).round() as usize +} + +/// The early-reflection taps for the diagram: (ms, level 0..1), the +/// direct sound first. Spacing grows with `size`, density with +/// `diffusion`, and levels fall slower with `decay`. +#[must_use] +pub fn room_taps(size: f64, decay: f64, diffusion: f64) -> Vec<(f64, f64)> { + let n = 5 + (diffusion.clamp(0.0, 1.0) * 10.0).round() as usize; + let spacing = 2.5 + 8.0 * size.clamp(0.0, 1.0); + let tau = 12.0 + 70.0 * decay.clamp(0.0, 1.0); + let mut taps = vec![(0.0, 1.0)]; + for j in 1..n { + let fj = j as f64; + let t = spacing * fj.powf(1.12) * (0.8 + 0.4 * jitter(fj + 3.0)); + if t > 78.0 { + break; + } + let a = (-t / tau).exp() * (0.7 + 0.3 * jitter(fj + 51.0)); + taps.push((t, a.clamp(0.02, 1.0))); + } + taps +} + +/// A studio room in perspective, redrawn from its params: the back wall +/// recedes with size, the reflection paths re-trace off the walls (their +/// count follows diffusion, the second-order ones follow decay), and the +/// tap diagram beside it spreads with size and lengthens with decay. +fn room(c: &ShapeCtx<'_>) -> Option> { + let (w, h) = (c.width, c.height); + let size = c.get("size", 0.45); + let decay = c.get("decay", 0.4); + let diffusion = c.get("diffusion", 0.6); + let r = Room::new(h, size); + let (f, b) = (r.front, r.back); + let n = reflections(diffusion); + let d = match c.part { + "back" => closed(&[(b.0, b.1), (b.2, b.1), (b.2, b.3), (b.0, b.3)]), + "outline" => polylines(&[vec![ + (b.0, b.1), + (b.2, b.1), + (b.2, b.3), + (b.0, b.3), + (b.0, b.1), + ]]), + "floor" => closed(&[(b.0, b.3), (b.2, b.3), (f.2, f.3), (f.0, f.3)]), + "edges" => polylines(&[ + vec![(f.0, f.1), (b.0, b.1)], + vec![(f.2, f.1), (b.2, b.1)], + vec![(f.0, f.3), (b.0, b.3)], + vec![(f.2, f.3), (b.2, b.3)], + ]), + "boards" => polylines( + &(1..8) + .map(|i| { + let t = f64::from(i) / 8.0; + vec![(lerp(b.0, b.2, t), b.3), (lerp(f.0, f.2, t), f.3)] + }) + .collect::>(), + ), + "speaker" => closed(&[ + (r.src.0 - 7.0, r.src.1 - 12.0), + (r.src.0 + 7.0, r.src.1 - 12.0), + (r.src.0 + 7.0, r.src.1 + 8.0), + (r.src.0 - 7.0, r.src.1 + 8.0), + ]), + "cone" => circles(&[(r.src.0, r.src.1 + 1.0, 4.5)]), + "direct" => polylines(&[vec![r.src, r.ear]]), + "rays" => polylines( + &(0..n) + .map(|i| vec![r.src, r.bounce(i), r.ear]) + .collect::>(), + ), + "echoes" => { + let m = (decay.clamp(0.0, 1.0) * n as f64).round() as usize; + if m == 0 { + String::from("M 0 0") + } else { + polylines( + &(0..m) + .map(|i| vec![r.src, r.bounce(i), r.bounce((i + 2) % n), r.ear]) + .collect::>(), + ) + } + } + "bounces" => circles( + &(0..n) + .map(|i| { + let p = r.bounce(i); + (p.0, p.1, 2.2) + }) + .collect::>(), + ), + "taps" | "tap_heads" => { + let (x0, x1, base, top) = (352.0, w - 20.0, h - 30.0, 26.0); + let taps = room_taps(size, decay, diffusion); + let at = |ms: f64, a: f64| (x0 + (x1 - x0) * ms / 80.0, base - (base - top) * a); + if c.part == "taps" { + polylines( + &taps + .iter() + .map(|(ms, a)| vec![(at(*ms, 0.0).0, base), at(*ms, *a)]) + .collect::>(), + ) + } else { + circles( + &taps + .iter() + .map(|(ms, a)| { + let p = at(*ms, *a); + (p.0, p.1, 1.8) + }) + .collect::>(), + ) + } + } + _ => return None, + }; + Some(vec![d]) +} + +// ── wave ───────────────────────────────────────────────────────────────── + +/// A periodic wave across the box: `cycles`, `amp` of the half height, +/// `phase` in turns, `shape` 0 sine / 1 triangle / 2 saw / 3 square. +fn wave(c: &ShapeCtx<'_>) -> Option> { + let (w, h) = (c.width, c.height); + let cycles = c.get("cycles", 2.0); + let amp = c.get("amp", 0.8) * h / 2.0; + let phase = c.get("phase", 0.0); + let shape = c.get("shape", 0.0).round() as i32; + let n = ((w / 2.0).ceil() as usize).clamp(16, 400); + let f = |p: f64| { + let p = fract(p); + match shape { + 1 => 1.0 - 4.0 * (p - 0.5).abs(), + 2 => 2.0 * p - 1.0, + 3 => { + if p < 0.5 { + 1.0 + } else { + -1.0 + } + } + _ => (TAU * p).sin(), + } + }; + let pts: Vec<(f64, f64)> = (0..=n) + .map(|i| { + let u = i as f64 / n as f64; + (u * w, h / 2.0 - amp * f(u * cycles + phase)) + }) + .collect(); + Some(vec![polylines(&[pts])]) +} diff --git a/crates/frame-ui/src/anyrender_renderer/cache.rs b/crates/frame/src/render/anyrender_renderer/cache.rs similarity index 86% rename from crates/frame-ui/src/anyrender_renderer/cache.rs rename to crates/frame/src/render/anyrender_renderer/cache.rs index 55f00e1f..52225b57 100644 --- a/crates/frame-ui/src/anyrender_renderer/cache.rs +++ b/crates/frame/src/render/anyrender_renderer/cache.rs @@ -15,10 +15,10 @@ pub(super) fn cached_bez_path(path_data: &str) -> Option> { path_data.hash(&mut hasher); let key = hasher.finish(); - if let Ok(cache) = CACHE.lock() { - if let Some(cached) = cache.get(&key) { - return Some(Arc::clone(cached)); - } + if let Ok(cache) = CACHE.lock() + && let Some(cached) = cache.get(&key) + { + return Some(Arc::clone(cached)); } let path = kurbo::BezPath::from_svg(path_data).ok()?; @@ -46,10 +46,10 @@ pub(super) fn cached_svg_decode(svg_base64: &str) -> Option> { svg_base64.hash(&mut hasher); let key = hasher.finish(); - if let Ok(cache) = CACHE.lock() { - if let Some(cached) = cache.get(&key) { - return Some(Arc::clone(cached)); - } + if let Ok(cache) = CACHE.lock() + && let Some(cached) = cache.get(&key) + { + return Some(Arc::clone(cached)); } let bytes = base64::engine::general_purpose::STANDARD @@ -79,10 +79,10 @@ pub(super) fn cached_svg_tree(svg_text: &str) -> Option RenderDiagnostics { + let mut out = RenderDiagnostics::default(); + for id in scene.walk(root) { + let Some(node) = scene.node(id) else { continue }; + out.total_nodes += 1; + if !matches!(node.blend, BlendMode::Normal | BlendMode::PassThrough) { + out.non_normal_blend_nodes += 1; + } + let fills = || node.fills.iter().filter(|p| p.visible); + if fills().any(|p| { + matches!( + p.kind, + PaintKind::LinearGradient { .. } + | PaintKind::RadialGradient { .. } + | PaintKind::AngularGradient { .. } + ) + }) { + out.gradient_fill_nodes += 1; + } + if fills().any(|p| matches!(p.kind, PaintKind::Image { .. })) { + out.image_fill_nodes += 1; + } + let has = |kind: EffectKind| node.effects.iter().any(|e| e.visible && e.kind == kind); + if has(EffectKind::DropShadow) { + out.drop_shadow_nodes += 1; + } + if has(EffectKind::InnerShadow) { + out.inner_shadow_nodes += 1; + } + if has(EffectKind::LayerBlur) { + out.layer_blur_nodes += 1; + // Layer blur is only approximated on text and non-drawing nodes. + if matches!( + node.kind, + NodeKind::Text(_) | NodeKind::Utility | NodeKind::Other { .. } + ) { + out.layer_blur_approx_nodes += 1; + } + } + if has(EffectKind::BackgroundBlur) { + out.background_blur_nodes += 1; + out.background_blur_approx_nodes += 1; + } + match node.mask { + Some(MaskKind::Alpha) => out.alpha_mask_nodes += 1, + Some(MaskKind::Luminance) => out.luminance_mask_nodes += 1, + Some(MaskKind::Vector) | None => {} + } + } + out +} diff --git a/crates/frame/src/render/anyrender_renderer/effects_plan.rs b/crates/frame/src/render/anyrender_renderer/effects_plan.rs new file mode 100644 index 00000000..512776d9 --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/effects_plan.rs @@ -0,0 +1,114 @@ +//! What the effects stage needs beyond the paint primitives: emissive draws +//! (light a node gives off) and shader regions (post-effects on a subtree). +//! +//! Collected alongside the primitives by +//! [`build_scene_primitives_and_effects`](super::build_scene_primitives_and_effects); +//! a scene without `emission`/`shader` yields an empty plan and exactly the +//! primitives [`build_scene_primitives_with`](super::build_scene_primitives_with) +//! would without a glow. + +use std::collections::BTreeMap; +use std::ops::Range; + +use crate::document::proto::NodeId; + +use super::types::{AffineTransform2D, PaintPrimitive, Rgba}; + +/// One emitting node, ready to bloom. +#[derive(Debug, Clone, PartialEq)] +pub struct EmissiveDraw { + pub node_id: NodeId, + /// The node's own geometry, recoloured to the (opaque) emission colour, + /// in flattened scene space (like every primitive). + pub primitives: Vec, + /// The enclosing groups' transform (flattened scene → world), already + /// composed; paint `primitives` under `root · world`. + pub world: AffineTransform2D, + /// The ancestors' open clips (`ClipStart`s), outermost first, each with + /// the world transform it was opened under: the light is cut to them + /// like the node itself is, so a needle's glow stays inside its window. + pub clips: Vec<(AffineTransform2D, PaintPrimitive)>, + /// The emission colour (alpha 1). + pub color: Rgba, + /// Light strength: emission intensity × the node's effective opacity. + pub strength: f64, + /// Blur radius in scene px. + pub radius: f64, + /// Params of the `flicker` shader on this node or an ancestor, when there + /// is one: its light flickers with it. + pub flicker: Option>, +} + +/// One node whose subtree runs through a shader. +#[derive(Debug, Clone, PartialEq)] +pub struct ShaderRegion { + pub node_id: NodeId, + /// The node's primitives (its own and its subtree's, including its own + /// group/layer/clip wrappers) as an index range into the primitive list. + pub range: Range, + /// Node-local box space (`0..w × 0..h`) → world scene space. + pub world: AffineTransform2D, + pub width: f64, + pub height: f64, + /// The shader id (`glass`, `crt`, …, or `asset:`). + pub shader: String, + pub params: BTreeMap, + /// The nearest control (this node or an ancestor) whose position feeds the + /// shader's `value`, with that control's default position. + pub control: Option<(NodeId, Option)>, + /// The live data the shader reads (resolved per frame by the host). + pub inputs: Vec, + /// The node's effective opacity (its own × its ancestors'): the effect's + /// result is laid down at this opacity, as the subtree it replaces + /// would have been. + pub opacity: f64, +} + +/// Everything the effects stage runs for one frame. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct EffectsPlan { + pub emissive: Vec, + pub shaders: Vec, +} + +impl EffectsPlan { + #[must_use] + pub fn is_empty(&self) -> bool { + self.emissive.is_empty() && self.shaders.is_empty() + } + + /// Whether frames differ over time (a `flicker`, or a shader — built-in + /// or custom — that reads `time_s`): the host must keep redrawing. + #[must_use] + pub fn time_dependent(&self, source: impl Fn(&str) -> Option) -> bool { + self.emissive.iter().any(|e| e.flicker.is_some()) + || self.shaders.iter().any(|s| match s.shader.as_str() { + "flicker" => true, + id => source(id).is_some_and(|src| src.contains("time_s")), + }) + } +} + +/// The tube-lamp brightness of a `flicker` at `time_s` (1 = steady), shared by +/// the shader and the emission it modulates so both flicker together. +/// `strength` is the depth (0..1), `speed` the jitter rate (Hz). +#[must_use] +pub fn flicker_level(time_s: f64, params: &BTreeMap) -> f64 { + let depth = params + .get("strength") + .copied() + .unwrap_or(0.5) + .clamp(0.0, 1.0); + let speed = params.get("speed").copied().unwrap_or(10.0).max(0.0); + let hash = |x: f64| ((x * 12.9898).sin() * 43_758.545_3).rem_euclid(1.0); + let x = time_s * speed; + let (i, f) = (x.floor(), x - x.floor()); + let s = f * f * (3.0 - 2.0 * f); + let noise = hash(i) * (1.0 - s) + hash(i + 1.0) * s; + let dropout = if hash((x * 0.37).floor() + 7.0) > 0.9 { + 0.6 + } else { + 0.0 + }; + (1.0 - depth * (noise * 0.5 + dropout)).clamp(0.0, 1.0) +} diff --git a/crates/frame-ui/src/anyrender_renderer/geometry.rs b/crates/frame/src/render/anyrender_renderer/geometry.rs similarity index 100% rename from crates/frame-ui/src/anyrender_renderer/geometry.rs rename to crates/frame/src/render/anyrender_renderer/geometry.rs diff --git a/crates/frame/src/render/anyrender_renderer/mod.rs b/crates/frame/src/render/anyrender_renderer/mod.rs new file mode 100644 index 00000000..9c450c1c --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/mod.rs @@ -0,0 +1,38 @@ +mod diagnostics; +pub mod effects_plan; +mod scene_collect; +pub mod transform; +mod types; + +#[cfg(feature = "anyrender")] +mod cache; +#[cfg(feature = "anyrender")] +mod geometry; +#[cfg(feature = "anyrender")] +mod paint; +#[cfg(feature = "anyrender")] +mod soft; +#[cfg(feature = "svg_export")] +pub(crate) mod subpaths; +#[cfg(feature = "anyrender")] +pub mod target; + +pub use diagnostics::collect_render_diagnostics; +pub use effects_plan::{EffectsPlan, EmissiveDraw, ShaderRegion, flicker_level}; +#[cfg(feature = "svg_export")] +pub(crate) use scene_collect::fill_paint; +pub use scene_collect::{ + CollectOptions, build_scene_layout_boxes, build_scene_primitives, + build_scene_primitives_and_effects, build_scene_primitives_with, text_font, +}; +pub use transform::AffineTransform2D; +pub use types::{ + BlendMix, EffectKind, FillPaint, GradientStop, ImageScaleMode, LayoutBox, NodeEffect, + PaintPrimitive, RenderDiagnostics, Rgba, StrokeAlign, StrokeCap, StrokeJoin, StrokeStyle, + TextAlignment, TextCase, TextStyleRun, stroke_gradient_box, +}; + +#[cfg(feature = "anyrender")] +pub use paint::{paint_primitives_into_scene_with, paint_scene_into}; +#[cfg(feature = "anyrender")] +pub use target::{GlyphRun, PaintTarget}; diff --git a/crates/frame/src/render/anyrender_renderer/paint.rs b/crates/frame/src/render/anyrender_renderer/paint.rs new file mode 100644 index 00000000..35dc11de --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/paint.rs @@ -0,0 +1,2444 @@ +use super::cache::{cached_bez_path, cached_svg_decode, cached_svg_fit_transform}; +use super::geometry::{compute_path_fit_transform, extract_scale, to_peniko}; +use super::scene_collect::first_color; +use super::soft::{self, Soft}; +use super::subpaths::split_open_closed; +use super::target::{GlyphRun, PaintTarget}; +use super::types::*; +use crate::render::font_book::FontBook; + +const TEXT_ANCHOR_SHIFT_X: f64 = 1.0; +const TEXT_ANCHOR_SHIFT_Y: f64 = 1.0; + +/// Paint a frame document (`root`'s subtree) into a vello scene. Text is +/// set from `fonts` (the host's book) plus the fonts the document carries; +/// without a book, text is drawn as baseline bars. +pub fn paint_scene_into( + scene: &mut S, + doc: &crate::document::proto::Scene, + root: crate::document::proto::NodeId, + scene_transform: kurbo::Affine, + fonts: Option<&FontBook>, +) { + let primitives = super::scene_collect::build_scene_primitives(doc, root); + let book = fonts.map(|f| crate::render::fonts::book_for_scene(f, doc)); + paint_primitives_into_scene_with(scene, &primitives, scene_transform, book.as_deref()); +} + +/// Draw one invisible late-bound resource (a transparent gradient on a pixel +/// far outside any target) so vello never resolves this scene on its +/// solid-paths-only fast path. +/// +/// That path (vello 0.10's `Resolver::resolve` for a scene without images, +/// gradients or glyphs) reports an empty image atlas, so the renderer drops +/// its atlas texture for a 1×1 one — while the resolver still counts every +/// image uploaded before as resident. The next scene with those images +/// gets a fresh, blank atlas and uploads nothing: its textures draw as +/// nothing. The effects stage renders several scenes a frame (segments, +/// bloom passes of plain shapes), so from the second frame on every texture +/// vanished; a reused `Offscreen` did the same after any plain scene. +pub(super) fn keep_image_atlas(scene: &mut vello::Scene) { + let clear = peniko::Color::TRANSPARENT; + let gradient = + peniko::Gradient::new_linear((0.0, 0.0), (1.0, 0.0)).with_stops([clear, clear].as_slice()); + let far = kurbo::Rect::new(-1.0e6, -1.0e6, -1.0e6 + 1.0, -1.0e6 + 1.0); + scene.fill( + peniko::Fill::NonZero, + kurbo::Affine::IDENTITY, + &gradient, + None, + &far, + ); +} + +/// A face's bytes as a vello font, cached by face so every frame (and +/// vello's glyph cache) sees the same blob. +fn font_data(face: &crate::render::font_book::Face) -> peniko::FontData { + use std::collections::HashMap; + use std::sync::{Arc, LazyLock, Mutex}; + static CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + if let Some(hit) = CACHE.lock().ok().and_then(|c| c.get(&face.id()).cloned()) { + return hit; + } + let data = peniko::FontData::new( + peniko::Blob::new(Arc::new(face.font_bytes().clone())), + face.index, + ); + if let Ok(mut c) = CACHE.lock() { + c.insert(face.id(), data.clone()); + } + data +} + +pub fn paint_primitives_into_scene_with( + scene: &mut S, + primitives: &[PaintPrimitive], + root_transform: kurbo::Affine, + fonts: Option<&FontBook>, +) { + use base64::Engine; + use kurbo::{BezPath, Rect, RoundedRect, Stroke}; + use peniko::{Blob, Fill, ImageBrush, ImageData, ImageSampler}; + use std::sync::Arc; + use vello::Glyph; + + scene.begin(); + + // Mask buffering state: collect primitives between MaskDefStart..MaskDefEnd + // so they can be replayed as a luminance mask when MaskApplyEnd is reached. + let mut capturing_mask = false; + let mut mask_def_primitives: Vec<&PaintPrimitive> = Vec::new(); + let mut mask_bounds: Option = None; + // Inherited group transforms (`GroupStart`), outermost first; each entry + // is already composed with the ones before it. + let mut group_stack: Vec = Vec::new(); + + for primitive in primitives { + // If we're capturing mask definition primitives, buffer them instead of rendering + if capturing_mask { + match primitive { + PaintPrimitive::MaskDefEnd { .. } => { + capturing_mask = false; + } + _ => { + mask_def_primitives.push(primitive); + } + } + continue; + } + + let scene_transform = group_stack.last().copied().unwrap_or(root_transform); + match primitive { + PaintPrimitive::GroupStart { transform, .. } => { + group_stack.push(scene_transform * to_kurbo_affine(transform)); + } + PaintPrimitive::GroupEnd { .. } => { + group_stack.pop(); + } + PaintPrimitive::LayerStart { + x, + y, + width, + height, + blend, + opacity, + .. + } => { + let (x, y, width, height, opacity) = (*x, *y, *width, *height, *opacity); + let clip_rect = Rect::new(x, y, x + width, y + height); + scene.push_layer( + Fill::NonZero, + peniko::BlendMode { + mix: to_peniko_mix(blend), + compose: peniko::Compose::SrcOver, + }, + opacity as f32, + scene_transform, + &clip_rect, + ); + } + PaintPrimitive::LayerEnd { .. } => { + scene.pop_layer(); + } + PaintPrimitive::ClipStart { + x, + y, + width, + height, + corner_radii, + .. + } => { + let (x, y, width, height) = (*x, *y, *width, *height); + let rect = Rect::new(x, y, x + width, y + height); + if !corner_radii.is_zero() { + let rr = RoundedRect::from_rect( + rect, + kurbo::RoundedRectRadii::new( + corner_radii.top_left, + corner_radii.top_right, + corner_radii.bottom_right, + corner_radii.bottom_left, + ), + ); + scene.push_clip_layer(Fill::NonZero, scene_transform, &rr); + } else { + scene.push_clip_layer(Fill::NonZero, scene_transform, &rect); + } + } + PaintPrimitive::ClipEnd { .. } => { + scene.pop_layer(); + } + PaintPrimitive::MaskDefStart { + x, + y, + width, + height, + .. + } => { + capturing_mask = true; + mask_def_primitives.clear(); + mask_bounds = Some(Rect::new(*x, *y, *x + *width, *y + *height)); + } + PaintPrimitive::MaskDefEnd { .. } => { + // Handled above in the capturing_mask branch + } + PaintPrimitive::MaskApplyStart { + x, + y, + width, + height, + .. + } => { + let rect = Rect::new(*x, *y, *x + *width, *y + *height); + // Isolation layer — everything inside gets composited then masked + scene.push_layer( + Fill::NonZero, + peniko::BlendMode::default(), + 1.0, + scene_transform, + &rect, + ); + } + PaintPrimitive::MaskApplyEnd { .. } => { + // Apply the buffered mask primitives as a luminance mask + let clip = mask_bounds.unwrap_or(Rect::new(0.0, 0.0, 1e6, 1e6)); + scene.push_luminance_mask_layer(Fill::NonZero, 1.0, scene_transform, &clip); + // Replay mask definition primitives + if !mask_def_primitives.is_empty() { + render_mask_primitives(scene, &mask_def_primitives, scene_transform); + } + scene.pop_layer(); // luminance mask + scene.pop_layer(); // isolation + mask_def_primitives.clear(); + mask_bounds = None; + } + PaintPrimitive::Rect { + x, + y, + width, + height, + fills, + stroke, + corner_radii, + effects, + blend: _, + rotation, + authored, + .. + } => { + let (x, y, width, height, rotation) = (*x, *y, *width, *height, *rotation); + let corner_radii = *corner_radii; + // Authored rects with per-corner radii shadow their real + // outline (vello blurs uniform rounded rects only). + let soft_outline = (*authored && !radii_uniform(&corner_radii)).then(|| { + soft::rounded_rect_path(Rect::new(0.0, 0.0, width, height), &corner_radii) + }); + let (node_transform, lx, ly) = if rotation.abs() > 1e-6 { + let t = scene_transform + * kurbo::Affine::translate((x, y)) + * kurbo::Affine::rotate(rotation); + (t, 0.0, 0.0) + } else { + (scene_transform, x, y) + }; + let rect = Rect::new(lx, ly, lx + width, ly + height); + let uniform_corner = corner_radii.top_left; + let blur_color_hint = fills + .first() + .and_then(|(f, _)| first_color(f)) + .or_else(|| stroke.as_ref().map(|s| s.color.clone())); + let has_layer_blur = effects + .iter() + .any(|e| matches!(e.kind, EffectKind::LayerBlur)); + for effect in effects { + if matches!(effect.kind, EffectKind::LayerBlur) { + paint_blurred_rect_content( + scene, + node_transform, + lx, + ly, + width, + height, + uniform_corner, + fills.first().map(|(f, _)| f), + stroke.as_ref(), + effect, + ); + } + } + for effect in effects { + if matches!(effect.kind, EffectKind::DropShadow | EffectKind::Glow) { + if let Some(outline) = &soft_outline { + let soft = Soft::Outer { + dx: effect.offset_x, + dy: effect.offset_y, + spread: effect.spread, + }; + let at = node_transform * kurbo::Affine::translate((lx, ly)); + soft::paint(scene, at, &[outline], &effect.color, effect.radius, soft); + continue; + } + paint_effect( + scene, + node_transform, + &rect, + uniform_corner, + effect, + blur_color_hint.as_ref(), + ); + } + } + // When LayerBlur is active, the blurred rect IS the content — + // don't draw sharp fills/strokes on top. + if !has_layer_blur { + if !corner_radii.is_zero() { + let radii = kurbo::RoundedRectRadii::new( + corner_radii.top_left, + corner_radii.top_right, + corner_radii.bottom_right, + corner_radii.bottom_left, + ); + let stroke_rect = stroke_aligned_rect(rect, stroke.as_ref()); + let stroke_radius = + stroke_aligned_corner_radius(uniform_corner, stroke.as_ref()); + let rr = RoundedRect::from_rect(rect, radii); + for (fill, fill_blend) in fills { + let need_blend_layer = *fill_blend != BlendMix::Normal; + if need_blend_layer { + scene.push_layer( + Fill::NonZero, + peniko::BlendMode { + mix: to_peniko_mix(fill_blend), + compose: peniko::Compose::SrcOver, + }, + 1.0, + node_transform, + &rect, + ); + } + fill_shape(scene, node_transform, &rr, lx, ly, width, height, fill); + if need_blend_layer { + scene.pop_layer(); + } + } + if let Some(stroke) = stroke.as_ref() { + let (brush, brush_transform) = + stroke_brush(stroke, lx, ly, width, height); + if stroke.width > 0.0 + && *authored + && let Some(ring) = aligned_stroke_ring(rect, &corner_radii, stroke) + { + scene.fill( + Fill::EvenOdd, + node_transform, + &brush, + brush_transform, + &ring, + ); + } else if stroke.width > 0.0 { + let stroke_style = to_kurbo_stroke(stroke, 1.0); + let stroke_rr = RoundedRect::from_rect(stroke_rect, stroke_radius); + scene.stroke( + &stroke_style, + node_transform, + &brush, + brush_transform, + &stroke_rr, + ); + } + } + } else { + for (fill, fill_blend) in fills { + let need_blend_layer = *fill_blend != BlendMix::Normal; + if need_blend_layer { + scene.push_layer( + Fill::NonZero, + peniko::BlendMode { + mix: to_peniko_mix(fill_blend), + compose: peniko::Compose::SrcOver, + }, + 1.0, + node_transform, + &rect, + ); + } + fill_shape(scene, node_transform, &rect, lx, ly, width, height, fill); + if need_blend_layer { + scene.pop_layer(); + } + } + if let Some(stroke) = stroke.as_ref() { + let (brush, brush_transform) = + stroke_brush(stroke, lx, ly, width, height); + if stroke.width > 0.0 + && *authored + && let Some(ring) = aligned_stroke_ring(rect, &corner_radii, stroke) + { + scene.fill( + Fill::EvenOdd, + node_transform, + &brush, + brush_transform, + &ring, + ); + } else if stroke.width > 0.0 { + let stroke_style = to_kurbo_stroke(stroke, 1.0); + let stroke_rect = stroke_aligned_rect(rect, Some(stroke)); + scene.stroke( + &stroke_style, + node_transform, + &brush, + brush_transform, + &stroke_rect, + ); + } + } + } + } // !has_layer_blur + for effect in effects { + if matches!( + effect.kind, + EffectKind::InnerShadow | EffectKind::BackgroundBlur + ) { + if let (Some(outline), EffectKind::InnerShadow) = + (&soft_outline, &effect.kind) + { + let soft = Soft::Inner { + dx: effect.offset_x, + dy: effect.offset_y, + spread: effect.spread, + }; + let at = node_transform * kurbo::Affine::translate((lx, ly)); + soft::paint(scene, at, &[outline], &effect.color, effect.radius, soft); + continue; + } + paint_effect( + scene, + node_transform, + &rect, + uniform_corner, + effect, + blur_color_hint.as_ref(), + ); + } + } + } + PaintPrimitive::Path { + x: path_x, + y: path_y, + width, + height, + normalize_to_origin, + text_outline, + fill_paths, + stroke_paths, + svg_base64, + fills, + stroke, + effects, + blend: _, + rotation, + authored, + .. + } => { + let mut path_x = *path_x; + let mut path_y = *path_y; + let (width, height, rotation) = (*width, *height, *rotation); + if *authored + && !*text_outline + && (!fill_paths.is_empty() || !stroke_paths.is_empty()) + { + let base_transform = if rotation.abs() > 1e-6 { + scene_transform + * kurbo::Affine::translate((path_x, path_y)) + * kurbo::Affine::rotate(rotation) + } else { + scene_transform * kurbo::Affine::translate((path_x, path_y)) + }; + paint_authored_path( + scene, + base_transform, + width, + height, + fill_paths, + stroke_paths, + fills, + stroke.as_ref(), + effects, + ); + continue; + } + if *text_outline { + path_x += TEXT_ANCHOR_SHIFT_X; + path_y += TEXT_ANCHOR_SHIFT_Y; + } + let (base_transform, lx, ly) = if rotation.abs() > 1e-6 { + let t = scene_transform + * kurbo::Affine::translate((path_x, path_y)) + * kurbo::Affine::rotate(rotation); + (t, 0.0, 0.0) + } else { + (scene_transform, path_x, path_y) + }; + let mut had_any_path = false; + let has_native_geometry = !fill_paths.is_empty() || !stroke_paths.is_empty(); + let bounds_rect = Rect::new(lx, ly, lx + width, ly + height); + let blur_color_hint = fills + .first() + .and_then(|(f, _)| first_color(f)) + .or_else(|| stroke.as_ref().map(|s| s.color.clone())); + let path_origin_transform = base_transform * kurbo::Affine::translate((lx, ly)); + let path_fit = compute_path_fit_transform( + fill_paths, + stroke_paths, + lx, + ly, + width, + height, + *normalize_to_origin, + ); + let path_transform = path_origin_transform * path_fit; + let (sx, sy) = extract_scale(path_fit); + let stroke_scale = ((sx.abs() + sy.abs()) * 0.5).max(0.01); + let decoded_svg_text: Option> = if has_native_geometry { + None + } else { + svg_base64.as_ref().and_then(|b64| cached_svg_decode(b64)) + }; + let has_layer_blur = effects + .iter() + .any(|e| matches!(e.kind, EffectKind::LayerBlur)); + + if let (Some(svg_text), Some(layer_blur)) = ( + decoded_svg_text.as_deref(), + effects + .iter() + .find(|e| matches!(e.kind, EffectKind::LayerBlur)), + ) { + paint_blurred_svg_content( + scene, + base_transform, + svg_text, + lx, + ly, + width, + height, + layer_blur, + ); + } + for effect in effects { + if matches!(effect.kind, EffectKind::LayerBlur) { + paint_blurred_path_content( + scene, + path_transform, + fill_paths, + stroke_paths, + fills.first().map(|(f, _)| f), + stroke.as_ref(), + stroke_scale, + lx, + ly, + width, + height, + effect, + ); + } + } + for effect in effects { + // Figma imports (and snapshot-only vectors) keep box + // shadows, as parity expects; a glow is a rounded box. + // Authored vectors follow their outline (above). + let corner = match effect.kind { + EffectKind::DropShadow => 0.0, + EffectKind::Glow => width.min(height) * 0.5, + _ => continue, + }; + paint_effect( + scene, + base_transform, + &bounds_rect, + corner, + effect, + blur_color_hint.as_ref(), + ); + } + if !has_layer_blur && let Some(svg_text) = decoded_svg_text.as_deref() { + let svg_fit = cached_svg_fit_transform(svg_text, width, height); + let svg_transform = path_origin_transform * svg_fit; + if scene.draw_svg(svg_text, svg_transform) { + had_any_path = true; + } + } // !has_layer_blur for SVG + + if !had_any_path && !has_layer_blur { + for (fill_paint, fill_blend) in fills { + let need_blend_layer = *fill_blend != BlendMix::Normal; + if need_blend_layer { + scene.push_layer( + Fill::NonZero, + peniko::BlendMode { + mix: to_peniko_mix(fill_blend), + compose: peniko::Compose::SrcOver, + }, + 1.0, + base_transform, + &bounds_rect, + ); + } + for path_data in fill_paths { + if let Some(path) = cached_bez_path(path_data) { + fill_path_shape( + scene, + path_transform, + &path, + lx, + ly, + width, + height, + fill_paint, + ); + had_any_path = true; + } + } + if need_blend_layer { + scene.pop_layer(); + } + } + + if let Some(stroke) = stroke.as_ref() + && stroke.width > 0.0 + { + // Path space is node-local, as for the fills. + let (brush, brush_transform) = + stroke_brush(stroke, 0.0, 0.0, width, height); + for path_data in stroke_paths { + if let Some(path) = cached_bez_path(path_data) { + // Figma strokeGeometry is already the outlined stroke shape. + // Fill it directly to avoid double-stroking artifacts. + scene.fill( + peniko::Fill::NonZero, + path_transform, + &brush, + brush_transform, + &*path, + ); + had_any_path = true; + } + } + } + } + + if !had_any_path { + let rect = Rect::new(lx, ly, lx + width, ly + height); + for (fill, fill_blend) in fills { + let need_blend_layer = *fill_blend != BlendMix::Normal; + if need_blend_layer { + scene.push_layer( + Fill::NonZero, + peniko::BlendMode { + mix: to_peniko_mix(fill_blend), + compose: peniko::Compose::SrcOver, + }, + 1.0, + base_transform, + &rect, + ); + } + fill_shape(scene, base_transform, &rect, lx, ly, width, height, fill); + if need_blend_layer { + scene.pop_layer(); + } + } + if let Some(stroke) = stroke.as_ref() + && stroke.width > 0.0 + { + let stroke_style = to_kurbo_stroke(stroke, 1.0); + let (brush, brush_transform) = stroke_brush(stroke, lx, ly, width, height); + scene.stroke( + &stroke_style, + base_transform, + &brush, + brush_transform, + &rect, + ); + } + } + for effect in effects { + if matches!( + effect.kind, + EffectKind::InnerShadow | EffectKind::BackgroundBlur + ) { + paint_effect( + scene, + base_transform, + &bounds_rect, + 0.0, + effect, + blur_color_hint.as_ref(), + ); + } + } + } + PaintPrimitive::Text { + x, + y, + text, + color, + font_size, + line_height, + letter_spacing, + text_case, + font, + style_runs, + blend: _, + width: box_width, + align, + effects, + .. + } => { + let (x, y, font_size, letter_spacing) = ( + *x + TEXT_ANCHOR_SHIFT_X, + *y + TEXT_ANCHOR_SHIFT_Y, + *font_size, + *letter_spacing, + ); + let text = apply_text_case(text, text_case); + let line_height = line_height.unwrap_or(font_size * 1.2).max(1.0); + let max_line_chars = text.lines().map(|l| l.chars().count()).max().unwrap_or(0); + let approx_width = + (max_line_chars as f64 * (font_size * 0.6 + letter_spacing)).max(font_size); + let line_count = text.lines().count().max(1) as f64; + let _bounds_rect = Rect::new(x, y, x + approx_width, y + line_count * line_height); + if let Some(book) = fonts { + let runs = if style_runs.is_empty() { + vec![TextStyleRun { + text: text.clone(), + color: Some(color.clone()), + font_size: Some(font_size), + letter_spacing: Some(letter_spacing), + font_style: None, + font_weight: None, + font_family: None, + font_width: None, + }] + } else { + style_runs + .iter() + .cloned() + .map(|mut run| { + run.text = apply_text_case(&run.text, text_case); + run + }) + .collect::>() + }; + // Each run's faces: its own settings over the node's. + let chains: Vec = runs + .iter() + .map(|run| book.chain(&run.font().over(font).query())) + .collect(); + + // Measure each line first so it can be aligned in its box. + let mut line_widths: Vec = vec![0.0]; + for (run, chain) in runs.iter().zip(&chains) { + let size = run.font_size.unwrap_or(font_size).max(1.0); + let spacing = run.letter_spacing.unwrap_or(letter_spacing); + for ch in run.text.chars() { + if ch == '\n' { + line_widths.push(0.0); + continue; + } + let advance = chain.pick(ch).map_or(0.0, |(f, g)| f.advance(g, size)); + if let Some(w) = line_widths.last_mut() { + *w += advance + spacing; + } + } + } + let line_start = |line: usize| -> f64 { + let w = line_widths.get(line).copied().unwrap_or(0.0); + match align { + TextAlignment::Left => 0.0, + TextAlignment::Center => ((*box_width - w) * 0.5).max(0.0), + TextAlignment::Right => (*box_width - w).max(0.0), + } + }; + let mut line_idx: usize = 0; + let mut pen_x: f64 = line_start(0); + let mut placed: Vec> = Vec::new(); + for (run, chain) in runs.iter().zip(&chains) { + let run_size = run.font_size.unwrap_or(font_size).max(1.0); + let run_letter_spacing = + run.letter_spacing.unwrap_or(letter_spacing) as f32; + let run_color = run.color.clone().unwrap_or_else(|| color.clone()); + let ascent = chain + .primary() + .map_or(run_size * 0.8, |f| f.ascent(run_size)); + for ch in run.text.chars() { + if ch == '\n' { + line_idx += 1; + pen_x = line_start(line_idx); + continue; + } + let Some((face, gid)) = chain.pick(ch) else { + continue; + }; + let advance = face.advance(gid, run_size); + let local = kurbo::Affine::translate(( + x + pen_x, + y + ascent + (line_idx as f64 * line_height), + )); + placed.push(Placed { + face, + gid, + size: run_size, + color: run_color.clone(), + local, + }); + pen_x += advance + f64::from(run_letter_spacing); + } + } + // Shadows and glows follow the glyph outlines. + let outline = (!effects.is_empty()).then(|| glyph_outlines(&placed)); + for effect in effects { + if let (Some(o), EffectKind::DropShadow | EffectKind::Glow) = + (&outline, &effect.kind) + { + let soft = Soft::Outer { + dx: effect.offset_x, + dy: effect.offset_y, + spread: effect.spread, + }; + soft::paint( + scene, + scene_transform, + &[o], + &effect.color, + effect.radius, + soft, + ); + } + } + for g in &placed { + let face = g.face; + let grow = face.embolden(g.size); + let dx = grow as f32; + let font = font_data(&face.face); + let brush = to_peniko(g.color.clone()); + scene.draw_glyph_run(&GlyphRun { + font: &font, + font_size: g.size as f32, + normalized_coords: &face.coords, + brush: (&brush).into(), + brush_alpha: 1.0, + transform: scene_transform * g.local, + // Glyph space is y-up: a synthetic italic leans the + // tops right. + glyph_transform: face.synthetic_italic.then(|| { + kurbo::Affine::new([ + 1.0, + 0.0, + crate::render::font_book::SYNTHETIC_SKEW, + 1.0, + 0.0, + 0.0, + ]) + }), + embolden: if grow > 0.0 { + kurbo::Vec2::new(grow, grow * 0.5) + } else { + kurbo::Vec2::ZERO + }, + glyphs: &[Glyph { + id: g.gid.to_u32(), + x: dx, + y: 0.0, + }], + }); + } + for effect in effects { + if let (Some(o), EffectKind::InnerShadow) = (&outline, &effect.kind) { + let soft = Soft::Inner { + dx: effect.offset_x, + dy: effect.offset_y, + spread: effect.spread, + }; + soft::paint( + scene, + scene_transform, + &[o], + &effect.color, + effect.radius, + soft, + ); + } + } + } else { + // fallback to a baseline block when no text font is configured + for (line_idx, line_text) in text.lines().enumerate() { + let width = (line_text.chars().count() as f64 + * (font_size * 0.5 + letter_spacing)) + .max(font_size); + let y_line = y + (line_idx as f64 * line_height); + let line = Rect::new( + x, + y_line + font_size * 0.85, + x + width, + y_line + font_size * 0.95, + ); + scene.fill( + Fill::NonZero, + scene_transform, + to_peniko(color.clone()), + None, + &line, + ); + } + } + } + } + } + + /// A glyph placed in text space: which face draws it, where its pen + /// starts on the baseline. + struct Placed<'c> { + face: &'c crate::render::font_book::Resolved, + gid: skrifa::GlyphId, + size: f64, + color: Rgba, + local: kurbo::Affine, + } + + /// The placed glyphs' outlines in text space, as drawn (instanced, + /// slanted, emboldened). + fn glyph_outlines(placed: &[Placed<'_>]) -> BezPath { + let mut out = BezPath::new(); + for g in placed { + if let Some(p) = g.face.outline(g.gid, g.size) { + out.extend((g.local * p).elements().iter().copied()); + } + } + out + } + + fn radii_uniform(r: &crate::document::proto::CornerRadii) -> bool { + r.top_left == r.top_right && r.top_left == r.bottom_right && r.top_left == r.bottom_left + } + + /// An inside or outside stroke of a (rounded) rect as the exact ring + /// between its two edges, filled even-odd. Stroking the inset/outset + /// outline instead lets vello's GPU stroker put an edge a hair above a + /// tile row, which fills that whole 16 px row with the stroke colour. + /// `None` for centred and dashed strokes (stroked as usual). + fn aligned_stroke_ring( + rect: Rect, + radii: &crate::document::proto::CornerRadii, + stroke: &StrokeStyle, + ) -> Option { + use kurbo::Shape; + if !stroke.dash_pattern.is_empty() { + return None; + } + let w = stroke.width.max(0.0); + let rounded = |r: Rect, grow: f64| { + // Growing a square corner keeps it square unless the join + // rounds it; shrinking a corner stops at square. + let corner = |c: f64| { + if c <= 0.0 && grow > 0.0 && !matches!(stroke.join, StrokeJoin::Round) { + 0.0 + } else { + (c + grow).max(0.0) + } + }; + RoundedRect::from_rect( + r, + kurbo::RoundedRectRadii::new( + corner(radii.top_left), + corner(radii.top_right), + corner(radii.bottom_right), + corner(radii.bottom_left), + ), + ) + .to_path(0.01) + }; + let (mut ring, inner, grow) = match stroke.align { + StrokeAlign::Center => return None, + StrokeAlign::Inside => (rounded(rect, 0.0), rect.inset(-w), -w), + StrokeAlign::Outside => (rounded(rect.inset(w), w), rect, 0.0), + }; + if inner.width() > 0.0 && inner.height() > 0.0 { + ring.extend(rounded(inner, grow)); + } + Some(ring) + } + + /// A vector authored in frame: geometry drawn as written, in node-local + /// coordinates (`transform` maps them to the target); a stroke without + /// stroke geometry strokes the fill geometry (open subpaths as lines, + /// never filled); effects follow the outline. + #[allow(clippy::too_many_arguments)] + fn paint_authored_path( + scene: &mut S, + transform: kurbo::Affine, + width: f64, + height: f64, + fill_paths: &[String], + stroke_paths: &[String], + fills: &[(FillPaint, BlendMix)], + stroke: Option<&StrokeStyle>, + effects: &[NodeEffect], + ) { + use kurbo::Shape; + let stroke = stroke.filter(|s| s.width > 0.0); + let fill_geo: Vec> = fill_paths + .iter() + .filter_map(|d| cached_bez_path(d)) + .collect(); + let stroke_geo: Vec> = stroke_paths + .iter() + .filter_map(|d| cached_bez_path(d)) + .collect(); + // Stroke geometry is the stroke's own filled outline (as Figma + // exports it); without it, a stroke strokes the fill geometry. + let strokes_fill = stroke.is_some() && stroke_geo.is_empty(); + let filled: Vec = fill_geo + .iter() + .map(|p| { + if strokes_fill { + split_open_closed(p).0 + } else { + (**p).clone() + } + }) + .filter(|p| !p.elements().is_empty()) + .collect(); + let stroke_area: Vec = match stroke { + _ if !stroke_geo.is_empty() => stroke_geo.iter().map(|p| (**p).clone()).collect(), + Some(s) => { + let style = to_kurbo_stroke(s, 1.0); + fill_geo + .iter() + .map(|p| { + kurbo::stroke( + p.elements().iter().copied(), + &style, + &kurbo::StrokeOpts::default(), + 0.05, + ) + }) + .collect() + } + None => Vec::new(), + }; + let fill_refs: Vec<&BezPath> = if fills.is_empty() { + Vec::new() + } else { + filled.iter().collect() + }; + let outline: Vec<&BezPath> = fill_refs + .iter() + .copied() + .chain(stroke_area.iter()) + .collect(); + let bounds = outline + .iter() + .map(|p| p.bounding_box()) + .reduce(|a, b| a.union(b)) + .unwrap_or(Rect::new(0.0, 0.0, width, height)) + .inflate(1.0, 1.0); + + let layer_blur = effects + .iter() + .find(|e| matches!(e.kind, EffectKind::LayerBlur)); + if let Some(blur) = layer_blur { + let still = Soft::Outer { + dx: 0.0, + dy: 0.0, + spread: 0.0, + }; + for (fill, _) in fills { + match fill { + FillPaint::Solid(c) => { + soft::paint(scene, transform, &fill_refs, c, blur.radius, still) + } + _ => { + let owned: Vec = filled.iter().map(BezPath::to_svg).collect(); + paint_blurred_path_content( + scene, + transform, + &owned, + &[], + Some(fill), + None, + 1.0, + 0.0, + 0.0, + width, + height, + blur, + ); + } + } + } + if let Some(s) = stroke { + let refs: Vec<&BezPath> = stroke_area.iter().collect(); + soft::paint(scene, transform, &refs, &s.color, blur.radius, still); + } + } + for effect in effects { + if matches!(effect.kind, EffectKind::DropShadow | EffectKind::Glow) { + let soft = Soft::Outer { + dx: effect.offset_x, + dy: effect.offset_y, + spread: effect.spread, + }; + soft::paint( + scene, + transform, + &outline, + &effect.color, + effect.radius, + soft, + ); + } + } + if layer_blur.is_none() { + for (fill, fill_blend) in fills { + let blended = *fill_blend != BlendMix::Normal; + if blended { + scene.push_layer( + Fill::NonZero, + peniko::BlendMode { + mix: to_peniko_mix(fill_blend), + compose: peniko::Compose::SrcOver, + }, + 1.0, + transform, + &bounds, + ); + } + for path in &filled { + fill_path_shape(scene, transform, path, 0.0, 0.0, width, height, fill); + } + if blended { + scene.pop_layer(); + } + } + if let Some(s) = stroke { + let brush = stroke_brush(s, 0.0, 0.0, width, height); + if strokes_fill { + for path in &fill_geo { + stroke_aligned_path(scene, transform, path, s, &brush); + } + } else { + for path in &stroke_geo { + scene.fill(Fill::NonZero, transform, &brush.0, brush.1, &**path); + } + } + } + } + for effect in effects { + match effect.kind { + EffectKind::InnerShadow => { + let inside: Vec<&BezPath> = if fill_refs.is_empty() { + stroke_area.iter().collect() + } else { + fill_refs.clone() + }; + let soft = Soft::Inner { + dx: effect.offset_x, + dy: effect.offset_y, + spread: effect.spread, + }; + soft::paint( + scene, + transform, + &inside, + &effect.color, + effect.radius, + soft, + ); + } + EffectKind::BackgroundBlur => { + let hint = fills + .first() + .and_then(|(f, _)| first_color(f)) + .or_else(|| stroke.map(|s| s.color.clone())); + paint_effect( + scene, + transform, + &Rect::new(0.0, 0.0, width, height), + 0.0, + effect, + hint.as_ref(), + ); + } + _ => {} + } + } + } + + /// Stroke `path` with `s` in `brush` (see [`stroke_brush`]), honouring + /// its alignment on closed subpaths (open ones are always centred). + fn stroke_aligned_path( + scene: &mut S, + transform: kurbo::Affine, + path: &BezPath, + s: &StrokeStyle, + brush: &(peniko::Brush, Option), + ) { + use kurbo::Shape; + let (colour, bt) = (&brush.0, brush.1); + if matches!(s.align, StrokeAlign::Center) { + scene.stroke(&to_kurbo_stroke(s, 1.0), transform, colour, bt, path); + return; + } + let (closed, open) = split_open_closed(path); + if !closed.elements().is_empty() { + // Twice the width, centred on the edge, then cut to one side. + let double = to_kurbo_stroke( + &StrokeStyle { + width: s.width * 2.0, + ..s.clone() + }, + 1.0, + ); + if matches!(s.align, StrokeAlign::Inside) { + scene.push_clip_layer(Fill::NonZero, transform, &closed); + scene.stroke(&double, transform, colour, bt, &closed); + scene.pop_layer(); + } else { + let reach = closed + .bounding_box() + .inflate(s.width * 2.0 + 1.0, s.width * 2.0 + 1.0); + scene.push_layer(Fill::NonZero, peniko::Mix::Normal, 1.0, transform, &reach); + scene.stroke(&double, transform, colour, bt, &closed); + scene.push_layer( + Fill::NonZero, + peniko::Compose::DestOut, + 1.0, + transform, + &closed, + ); + scene.fill( + Fill::NonZero, + transform, + peniko::Color::BLACK, + None, + &closed, + ); + scene.pop_layer(); + scene.pop_layer(); + } + } + if !open.elements().is_empty() { + scene.stroke(&to_kurbo_stroke(s, 1.0), transform, colour, bt, &open); + } + } + + /// A stroke's brush: its gradient laid across the node's box + /// `(x, y, w, h)` exactly as a fill's (with the brush transform a radial + /// gradient needs), or its colour. + fn stroke_brush( + s: &StrokeStyle, + x: f64, + y: f64, + w: f64, + h: f64, + ) -> (peniko::Brush, Option) { + let (x, y, w, h) = super::types::stroke_gradient_box(s, x, y, w, h); + let gradient = match &s.gradient { + Some(FillPaint::GradientLinear { transform, stops }) => { + build_linear_gradient(x, y, w, h, transform.as_ref(), stops).map(|g| (g, None)) + } + Some(FillPaint::GradientRadial { transform, stops }) => { + build_radial_gradient(x, y, w, h, transform.as_ref(), stops) + } + Some(FillPaint::GradientAngular { transform, stops }) => { + build_angular_gradient(x, y, w, h, transform.as_ref(), stops).map(|g| (g, None)) + } + _ => None, + }; + match gradient { + Some((g, t)) => (peniko::Brush::Gradient(g), t), + None => (peniko::Brush::Solid(to_peniko(s.color.clone())), None), + } + } + + fn fill_shape( + scene: &mut S, + scene_transform: kurbo::Affine, + shape: &impl kurbo::Shape, + x: f64, + y: f64, + width: f64, + height: f64, + fill: &FillPaint, + ) { + use peniko::Fill; + + match fill { + FillPaint::Solid(color) => { + scene.fill( + Fill::NonZero, + scene_transform, + to_peniko(color.clone()), + None, + shape, + ); + } + FillPaint::GradientLinear { transform, stops } => { + if let Some(gradient) = + build_linear_gradient(x, y, width, height, transform.as_ref(), stops) + { + scene.fill(Fill::NonZero, scene_transform, &gradient, None, shape); + } + } + FillPaint::GradientRadial { transform, stops } => { + if let Some((gradient, brush_xform)) = + build_radial_gradient(x, y, width, height, transform.as_ref(), stops) + { + scene.fill( + Fill::NonZero, + scene_transform, + &gradient, + brush_xform, + shape, + ); + } + } + FillPaint::GradientAngular { transform, stops } => { + if let Some(gradient) = + build_angular_gradient(x, y, width, height, transform.as_ref(), stops) + { + scene.fill(Fill::NonZero, scene_transform, &gradient, None, shape); + } + } + FillPaint::Image { + image_hash: _, + data_base64, + alpha, + scale_mode, + image_transform, + } => { + if let Some(image_brush) = + decode_image_brush(data_base64, *alpha, *scale_mode, width, height) + { + let iw = image_brush.image.width as f64; + let ih = image_brush.image.height as f64; + if iw > 0.0 && ih > 0.0 { + let brush_transform = image_brush_transform( + *scale_mode, + image_transform, + x, + y, + width, + height, + iw, + ih, + ); + scene.fill( + Fill::NonZero, + scene_transform, + image_brush.as_ref(), + Some(brush_transform), + shape, + ); + } + } + } + } + } + + fn paint_effect( + scene: &mut S, + scene_transform: kurbo::Affine, + base_rect: &Rect, + corner_radius: f64, + effect: &NodeEffect, + blur_color_hint: Option<&Rgba>, + ) { + match effect.kind { + EffectKind::DropShadow | EffectKind::Glow => { + // Spread expands the shadow rect outward in all directions + let shadow_rect = Rect::new( + base_rect.x0 + effect.offset_x - effect.spread, + base_rect.y0 + effect.offset_y - effect.spread, + base_rect.x1 + effect.offset_x + effect.spread, + base_rect.y1 + effect.offset_y + effect.spread, + ); + // Corner radius grows additively with spread + let shadow_corner = (corner_radius + effect.spread).max(0.0); + // Figma radius is CSS blur radius (≈ 2×sigma); draw_box_shadow expects sigma + scene.draw_blurred_rounded_rect( + scene_transform, + shadow_rect, + to_peniko(effect.color.clone()), + shadow_corner, + (effect.radius * 0.5).max(0.0), + ); + } + EffectKind::InnerShadow => { + paint_inner_shadow(scene, scene_transform, base_rect, corner_radius, effect) + } + EffectKind::LayerBlur => { + // Single GPU-native Gaussian blur of the bounding rect. + let sigma = (effect.radius * 0.5).max(0.5); + let hint = blur_color_hint.cloned().unwrap_or(Rgba { + r: 0.75, + g: 0.78, + b: 0.82, + a: 1.0, + }); + scene.draw_blurred_rounded_rect( + scene_transform, + *base_rect, + to_peniko(hint), + corner_radius.max(effect.spread), + sigma, + ); + } + EffectKind::BackgroundBlur => { + // Single blurred rect approximation for backdrop blur. + // True background blur requires backdrop sampling which vello doesn't support. + let sigma = (effect.radius * 0.5).max(0.5); + let hint_color = blur_color_hint.cloned().unwrap_or(Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 0.3, + }); + scene.draw_blurred_rounded_rect( + scene_transform, + *base_rect, + to_peniko(hint_color), + corner_radius.max(effect.spread), + sigma, + ); + } + } + } + + /// An inner shadow of `base` (a rect rounded by `corner`): the outline + /// filled with the shadow colour, minus a blurred hole at the offset + /// (a DestOut layer), in an isolating layer. (Pattern from the Blitz + /// reference implementation.) + /// + /// Over a large node the layers are bounded to the band along the edge + /// the shadow can reach ([`inner_shadow_cut`]): deeper inside, the hole + /// covers everything and whole-outline layers would only cost blend-stack + /// depth, per-tile commands and tile allocations over the node's full + /// area — enough, with a few large faces on a page, to overflow vello's + /// fixed buffers and blank the page. Under an axis-aligned transform the + /// band is drawn as up to four strips, each its own clip, so every draw's + /// tiles stay within its strip; otherwise as one even-odd band clip. + fn paint_inner_shadow( + scene: &mut S, + transform: kurbo::Affine, + base: &Rect, + corner: f64, + effect: &NodeEffect, + ) { + let rr = RoundedRect::from_rect(*base, corner); + let whole = || (Fill::NonZero, kurbo::Shape::to_path(&rr, 0.1)); + let device_area = base.area() * transform.determinant().abs(); + let cut = if device_area >= BAND_MIN_DEVICE_AREA { + inner_shadow_cut(base, corner, effect) + } else { + None + }; + let Some(cut) = cut else { + let (fill, clip) = whole(); + inner_shadow_layers(scene, transform, &rr, (fill, &clip), effect); + return; + }; + if let Some(strips) = band_strips(transform, base, &cut) { + let (fill, clip) = whole(); + for strip in strips { + scene.push_clip_layer(Fill::NonZero, transform, &strip); + inner_shadow_layers(scene, transform, &rr, (fill, &clip), effect); + scene.pop_layer(); + } + return; + } + let mut band = kurbo::Shape::to_path(&rr, 0.1); + band.extend(kurbo::Shape::to_path(&cut, 0.1)); + inner_shadow_layers(scene, transform, &rr, (Fill::EvenOdd, &band), effect); + } + + /// The inner shadow's layers, the outer one clipped to `clip`. + fn inner_shadow_layers( + scene: &mut S, + transform: kurbo::Affine, + rr: &RoundedRect, + clip: (Fill, &BezPath), + effect: &NodeEffect, + ) { + // Layer 1: isolate inner shadow compositing group + scene.push_layer(clip.0, peniko::Mix::Normal, 1.0, transform, clip.1); + // Fill entire clipped area with shadow color + scene.fill( + Fill::NonZero, + transform, + to_peniko(effect.color.clone()), + None, + rr, + ); + // Layer 2: DestOut — punch a blurred hole at offset position. + // (Clipped to the whole outline: where the outer clip is partial the + // hole is complete, and clipping both would leave a faint seam.) + scene.push_layer(Fill::NonZero, peniko::Compose::DestOut, 1.0, transform, rr); + // Spread shrinks the hole (opposite of drop shadow) + let hole_corner = (rr.radii().top_left - effect.spread).max(0.0); + // Figma radius is CSS blur radius (≈ 2×sigma) + scene.draw_blurred_rounded_rect( + transform, + inner_shadow_hole(&rr.rect(), effect), + to_peniko(Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }), + hole_corner, + (effect.radius * 0.5).max(0.0), + ); + scene.pop_layer(); // DestOut + scene.pop_layer(); // isolation + } + + /// The inner shadow's hole: `base` offset, shrunk by the spread. + fn inner_shadow_hole(base: &Rect, effect: &NodeEffect) -> Rect { + Rect::new( + base.x0 + effect.offset_x + effect.spread, + base.y0 + effect.offset_y + effect.spread, + base.x1 + effect.offset_x - effect.spread, + base.y1 + effect.offset_y - effect.spread, + ) + } + + /// Nodes smaller than this (device px², 1024 of vello's 16 px tiles) + /// keep whole-outline layers: they cost little, and bounding them would + /// only move where vello rounds the backdrop to 8 bits. + const BAND_MIN_DEVICE_AREA: f64 = 512.0 * 512.0; + + /// The region inside `base` that an inner shadow's blurred hole covers + /// completely — nothing of the shadow shows there. `None` when that + /// region is small (a small node, a wide blur or a large offset). + /// + /// It keeps a margin of 4σ past the hole's edge (its rounded corners + /// included), where the blur's tail is under a thousandth — below one + /// 8-bit step at any shadow opacity — so the shadow comes out as over the + /// whole node. (What changes is only where vello rounds the backdrop to + /// 8 bits: inside the cut it no longer passes through the layer's blend + /// stack, so content drawn over it later can land one step apart.) + fn inner_shadow_cut(base: &Rect, corner: f64, effect: &NodeEffect) -> Option { + let sigma = (effect.radius * 0.5).max(0.0); + let hole_corner = (corner - effect.spread).max(0.0); + let margin = 4.0 * sigma + hole_corner + 1.0; + let cut = inner_shadow_hole(base, effect) + .inset(-margin) + .intersect(*base); + // Not worth it unless it saves most of the node. + (cut.width() > 0.0 && cut.height() > 0.0 && cut.area() >= base.area() * 0.25).then_some(cut) + } + + /// `base` minus `cut` as up to four rectangles (top and bottom full + /// width, left and right between them), when `transform` is + /// axis-aligned: their edges are moved to whole device tiles (16 px), + /// the cut only shrinking, so neighbouring strips meet on pixel + /// boundaries and no pixel is split between two of them. The outer + /// edges lie a tile outside `base`, so the node's own outline is + /// antialiased as without them. `None` under rotation or skew. + fn band_strips(transform: kurbo::Affine, base: &Rect, cut: &Rect) -> Option> { + const TILE: f64 = 16.0; + let [a, b, c, d, _, _] = transform.as_coeffs(); + if b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-9 || d.abs() < 1e-9 { + return None; + } + let device = |r: &Rect| transform.transform_rect_bbox(*r); + let (outer, inner) = (device(base), device(cut)); + let outer = Rect::new( + (outer.x0 / TILE).floor() * TILE - TILE, + (outer.y0 / TILE).floor() * TILE - TILE, + (outer.x1 / TILE).ceil() * TILE + TILE, + (outer.y1 / TILE).ceil() * TILE + TILE, + ); + let inner = Rect::new( + (inner.x0 / TILE).ceil() * TILE, + (inner.y0 / TILE).ceil() * TILE, + (inner.x1 / TILE).floor() * TILE, + (inner.y1 / TILE).floor() * TILE, + ); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return None; + } + let back = transform.inverse(); + let strips = [ + Rect::new(outer.x0, outer.y0, outer.x1, inner.y0), + Rect::new(outer.x0, inner.y1, outer.x1, outer.y1), + Rect::new(outer.x0, inner.y0, inner.x0, inner.y1), + Rect::new(inner.x1, inner.y0, outer.x1, inner.y1), + ]; + Some( + strips + .iter() + .map(|s| back.transform_rect_bbox(*s)) + .collect(), + ) + } + + fn paint_blurred_rect_content( + scene: &mut S, + scene_transform: kurbo::Affine, + x: f64, + y: f64, + width: f64, + height: f64, + corner_radius: f64, + fill: Option<&FillPaint>, + stroke: Option<&StrokeStyle>, + effect: &NodeEffect, + ) { + let sigma = (effect.radius * 0.5).max(0.5); + let rect = Rect::new(x, y, x + width, y + height); + + // For solid fills, use vello's GPU-native Gaussian blur on the rectangle. + // This produces a proper blur rather than shifted semi-transparent copies. + if let Some(fill) = fill { + match fill { + FillPaint::Solid(color) => { + scene.draw_blurred_rounded_rect( + scene_transform, + rect, + to_peniko(color.clone()), + corner_radius, + sigma, + ); + } + // For gradient/image fills, fall back to shifted copies + _ => { + for (dx, dy, weight) in layer_blur_kernel(effect.radius.max(0.5)) { + let shifted_transform = + scene_transform * kurbo::Affine::translate((dx, dy)); + if corner_radius > 0.0 { + let rr = RoundedRect::from_rect(rect, corner_radius); + let f = fill.clone().with_opacity(weight); + fill_shape(scene, shifted_transform, &rr, x, y, width, height, &f); + } else { + let f = fill.clone().with_opacity(weight); + fill_shape(scene, shifted_transform, &rect, x, y, width, height, &f); + } + } + } + } + } + // Stroke blur: single blurred rect with stroke color + if let Some(stroke) = stroke + && stroke.width > 0.0 + { + let stroke_rect = stroke_aligned_rect(rect, Some(stroke)); + let stroke_radius = stroke_aligned_corner_radius(corner_radius, Some(stroke)); + scene.draw_blurred_rounded_rect( + scene_transform, + stroke_rect, + to_peniko(stroke.color.clone()), + stroke_radius, + sigma, + ); + } + } + + fn paint_blurred_path_content( + scene: &mut S, + path_transform: kurbo::Affine, + fill_paths: &[String], + stroke_paths: &[String], + fill: Option<&FillPaint>, + stroke: Option<&StrokeStyle>, + stroke_scale: f64, + x: f64, + y: f64, + width: f64, + height: f64, + effect: &NodeEffect, + ) { + let radius = effect.radius.max(0.5); + for (dx, dy, weight) in layer_blur_kernel(radius) { + let shifted = kurbo::Affine::translate((dx, dy)) * path_transform; + if let Some(fill_paint) = fill.cloned().map(|f| f.with_opacity(weight)) { + for path_data in fill_paths { + if let Some(path) = cached_bez_path(path_data) { + fill_path_shape(scene, shifted, &path, x, y, width, height, &fill_paint); + } + } + } + if let Some(stroke) = stroke + && stroke.width > 0.0 + { + let mut stroke_blur = stroke.clone(); + stroke_blur.color.a = (stroke_blur.color.a * weight).clamp(0.0, 1.0); + let stroke_style = to_kurbo_stroke(&stroke_blur, stroke_scale); + for path_data in stroke_paths { + if let Some(path) = cached_bez_path(path_data) { + scene.stroke( + &stroke_style, + shifted, + to_peniko(stroke_blur.color.clone()), + None, + &*path, + ); + } + } + } + } + } + + fn paint_blurred_svg_content( + scene: &mut S, + scene_transform: kurbo::Affine, + svg_text: &str, + x: f64, + y: f64, + width: f64, + height: f64, + effect: &NodeEffect, + ) { + let radius = effect.radius.max(0.5); + let clip_rect = Rect::new(x, y, x + width, y + height); + let svg_fit = cached_svg_fit_transform(svg_text, width, height); + // Cache the parsed SVG tree once — blur kernel loops would otherwise + // re-parse the XML 6-20 times per blurred element per frame. + // Parsed once (each target caches its tree): the blur kernel loops + // would otherwise re-parse the XML 6-20 times per blurred element. + for (dx, dy, weight) in layer_blur_kernel(radius) { + let svg_transform = + scene_transform * kurbo::Affine::translate((x + dx, y + dy)) * svg_fit; + scene.push_layer( + Fill::NonZero, + peniko::BlendMode { + mix: peniko::Mix::Normal, + compose: peniko::Compose::SrcOver, + }, + weight as f32, + scene_transform, + &clip_rect, + ); + scene.draw_svg(svg_text, svg_transform); + scene.pop_layer(); + } + } + + fn fill_path_shape( + scene: &mut S, + path_transform: kurbo::Affine, + path: &BezPath, + _x: f64, + _y: f64, + width: f64, + height: f64, + fill: &FillPaint, + ) { + use peniko::Fill; + + // Gradient coordinates must be in the same space as the path shape. + // fillGeometry paths are in local coords (0,0 origin); path_transform + // maps them to screen space. Build gradients at (0,0) so they align. + match fill { + FillPaint::Solid(color) => { + scene.fill( + Fill::NonZero, + path_transform, + to_peniko(color.clone()), + None, + path, + ); + } + FillPaint::GradientLinear { transform, stops } => { + if let Some(gradient) = + build_linear_gradient(0.0, 0.0, width, height, transform.as_ref(), stops) + { + scene.fill(Fill::NonZero, path_transform, &gradient, None, path); + } + } + FillPaint::GradientRadial { transform, stops } => { + if let Some((gradient, brush_xform)) = + build_radial_gradient(0.0, 0.0, width, height, transform.as_ref(), stops) + { + scene.fill(Fill::NonZero, path_transform, &gradient, brush_xform, path); + } + } + FillPaint::GradientAngular { transform, stops } => { + if let Some(gradient) = + build_angular_gradient(0.0, 0.0, width, height, transform.as_ref(), stops) + { + scene.fill(Fill::NonZero, path_transform, &gradient, None, path); + } + } + FillPaint::Image { + image_hash: _, + data_base64, + alpha, + scale_mode, + image_transform, + } => { + if let Some(image_brush) = + decode_image_brush(data_base64, *alpha, *scale_mode, width, height) + { + let iw = image_brush.image.width as f64; + let ih = image_brush.image.height as f64; + if iw > 0.0 && ih > 0.0 { + // Use local-space origin (0, 0) — same as gradient fix above + let brush_transform = image_brush_transform( + *scale_mode, + image_transform, + 0.0, + 0.0, + width, + height, + iw, + ih, + ); + scene.fill( + Fill::NonZero, + path_transform, + image_brush.as_ref(), + Some(brush_transform), + path, + ); + } + } + } + } + } + + fn image_brush_transform( + scale_mode: ImageScaleMode, + image_transform: &Option, + x: f64, + y: f64, + width: f64, + height: f64, + iw: f64, + ih: f64, + ) -> kurbo::Affine { + match scale_mode { + ImageScaleMode::Fill => { + // Scale uniformly to cover the entire node, center the image + let scale = if width > 0.0 && height > 0.0 { + (width / iw).max(height / ih) + } else { + 1.0 + }; + let offset_x = x + (width - iw * scale) * 0.5; + let offset_y = y + (height - ih * scale) * 0.5; + kurbo::Affine::translate((offset_x, offset_y)) * kurbo::Affine::scale(scale) + } + ImageScaleMode::Fit => { + // Scale uniformly to fit inside the node, center the image + let scale = if width > 0.0 && height > 0.0 { + (width / iw).min(height / ih) + } else { + 1.0 + }; + let offset_x = x + (width - iw * scale) * 0.5; + let offset_y = y + (height - ih * scale) * 0.5; + kurbo::Affine::translate((offset_x, offset_y)) * kurbo::Affine::scale(scale) + } + ImageScaleMode::Crop => { + // Figma imageTransform maps node-normalized [0,1]×[0,1] to + // image-normalized [0,1]×[0,1]. The brush transform maps image + // pixels → scene pixels, so we need the INVERSE of imageTransform: + // translate(node_origin) * scale(node_size) * inv(T) * scale(1/img_size) + if let Some(t) = image_transform { + let det = t.determinant(); + if det.abs() < 1e-12 { + let scale = (width / iw).max(height / ih); + let ox = x + (width - iw * scale) * 0.5; + let oy = y + (height - ih * scale) * 0.5; + return kurbo::Affine::translate((ox, oy)) * kurbo::Affine::scale(scale); + } + // Invert the 2x3 affine: node-norm → image-norm becomes + // image-norm → node-norm + let inv = kurbo::Affine::new([ + t.d / det, + -t.c / det, + -t.b / det, + t.a / det, + (t.b * t.ty - t.d * t.tx) / det, + (t.c * t.tx - t.a * t.ty) / det, + ]); + kurbo::Affine::translate((x, y)) + * kurbo::Affine::scale_non_uniform(width, height) + * inv + * kurbo::Affine::scale_non_uniform(1.0 / iw, 1.0 / ih) + } else { + // No transform — fall back to Fill + let scale = (width / iw).max(height / ih); + let ox = x + (width - iw * scale) * 0.5; + let oy = y + (height - ih * scale) * 0.5; + kurbo::Affine::translate((ox, oy)) * kurbo::Affine::scale(scale) + } + } + ImageScaleMode::Tile => { + // One tile placed by the image transform (image px → the + // node's box, from its top-left; identity = the image at + // its own size), repeated by the brush's `Extend::Repeat`. + kurbo::Affine::translate((x, y)) + * image_transform + .as_ref() + .map_or(kurbo::Affine::IDENTITY, to_kurbo_affine) + } + ImageScaleMode::Stretch => { + // Non-uniform stretch to fill node exactly + let sx = if width > 0.0 { width / iw } else { 1.0 }; + let sy = if height > 0.0 { height / ih } else { 1.0 }; + kurbo::Affine::translate((x, y)) * kurbo::Affine::scale_non_uniform(sx, sy) + } + } + } + + fn layer_blur_kernel(radius: f64) -> Vec<(f64, f64, f64)> { + let sigma = (radius * 0.5).max(0.5); + // Cap the integer grid to at most 7x7 = 49 samples to avoid + // overwhelming the GPU with draw calls per blurred element. + let half_grid: i32 = 3; // grid goes -3..+3 → 7 taps per axis + // Step size spreads the grid to cover 2.5σ spatially + let step = (sigma * 2.5) / half_grid as f64; + let mut kernel = Vec::new(); + let mut total_weight = 0.0; + + for iy in -half_grid..=half_grid { + for ix in -half_grid..=half_grid { + let dx = ix as f64 * step; + let dy = iy as f64 * step; + let w = (-0.5 * (dx * dx + dy * dy) / (sigma * sigma)).exp(); + kernel.push((dx, dy, w)); + total_weight += w; + } + } + + // Normalize weights to sum to 1.0 + for entry in &mut kernel { + entry.2 /= total_weight; + } + kernel + } + + fn build_linear_gradient( + x: f64, + y: f64, + width: f64, + height: f64, + transform: Option<&AffineTransform2D>, + stops: &[GradientStop], + ) -> Option { + use kurbo::Point; + + if stops.is_empty() || width <= 0.0 || height <= 0.0 { + return None; + } + // Figma linear gradient runs from UV (0, 0.5) to (1, 0.5) + let (snx, sny) = gradient_transform_point(transform, 0.0, 0.5); + let (enx, eny) = gradient_transform_point(transform, 1.0, 0.5); + let start = Point::new(x + snx * width, y + sny * height); + let end = Point::new(x + enx * width, y + eny * height); + Some(peniko::Gradient::new_linear(start, end).with_stops(to_peniko_stops(stops).as_slice())) + } + + fn build_radial_gradient( + x: f64, + y: f64, + width: f64, + height: f64, + transform: Option<&AffineTransform2D>, + stops: &[GradientStop], + ) -> Option<(peniko::Gradient, Option)> { + use kurbo::Point; + + if stops.is_empty() || width <= 0.0 || height <= 0.0 { + return None; + } + // Figma radial center is at UV (0.5, 0.5) + let (cnx, cny) = gradient_transform_point(transform, 0.5, 0.5); + let center = Point::new(x + cnx * width, y + cny * height); + let peniko_stops = to_peniko_stops(stops); + + if let Some(t) = transform { + // Ellipse axes from transform column vectors scaled to pixel space + let ax = (t.a * width, t.c * height); + let ay = (t.b * width, t.d * height); + let rx = (ax.0 * ax.0 + ax.1 * ax.1).sqrt() * 0.5; + let ry = (ay.0 * ay.0 + ay.1 * ay.1).sqrt() * 0.5; + let angle = ax.1.atan2(ax.0); + + // Unit circle gradient at origin; brush_transform maps to ellipse + let gradient = + peniko::Gradient::new_two_point_radial(Point::ZERO, 0.0, Point::ZERO, 1.0) + .with_stops(peniko_stops.as_slice()); + + let brush_xform = kurbo::Affine::translate(center.to_vec2()) + * kurbo::Affine::rotate(angle) + * kurbo::Affine::scale_non_uniform(rx.max(0.5), ry.max(0.5)); + + Some((gradient, Some(brush_xform))) + } else { + let radius = (width.min(height) * 0.5).max(0.5) as f32; + let gradient = peniko::Gradient::new_two_point_radial(center, 0.0, center, radius) + .with_stops(peniko_stops.as_slice()); + Some((gradient, None)) + } + } + + fn build_angular_gradient( + x: f64, + y: f64, + width: f64, + height: f64, + transform: Option<&AffineTransform2D>, + stops: &[GradientStop], + ) -> Option { + use kurbo::Point; + + if stops.is_empty() || width <= 0.0 || height <= 0.0 { + return None; + } + // Figma angular/sweep center is at UV (0.5, 0.5) + let (cnx, cny) = gradient_transform_point(transform, 0.5, 0.5); + let center = Point::new(x + cnx * width, y + cny * height); + // Extract rotation angle from transform's first column + let start_angle = if let Some(t) = transform { + (t.c * height).atan2(t.a * width) as f32 + } else { + 0.0 + }; + let end_angle = start_angle + std::f32::consts::PI * 2.0; + Some( + peniko::Gradient::new_sweep(center, start_angle, end_angle) + // A sweep is periodic: wrap past the seam instead of padding, + // so a start angle other than +x doesn't leave a flat wedge. + .with_extend(peniko::Extend::Repeat) + .with_stops(to_peniko_stops(stops).as_slice()), + ) + } + + fn gradient_transform_point( + transform: Option<&AffineTransform2D>, + u: f64, + v: f64, + ) -> (f64, f64) { + if let Some(t) = transform { + t.transform_point(u, v) + } else { + (u, v) + } + } + + fn to_peniko_stops(stops: &[GradientStop]) -> Vec { + use peniko::color::DynamicColor; + + stops + .iter() + .map(|stop| peniko::ColorStop { + offset: stop.offset as f32, + color: DynamicColor::from_alpha_color(to_peniko(stop.color.clone())), + }) + .collect() + } + + fn to_kurbo_stroke(style: &StrokeStyle, width_scale: f64) -> Stroke { + let cap = match style.cap { + StrokeCap::None => kurbo::Cap::Butt, + StrokeCap::Round => kurbo::Cap::Round, + StrokeCap::Square => kurbo::Cap::Square, + StrokeCap::LineArrow + | StrokeCap::TriangleArrow + | StrokeCap::DiamondFilled + | StrokeCap::CircleFilled + | StrokeCap::TriangleFilled + | StrokeCap::WashiTape1 + | StrokeCap::WashiTape2 + | StrokeCap::WashiTape3 + | StrokeCap::WashiTape4 + | StrokeCap::WashiTape5 + | StrokeCap::WashiTape6 => kurbo::Cap::Butt, + }; + let join = match style.join { + StrokeJoin::Miter => kurbo::Join::Miter, + StrokeJoin::Round => kurbo::Join::Round, + StrokeJoin::Bevel => kurbo::Join::Bevel, + }; + let mut stroke = Stroke::new((style.width * width_scale).max(0.0)) + .with_caps(cap) + .with_join(join) + .with_miter_limit(style.miter_limit.max(1.0)); + if !style.dash_pattern.is_empty() { + stroke = stroke.with_dashes( + style.dash_offset * width_scale, + style + .dash_pattern + .iter() + .copied() + .map(|v| (v * width_scale).max(0.0)), + ); + } + stroke + } + + fn to_peniko_mix(blend: &BlendMix) -> peniko::Mix { + match blend { + BlendMix::Normal => peniko::Mix::Normal, + BlendMix::Multiply => peniko::Mix::Multiply, + BlendMix::Screen => peniko::Mix::Screen, + BlendMix::Overlay => peniko::Mix::Overlay, + BlendMix::Darken => peniko::Mix::Darken, + BlendMix::Lighten => peniko::Mix::Lighten, + BlendMix::ColorDodge => peniko::Mix::ColorDodge, + BlendMix::ColorBurn => peniko::Mix::ColorBurn, + BlendMix::HardLight => peniko::Mix::HardLight, + BlendMix::SoftLight => peniko::Mix::SoftLight, + BlendMix::Difference => peniko::Mix::Difference, + BlendMix::Exclusion => peniko::Mix::Exclusion, + BlendMix::Hue => peniko::Mix::Hue, + BlendMix::Saturation => peniko::Mix::Saturation, + BlendMix::Color => peniko::Mix::Color, + BlendMix::Luminosity => peniko::Mix::Luminosity, + } + } + + fn stroke_aligned_rect(base: Rect, style: Option<&StrokeStyle>) -> Rect { + let Some(style) = style else { + return base; + }; + let half = (style.width * 0.5).max(0.0); + let snap_half = |v: f64| ((v * 2.0).round()) * 0.5; + match style.align { + StrokeAlign::Inside => Rect::new( + base.x0 + half, + base.y0 + half, + base.x1 - half, + base.y1 - half, + ), + StrokeAlign::Outside => Rect::new( + base.x0 - half, + base.y0 - half, + base.x1 + half, + base.y1 + half, + ), + StrokeAlign::Center => Rect::new( + snap_half(base.x0), + snap_half(base.y0), + snap_half(base.x1), + snap_half(base.y1), + ), + } + } + + fn stroke_aligned_corner_radius(base: f64, style: Option<&StrokeStyle>) -> f64 { + let Some(style) = style else { + return base.max(0.0); + }; + let half = (style.width * 0.5).max(0.0); + match style.align { + StrokeAlign::Inside => (base - half).max(0.0), + StrokeAlign::Outside => (base + half).max(0.0), + StrokeAlign::Center => base.max(0.0), + } + } + + /// The image in `data_base64` as a brush at `alpha`, decoded once and + /// cached (see [`decoded_image`]). A tiled image repeats past its edges; + /// every other mode pads with its edge pixels. + fn decode_image_brush( + data_base64: &Arc, + alpha: f64, + scale_mode: ImageScaleMode, + display_w: f64, + display_h: f64, + ) -> Option { + let tile = scale_mode == ImageScaleMode::Tile; + // A tile is drawn at its own size, not stretched over the node: + // keep every pixel. + let display = (!tile).then_some((display_w, display_h)); + let image = decoded_image(data_base64, display)?; + let extend = if tile { + peniko::Extend::Repeat + } else { + peniko::Extend::Pad + }; + Some(ImageBrush { + image, + sampler: ImageSampler { + x_extend: extend, + y_extend: extend, + quality: peniko::ImageQuality::Medium, + alpha: alpha as f32, + }, + }) + } + + /// Decoded images kept before the cache is reset, in RGBA bytes. + const DECODED_CACHE_BYTES: usize = 512 << 20; + + /// The image in `data_base64`, decoded to RGBA and — for `display` + /// sizes much smaller than the image — downscaled to twice the display + /// size (in 64 px buckets), so a large photo shown small doesn't upload + /// megabytes of texture. Cached across frames: by the shared data's + /// address (collects share one encoding per asset, so a frame's lookup + /// is a pointer, not a hash of megabytes of text; the entry keeps the + /// data alive, so the address can't be reused), and on a miss by + /// content, so fresh copies of the same data don't decode again. + fn decoded_image(data_base64: &Arc, display: Option<(f64, f64)>) -> Option { + use std::collections::HashMap; + use std::hash::{Hash, Hasher}; + use std::sync::{LazyLock, Mutex}; + + /// Display size bucket, px ((0, 0): full resolution). + type Bucket = (u32, u32); + struct Cache { + /// (data address, bucket) → (the data, its image). + by_ptr: HashMap<(usize, Bucket), (Arc, ImageData)>, + /// (data hash, bucket) → image. + by_hash: HashMap<(u64, Bucket), ImageData>, + bytes: usize, + } + static CACHE: LazyLock> = LazyLock::new(|| { + Mutex::new(Cache { + by_ptr: HashMap::new(), + by_hash: HashMap::new(), + bytes: 0, + }) + }); + + // Display dimensions bucketed to 64 px, so sub-pixel size changes + // share an entry; (0, 0) for full resolution. + let bucket = display.map_or((0, 0), |(w, h)| { + ( + ((w / 64.0).ceil() as u32).max(1) * 64, + ((h / 64.0).ceil() as u32).max(1) * 64, + ) + }); + let ptr_key = (Arc::as_ptr(data_base64).cast::() as usize, bucket); + if let Ok(cache) = CACHE.lock() + && let Some((data, image)) = cache.by_ptr.get(&ptr_key) + && Arc::ptr_eq(data, data_base64) + { + return Some(image.clone()); + } + let hash_key = { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + data_base64.hash(&mut hasher); + (hasher.finish(), bucket) + }; + let known = CACHE + .lock() + .ok() + .and_then(|c| c.by_hash.get(&hash_key).cloned()); + let image = match known { + Some(image) => image, + None => decode_rgba(data_base64, display)?, + }; + if let Ok(mut cache) = CACHE.lock() { + let size = image.data.len(); + if cache.bytes + size > DECODED_CACHE_BYTES { + cache.by_ptr.clear(); + cache.by_hash.clear(); + cache.bytes = 0; + } + cache.bytes += size; + cache.by_hash.insert(hash_key, image.clone()); + cache + .by_ptr + .insert(ptr_key, (Arc::clone(data_base64), image.clone())); + } + Some(image) + } + + /// Decode base64 image data to straight RGBA8, downscaled for + /// `display` (see [`decoded_image`]). + fn decode_rgba(data_base64: &str, display: Option<(f64, f64)>) -> Option { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data_base64) + .ok()?; + let decoded = image::load_from_memory(&bytes).ok()?.into_rgba8(); + let (orig_w, orig_h) = (decoded.width(), decoded.height()); + let (final_img, width, height) = match display { + Some((display_w, display_h)) + if orig_w > ((display_w * 2.0).ceil() as u32).max(64) + || orig_h > ((display_h * 2.0).ceil() as u32).max(64) => + { + // Downscale to 2x display size (for retina quality) when the + // source is significantly larger than the display area. + let max_w = (display_w * 2.0).ceil() as u32; + let max_h = (display_h * 2.0).ceil() as u32; + let scale = (max_w as f64 / orig_w as f64).min(max_h as f64 / orig_h as f64); + let new_w = ((orig_w as f64 * scale).round() as u32).max(1); + let new_h = ((orig_h as f64 * scale).round() as u32).max(1); + let resized = image::imageops::resize( + &decoded, + new_w, + new_h, + image::imageops::FilterType::Triangle, + ); + let (w, h) = (resized.width(), resized.height()); + (resized.into_vec(), w, h) + } + _ => (decoded.into_vec(), orig_w, orig_h), + }; + Some(ImageData { + data: Blob::new(Arc::new(final_img)), + format: peniko::ImageFormat::Rgba8, + alpha_type: peniko::ImageAlphaType::Alpha, + width, + height, + }) + } + + /// Render buffered mask-definition primitives into the scene. + /// These are the shape primitives captured between MaskDefStart..MaskDefEnd. + fn render_mask_primitives( + scene: &mut S, + prims: &[&PaintPrimitive], + scene_transform: kurbo::Affine, + ) { + let root_transform = scene_transform; + let mut group_stack: Vec = Vec::new(); + for prim in prims { + let scene_transform = group_stack.last().copied().unwrap_or(root_transform); + match prim { + PaintPrimitive::GroupStart { transform, .. } => { + group_stack.push(scene_transform * to_kurbo_affine(transform)); + } + PaintPrimitive::GroupEnd { .. } => { + group_stack.pop(); + } + PaintPrimitive::Rect { + x, + y, + width, + height, + fills, + corner_radii, + rotation, + .. + } => { + let (x, y, width, height, rotation) = (*x, *y, *width, *height, *rotation); + let (node_transform, lx, ly) = if rotation.abs() > 1e-6 { + let t = scene_transform + * kurbo::Affine::translate((x, y)) + * kurbo::Affine::rotate(rotation); + (t, 0.0, 0.0) + } else { + (scene_transform, x, y) + }; + let rect = Rect::new(lx, ly, lx + width, ly + height); + if !corner_radii.is_zero() { + let radii = kurbo::RoundedRectRadii::new( + corner_radii.top_left, + corner_radii.top_right, + corner_radii.bottom_right, + corner_radii.bottom_left, + ); + let rr = RoundedRect::from_rect(rect, radii); + for (fill, _) in fills { + fill_shape(scene, node_transform, &rr, lx, ly, width, height, fill); + } + } else { + for (fill, _) in fills { + fill_shape(scene, node_transform, &rect, lx, ly, width, height, fill); + } + } + } + PaintPrimitive::Path { + x: path_x, + y: path_y, + width, + height, + fill_paths, + fills, + rotation, + normalize_to_origin, + stroke_paths, + authored, + text_outline, + .. + } => { + let (width, height, rotation) = (*width, *height, *rotation); + let (base_transform, lx, ly) = if rotation.abs() > 1e-6 { + let t = scene_transform + * kurbo::Affine::translate((*path_x, *path_y)) + * kurbo::Affine::rotate(rotation); + (t, 0.0, 0.0) + } else { + (scene_transform, *path_x, *path_y) + }; + let path_origin_transform = base_transform * kurbo::Affine::translate((lx, ly)); + // Authored geometry is drawn as written (see the main painter). + let path_fit = if *authored && !*text_outline { + kurbo::Affine::IDENTITY + } else { + compute_path_fit_transform( + fill_paths, + stroke_paths, + lx, + ly, + width, + height, + *normalize_to_origin, + ) + }; + let path_transform = path_origin_transform * path_fit; + for (fill_paint, _) in fills { + for path_data in fill_paths { + if let Some(path) = cached_bez_path(path_data) { + fill_path_shape( + scene, + path_transform, + &path, + lx, + ly, + width, + height, + fill_paint, + ); + } + } + } + } + _ => { + // Other primitive types in mask defs are uncommon; skip them. + } + } + } + } + + /// Row-major `[[a, b, tx], [c, d, ty]]` → kurbo's column-major coefficients. + fn to_kurbo_affine(t: &AffineTransform2D) -> kurbo::Affine { + kurbo::Affine::new([t.a, t.c, t.b, t.d, t.tx, t.ty]) + } + + fn apply_text_case(text: &str, text_case: &TextCase) -> String { + match text_case { + TextCase::Original => text.to_string(), + TextCase::Upper | TextCase::SmallCaps | TextCase::SmallCapsForced => { + text.to_uppercase() + } + TextCase::Lower => text.to_lowercase(), + TextCase::Title => { + let mut out = String::with_capacity(text.len()); + let mut new_word = true; + for ch in text.chars() { + if ch.is_whitespace() { + new_word = true; + out.push(ch); + } else if new_word { + out.extend(ch.to_uppercase()); + new_word = false; + } else { + out.push(ch); + } + } + out + } + } + } +} diff --git a/crates/frame/src/render/anyrender_renderer/scene_collect.rs b/crates/frame/src/render/anyrender_renderer/scene_collect.rs new file mode 100644 index 00000000..36107e41 --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/scene_collect.rs @@ -0,0 +1,1473 @@ +//! `crate::document::proto::Scene` → paint primitives. +//! +//! The render semantics live here (opacity inheritance, compositing layers, +//! clip decisions, sibling masks, instance fallback); the scene only carries +//! authored values. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::document::proto::{self as doc, NodeId, NodeKind, Scene}; +use base64::Engine as _; +use taffy::TaffyTree; +use taffy::prelude::*; + +use super::effects_plan::{EffectsPlan, EmissiveDraw, ShaderRegion}; +use super::transform::AffineTransform2D; +use super::types::*; + +/// The nearest control, `id` or an ancestor, with its default position. +fn nearest_control(scene: &Scene, id: NodeId) -> Option<(NodeId, Option)> { + let mut at = Some(id); + while let Some(n) = at.and_then(|i| scene.node(i)) { + if let Some(c) = &n.control { + return Some((n.id, c.default)); + } + at = n.parent; + } + None +} + +// ── value conversion ───────────────────────────────────────────────────── + +fn clamp01(v: f64) -> f64 { + v.clamp(0.0, 1.0) +} + +/// A paint color with the paint's opacity folded into alpha. +fn rgba(c: &doc::Color, paint_opacity: f64) -> Rgba { + Rgba { + r: clamp01(c.r), + g: clamp01(c.g), + b: clamp01(c.b), + a: clamp01(clamp01(c.a) * clamp01(paint_opacity)), + } +} + +fn affine(t: &doc::Transform) -> AffineTransform2D { + AffineTransform2D::from([[t.a, t.b, t.tx], [t.c, t.d, t.ty]]) +} + +pub(super) fn blend_mix(b: doc::BlendMode) -> BlendMix { + use doc::BlendMode as B; + match b { + B::Multiply => BlendMix::Multiply, + B::Screen => BlendMix::Screen, + B::Overlay => BlendMix::Overlay, + B::Darken => BlendMix::Darken, + B::Lighten => BlendMix::Lighten, + B::ColorDodge => BlendMix::ColorDodge, + B::ColorBurn => BlendMix::ColorBurn, + B::HardLight => BlendMix::HardLight, + B::SoftLight => BlendMix::SoftLight, + B::Difference => BlendMix::Difference, + B::Exclusion => BlendMix::Exclusion, + B::Hue => BlendMix::Hue, + B::Saturation => BlendMix::Saturation, + B::Color => BlendMix::Color, + B::Luminosity => BlendMix::Luminosity, + B::Normal | B::PassThrough | B::LinearBurn | B::LinearDodge => BlendMix::Normal, + } +} + +fn stops(stops: &[doc::GradientStop], paint_opacity: f64) -> Vec { + stops + .iter() + .map(|s| GradientStop { + offset: clamp01(s.position), + color: rgba(&s.color, paint_opacity), + }) + .collect() +} + +pub(crate) fn fill_paint(p: &doc::Paint, scene: &Scene) -> Option { + Some(match &p.kind { + doc::PaintKind::Solid { color } => FillPaint::Solid(rgba(color, p.opacity)), + doc::PaintKind::LinearGradient { + stops: s, + transform, + } => FillPaint::GradientLinear { + transform: transform.as_ref().map(affine), + stops: stops(s, p.opacity), + }, + doc::PaintKind::RadialGradient { + stops: s, + transform, + } => FillPaint::GradientRadial { + transform: transform.as_ref().map(affine), + stops: stops(s, p.opacity), + }, + doc::PaintKind::AngularGradient { + stops: s, + transform, + } => FillPaint::GradientAngular { + transform: transform.as_ref().map(affine), + stops: stops(s, p.opacity), + }, + doc::PaintKind::Image { + asset, + source_ref, + scale_mode, + transform, + } => FillPaint::Image { + image_hash: source_ref.clone(), + data_base64: asset_base64(scene, asset), + alpha: clamp01(p.opacity), + scale_mode: match scale_mode { + doc::ImageScaleMode::Fill => ImageScaleMode::Fill, + doc::ImageScaleMode::Fit => ImageScaleMode::Fit, + doc::ImageScaleMode::Crop => ImageScaleMode::Crop, + doc::ImageScaleMode::Tile => ImageScaleMode::Tile, + doc::ImageScaleMode::Stretch => ImageScaleMode::Stretch, + }, + image_transform: transform.as_ref().map(affine), + }, + }) +} + +/// Visible paints, in stack order. +pub(super) fn fill_paints(paints: &[doc::Paint], scene: &Scene) -> Vec<(FillPaint, BlendMix)> { + paints + .iter() + .filter(|p| p.visible) + .filter_map(|p| fill_paint(p, scene).map(|f| (f, blend_mix(p.blend)))) + .collect() +} + +pub(super) fn first_color(fill: &FillPaint) -> Option { + match fill { + FillPaint::Solid(c) => Some(c.clone()), + FillPaint::GradientLinear { stops, .. } + | FillPaint::GradientRadial { stops, .. } + | FillPaint::GradientAngular { stops, .. } => stops.first().map(|s| s.color.clone()), + FillPaint::Image { .. } => None, + } +} + +/// The drawn stroke: the first visible solid or gradient paint, when the +/// weight is positive. (Image strokes aren't drawn.) +pub(super) fn stroke_style(s: &doc::Stroke, opacity: f64) -> Option { + let (color, gradient) = s + .paints + .iter() + .filter(|p| p.visible) + .find_map(|p| match &p.kind { + doc::PaintKind::Solid { color } => Some((rgba(color, p.opacity), None)), + doc::PaintKind::LinearGradient { + stops: s, + transform, + } => gradient_stroke(FillPaint::GradientLinear { + transform: transform.as_ref().map(affine), + stops: stops(s, p.opacity), + }), + doc::PaintKind::RadialGradient { + stops: s, + transform, + } => gradient_stroke(FillPaint::GradientRadial { + transform: transform.as_ref().map(affine), + stops: stops(s, p.opacity), + }), + doc::PaintKind::AngularGradient { + stops: s, + transform, + } => gradient_stroke(FillPaint::GradientAngular { + transform: transform.as_ref().map(affine), + stops: stops(s, p.opacity), + }), + doc::PaintKind::Image { .. } => None, + })?; + let width = s.weight.max(0.0); + if width <= 0.0 { + return None; + } + Some(StrokeStyle { + color: color.with_opacity(opacity), + gradient: gradient.map(|g| g.with_opacity(opacity)), + width, + cap: s.cap, + join: s.join, + align: s.align, + miter_limit: s.miter_limit.max(1.0), + dash_pattern: s + .dash_pattern + .iter() + .copied() + .filter(|v| *v > 0.0) + .collect(), + dash_offset: s.dash_offset, + }) +} + +/// A gradient stroke paint with its first stop as the stroke's colour; +/// `None` without stops. +fn gradient_stroke(g: FillPaint) -> Option<(Rgba, Option)> { + Some((first_color(&g)?, Some(g))) +} + +pub(super) fn node_effects(effects: &[doc::Effect], opacity: f64) -> Vec { + effects + .iter() + .filter(|e| e.visible) + .map(|e| NodeEffect { + kind: match e.kind { + doc::EffectKind::DropShadow => EffectKind::DropShadow, + doc::EffectKind::InnerShadow => EffectKind::InnerShadow, + doc::EffectKind::LayerBlur => EffectKind::LayerBlur, + doc::EffectKind::BackgroundBlur => EffectKind::BackgroundBlur, + }, + color: Rgba { + r: e.color.r, + g: e.color.g, + b: e.color.b, + a: e.color.a, + } + .with_opacity(opacity), + offset_x: e.offset_x, + offset_y: e.offset_y, + radius: e.radius.max(0.0), + spread: e.spread, + }) + .collect() +} + +fn text_case(c: doc::TextCase) -> TextCase { + match c { + doc::TextCase::Original => TextCase::Original, + doc::TextCase::Upper => TextCase::Upper, + doc::TextCase::Lower => TextCase::Lower, + doc::TextCase::Title => TextCase::Title, + doc::TextCase::SmallCaps => TextCase::SmallCaps, + doc::TextCase::SmallCapsForced => TextCase::SmallCapsForced, + } +} + +/// A text node's font settings. +#[must_use] +pub fn text_font(text: &doc::TextData) -> FontSpec { + FontSpec { + family: text.font_family.clone(), + weight: text.font_weight, + style: text.font_style.clone(), + width: text.font_width, + } +} + +fn text_style_runs(text: &doc::TextData, opacity: f64) -> Vec { + text.runs + .iter() + .filter(|r| !r.text.is_empty()) + .map(|r| TextStyleRun { + text: r.text.clone(), + // The first solid paint of the run, visible or not (matches the + // exporter's own run color). + color: r.fills.iter().find_map(|p| match &p.kind { + doc::PaintKind::Solid { color } => { + Some(rgba(color, p.opacity).with_opacity(opacity)) + } + _ => None, + }), + font_size: r.font_size, + letter_spacing: r.letter_spacing, + font_style: r.font_style.clone(), + font_weight: r.font_weight, + font_family: r.font_family.clone(), + font_width: r.font_width, + }) + .collect() +} + +/// One fill per visibly-distinct run, for painting text outline glyph groups. +fn text_outline_fills( + text: &doc::TextData, + scene: &Scene, + opacity: f64, +) -> Vec<(FillPaint, BlendMix)> { + let mut runs: Vec<(FillPaint, BlendMix)> = Vec::new(); + for run in &text.runs { + if run.text.trim().is_empty() { + continue; + } + let Some((fill, blend)) = fill_paints(&run.fills, scene) + .into_iter() + .map(|(f, b)| (f.with_opacity(opacity), b)) + .next() + else { + continue; + }; + if runs + .last() + .is_none_or(|(lf, lb)| *lb != blend || *lf != fill) + { + runs.push((fill, blend)); + } + } + runs +} + +fn snapshot_base64(node: &doc::Node, scene: &Scene) -> Option> { + let id = node.svg_snapshot.as_ref()?; + scene.asset(id)?; + Some(asset_base64(scene, id)) +} + +/// Cached encodings kept before the cache is reset, in base64 bytes. +const BASE64_CACHE_BYTES: usize = 256 << 20; + +/// An asset's bytes, base64-encoded, encoded once and shared across +/// collects (and frames): images and snapshots are carried to the painters +/// and the SVG exporter in base64, and encoding a large texture on every +/// collect was most of a textured page's collect time. Keyed by the asset +/// id with the bytes' length and a sample of them, so an id reused for +/// other bytes re-encodes. A missing asset encodes as empty. +pub(crate) fn asset_base64(scene: &Scene, id: &doc::AssetId) -> Arc { + use std::hash::{Hash, Hasher}; + use std::sync::{LazyLock, Mutex}; + type Cache = (HashMap<(String, u64), Arc>, usize); + static CACHE: LazyLock> = LazyLock::new(|| Mutex::new((HashMap::new(), 0))); + + let bytes = scene + .asset(id) + .map(|a| a.bytes.as_slice()) + .unwrap_or_default(); + let fingerprint = { + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.len().hash(&mut h); + bytes[..bytes.len().min(256)].hash(&mut h); + bytes[bytes.len().saturating_sub(256)..].hash(&mut h); + h.finish() + }; + let key = (id.0.clone(), fingerprint); + if let Some(hit) = CACHE.lock().ok().and_then(|c| c.0.get(&key).cloned()) { + return hit; + } + let encoded: Arc = base64::engine::general_purpose::STANDARD + .encode(bytes) + .into(); + let Ok(mut c) = CACHE.lock() else { + return encoded; + }; + if let Some(raced) = c.0.get(&key) { + // Another thread encoded it meanwhile: share theirs. + return Arc::clone(raced); + } + if c.1 + encoded.len() > BASE64_CACHE_BYTES { + *c = (HashMap::new(), 0); + } + c.1 += encoded.len(); + c.0.insert(key, Arc::clone(&encoded)); + encoded +} + +enum Class { + Text, + Shape, + Other, +} + +fn class(kind: &NodeKind) -> Class { + match kind { + NodeKind::Text(_) => Class::Text, + NodeKind::Rectangle + | NodeKind::Ellipse + | NodeKind::Line + | NodeKind::Polygon + | NodeKind::Star + | NodeKind::Vector + | NodeKind::BooleanOperation { .. } => Class::Shape, + _ => Class::Other, + } +} + +fn local_rect(node: &doc::Node) -> (f64, f64, f64, f64) { + ( + node.transform.tx, + node.transform.ty, + node.size.width, + node.size.height, + ) +} + +/// Whether `t`'s 2×2 linear part is (within `EPS`) the identity: a pure +/// translation, which positions children but doesn't transform them. +fn is_translation(t: &doc::Transform) -> bool { + const EPS: f64 = 1e-9; + (t.a - 1.0).abs() <= EPS && t.b.abs() <= EPS && t.c.abs() <= EPS && (t.d - 1.0).abs() <= EPS +} + +/// Whether `node` is drawn as authored rather than as a Figma import (see +/// `authored` on [`PaintPrimitive::Path`]). +fn is_authored(node: &doc::Node) -> bool { + node.source.as_ref().is_none_or(|s| s.tool != "figma") +} + +/// Node-local bounds `(x0, y0, x1, y1)` of an authored node's geometry, +/// stroke included; `None` for Figma imports and nodes without geometry. +fn authored_geometry_bounds(node: &doc::Node) -> Option<(f64, f64, f64, f64)> { + if !is_authored(node) || (node.fill_geometry.is_empty() && node.stroke_geometry.is_empty()) { + return None; + } + let b = node + .fill_geometry + .iter() + .chain(&node.stroke_geometry) + .filter_map(cached_path_bounds) + .filter(|r| r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()) + .reduce(|a, b| a.union(b))?; + let stroked = node.stroke.paints.iter().any(|p| p.visible) && node.stroke_geometry.is_empty(); + let pad = if stroked { + node.stroke.weight.max(0.0) * node.stroke.miter_limit.max(1.0) * 0.5 + } else { + 0.0 + }; + Some((b.x0 - pad, b.y0 - pad, b.x1 + pad, b.y1 + pad)) +} + +/// Whether a node's transform applies to its children. +/// +/// Groups and boolean operations are transparent: like Figma's, their +/// children are authored in the enclosing container's space and already carry +/// the group's transform, so propagating it would apply it twice. +fn propagates_transform(node: &doc::Node) -> bool { + !matches!( + node.kind, + NodeKind::Group | NodeKind::BooleanOperation { .. } + ) && !is_translation(&node.transform) +} + +/// The group matrix for a node whose box origin sits at `(x, y)` in +/// flattened scene space: its linear part, pivoted about its origin +/// (`T(x, y) · L · T(-x, -y)`). +fn group_transform(t: &doc::Transform, x: f64, y: f64) -> AffineTransform2D { + AffineTransform2D { + a: t.a, + b: t.b, + tx: x - (t.a * x + t.b * y), + c: t.c, + d: t.d, + ty: y - (t.c * x + t.d * y), + } +} + +// ── layout ─────────────────────────────────────────────────────────────── + +/// Absolute boxes for `root` and its subtree, running auto layout. +/// +/// Boxes are axis-aligned in *flattened* scene space: each child sits at its +/// parent's box origin plus its own offset, in the parent's **unrotated** +/// frame. Ancestor rotation/scale/skew is not applied (the painters apply it +/// through [`PaintPrimitive::GroupStart`]); a hit-tester over a transformed +/// container must map the pointer through the inverse of the containers' +/// transforms first. +#[must_use] +pub fn build_scene_layout_boxes(scene: &Scene, root: NodeId) -> HashMap { + let mut out = HashMap::new(); + let (x, y, w, h) = scene.node(root).map_or((0.0, 0.0, 0.0, 0.0), local_rect); + layout_recursive(scene, root, x, y, w, h, &mut out); + out +} + +fn layout_recursive( + scene: &Scene, + id: NodeId, + ax: f64, + ay: f64, + w: f64, + h: f64, + out: &mut HashMap, +) { + out.insert( + id, + LayoutBox { + x: ax, + y: ay, + width: w, + height: h, + }, + ); + let Some(node) = scene.node(id) else { return }; + if node.children.is_empty() { + return; + } + let free = |out: &mut HashMap| { + for c in &node.children { + let (cx, cy, cw, ch) = scene.node(*c).map_or((0.0, 0.0, 0.0, 0.0), local_rect); + layout_recursive(scene, *c, ax + cx, ay + cy, cw, ch, out); + } + }; + let Some(al) = &node.layout else { + free(out); + return; + }; + + let mut taffy = TaffyTree::<()>::new(); + let mut kids = Vec::new(); + for c in &node.children { + if let Ok(t) = taffy.new_leaf(child_style(scene, *c)) { + kids.push((*c, t)); + } + } + let ids: Vec<_> = kids.iter().map(|(_, t)| *t).collect(); + let Ok(container) = taffy.new_with_children(container_style(al, w, h), &ids) else { + free(out); + return; + }; + let _ = taffy.compute_layout( + container, + taffy::Size { + width: AvailableSpace::Definite(w as f32), + height: AvailableSpace::Definite(h as f32), + }, + ); + for (c, t) in kids { + if let Ok(l) = taffy.layout(t) { + layout_recursive( + scene, + c, + ax + f64::from(l.location.x), + ay + f64::from(l.location.y), + f64::from(l.size.width), + f64::from(l.size.height), + out, + ); + } else { + let (cx, cy, cw, ch) = scene.node(c).map_or((0.0, 0.0, 0.0, 0.0), local_rect); + layout_recursive(scene, c, ax + cx, ay + cy, cw, ch, out); + } + } +} + +fn container_style(al: &doc::AutoLayout, w: f64, h: f64) -> Style { + let row = matches!(al.direction, doc::LayoutDirection::Horizontal); + Style { + display: Display::Flex, + size: taffy::Size { + width: Dimension::Length(w as f32), + height: Dimension::Length(h as f32), + }, + flex_direction: if row { + FlexDirection::Row + } else { + FlexDirection::Column + }, + gap: if row { + taffy::Size { + width: LengthPercentage::Length(al.gap as f32), + height: LengthPercentage::Length(0.0), + } + } else { + taffy::Size { + width: LengthPercentage::Length(0.0), + height: LengthPercentage::Length(al.gap as f32), + } + }, + padding: taffy::Rect { + left: LengthPercentage::Length(al.padding_left as f32), + right: LengthPercentage::Length(al.padding_right as f32), + top: LengthPercentage::Length(al.padding_top as f32), + bottom: LengthPercentage::Length(al.padding_bottom as f32), + }, + flex_wrap: if al.wrap { + FlexWrap::Wrap + } else { + FlexWrap::NoWrap + }, + justify_content: al.main_align.map(|a| match a { + doc::MainAxisAlign::Start => JustifyContent::Start, + doc::MainAxisAlign::Center => JustifyContent::Center, + doc::MainAxisAlign::End => JustifyContent::End, + doc::MainAxisAlign::SpaceBetween => JustifyContent::SpaceBetween, + }), + align_items: al.cross_align.map(|a| match a { + doc::CrossAxisAlign::Start => AlignItems::Start, + doc::CrossAxisAlign::Center => AlignItems::Center, + doc::CrossAxisAlign::End => AlignItems::End, + doc::CrossAxisAlign::Baseline => AlignItems::Baseline, + }), + ..Default::default() + } +} + +fn child_style(scene: &Scene, id: NodeId) -> Style { + let Some(node) = scene.node(id) else { + return Style::default(); + }; + let (x, y, w, h) = local_rect(node); + let lc = &node.layout_child; + let mut style = Style { + size: taffy::Size { + width: Dimension::Length(w as f32), + height: Dimension::Length(h as f32), + }, + // Children keep their authored size; auto layout never shrinks them. + flex_shrink: 0.0, + min_size: taffy::Size { + width: Dimension::Length(lc.min_width.unwrap_or(0.0) as f32), + height: Dimension::Length(lc.min_height.unwrap_or(0.0) as f32), + }, + max_size: taffy::Size { + width: lc + .max_width + .map_or(Dimension::Auto, |v| Dimension::Length(v as f32)), + height: lc + .max_height + .map_or(Dimension::Auto, |v| Dimension::Length(v as f32)), + }, + align_self: lc.align_self.map(|a| match a { + doc::AlignSelf::Start => AlignSelf::Start, + doc::AlignSelf::Center => AlignSelf::Center, + doc::AlignSelf::End => AlignSelf::End, + doc::AlignSelf::Stretch => AlignSelf::Stretch, + }), + ..Default::default() + }; + if let Some(g) = lc.grow { + style.flex_grow = g as f32; + } + // A hidden child leaves the flow, as in Figma: the row closes up + // behind it. A theme hides a strip's pan section at narrow widths + // and the controls under it move up into the room. + if !node.visible { + style.display = Display::None; + } + if lc.absolute { + style.position = Position::Absolute; + style.inset = taffy::Rect { + left: LengthPercentageAuto::Length(x as f32), + right: LengthPercentageAuto::Auto, + top: LengthPercentageAuto::Length(y as f32), + bottom: LengthPercentageAuto::Auto, + }; + } + style +} + +// ── primitives ─────────────────────────────────────────────────────────── + +/// How [`build_scene_primitives_with`] treats the optional light/shader +/// properties. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CollectOptions { + /// Draw each `emission` as a [`EffectKind::Glow`] on the node's own + /// primitive (the fallback for paths without the GPU effects stage). + pub emission_glow: bool, +} + +impl Default for CollectOptions { + fn default() -> Self { + Self { + emission_glow: true, + } + } +} + +/// The paint primitives for `root`'s subtree, in paint order. +/// +/// Emitting nodes carry a [`EffectKind::Glow`] (the no-GPU fallback for +/// light); shaders are skipped. Scenes without `emission` are unaffected. +#[must_use] +pub fn build_scene_primitives(scene: &Scene, root: NodeId) -> Vec { + build_scene_primitives_with(scene, root, CollectOptions::default()) +} + +/// [`build_scene_primitives`] with explicit [`CollectOptions`]. +#[must_use] +pub fn build_scene_primitives_with( + scene: &Scene, + root: NodeId, + options: CollectOptions, +) -> Vec { + collect(scene, root, options, false).0 +} + +/// The primitives (without emission glows) and the [`EffectsPlan`] the GPU +/// effects stage runs over them. The primitives are exactly +/// `build_scene_primitives_with(.., CollectOptions { emission_glow: false })`. +#[must_use] +pub fn build_scene_primitives_and_effects( + scene: &Scene, + root: NodeId, +) -> (Vec, EffectsPlan) { + let (out, plan) = collect( + scene, + root, + CollectOptions { + emission_glow: false, + }, + true, + ); + (out, plan.unwrap_or_default()) +} + +fn collect( + scene: &Scene, + root: NodeId, + options: CollectOptions, + plan: bool, +) -> (Vec, Option) { + let boxes = build_scene_layout_boxes(scene, root); + let mut out = Vec::new(); + let mut cx = Cx { + scene, + boxes: &boxes, + out: &mut out, + options, + plan: plan.then(EffectsPlan::default), + groups: Vec::new(), + clips: Vec::new(), + in_shader: false, + in_mask_def: 0, + flicker: None, + }; + cx.node(root, 0.0, 0.0, false, 1.0, &[]); + let plan = cx.plan.take(); + (out, plan) +} + +struct Cx<'a> { + scene: &'a Scene, + boxes: &'a HashMap, + out: &'a mut Vec, + options: CollectOptions, + /// Collected effects, when the caller runs the GPU stage. + plan: Option, + /// Open `GroupStart` transforms, each composed with the ones before. + groups: Vec, + /// Open `ClipStart`s with the group transform each was opened under. + clips: Vec<(AffineTransform2D, PaintPrimitive)>, + /// Inside a shader region (nested shaders draw plainly). + in_shader: bool, + /// Inside a mask definition (no effects there). + in_mask_def: usize, + /// The params of the nearest enclosing `flicker` shader. + flicker: Option>, +} + +/// A copy of a node's own primitive painted in `color`: fills become one +/// solid fill, strokes take the colour, effects and blends are dropped. +fn recolor(p: &PaintPrimitive, color: &Rgba) -> Option { + let solid = || vec![(FillPaint::Solid(color.clone()), BlendMix::Normal)]; + let stroke_of = |s: &Option| { + s.clone().map(|s| StrokeStyle { + color: color.clone(), + gradient: None, + ..s + }) + }; + match p { + PaintPrimitive::Rect { + node_id, + x, + y, + width, + height, + fills, + stroke, + corner_radii, + rotation, + authored, + .. + } => (!fills.is_empty() || stroke.is_some()).then(|| PaintPrimitive::Rect { + node_id: *node_id, + x: *x, + y: *y, + width: *width, + height: *height, + fills: if fills.is_empty() { + Vec::new() + } else { + solid() + }, + stroke: stroke_of(stroke), + corner_radii: *corner_radii, + effects: Vec::new(), + blend: BlendMix::Normal, + rotation: *rotation, + authored: *authored, + }), + PaintPrimitive::Path { + node_id, + x, + y, + width, + height, + normalize_to_origin, + text_outline, + fill_paths, + stroke_paths, + fills, + stroke, + rotation, + authored, + .. + } => { + // A snapshot-only path can't be recoloured; the caller falls back + // to the node's box. + (!fill_paths.is_empty() || !stroke_paths.is_empty()).then(|| PaintPrimitive::Path { + node_id: *node_id, + x: *x, + y: *y, + width: *width, + height: *height, + normalize_to_origin: *normalize_to_origin, + text_outline: *text_outline, + // A stroke with no stroke geometry strokes the fill geometry + // (an open line): keep it so the light follows the line. + fill_paths: if fills.is_empty() && (stroke.is_none() || !stroke_paths.is_empty()) { + Vec::new() + } else { + fill_paths.clone() + }, + stroke_paths: if stroke.is_some() { + stroke_paths.clone() + } else { + Vec::new() + }, + svg_base64: None, + fills: if fills.is_empty() { + Vec::new() + } else { + solid() + }, + stroke: stroke_of(stroke), + effects: Vec::new(), + blend: BlendMix::Normal, + rotation: *rotation, + authored: *authored, + }) + } + PaintPrimitive::Text { .. } => { + let mut t = p.clone(); + if let PaintPrimitive::Text { + color: c, + style_runs, + blend, + effects, + .. + } = &mut t + { + *c = color.clone(); + *blend = BlendMix::Normal; + effects.clear(); + for r in style_runs { + r.color = Some(color.clone()); + } + } + Some(t) + } + _ => None, + } +} + +impl Cx<'_> { + fn child_box(&self, child: NodeId, px: f64, py: f64) -> (f64, f64, f64, f64) { + self.boxes.get(&child).map_or_else( + || { + let (lx, ly, w, h) = self + .scene + .node(child) + .map_or((0.0, 0.0, 0.0, 0.0), local_rect); + (px + lx, py + ly, w, h) + }, + |b| (b.x, b.y, b.width, b.height), + ) + } + + #[allow(clippy::too_many_lines)] + fn node( + &mut self, + node_id: NodeId, + parent_x: f64, + parent_y: f64, + force_local: bool, + inherited_opacity: f64, + inherited_blurs: &[NodeEffect], + ) { + let scene = self.scene; + let Some(node) = scene.node(node_id) else { + return; + }; + if !node.visible { + return; + } + let effective_opacity = clamp01(inherited_opacity * clamp01(node.opacity)); + let (lx, ly, lw, lh) = local_rect(node); + let (chain_x, chain_y) = (parent_x + lx, parent_y + ly); + let (x, y, width, height) = if force_local { + (chain_x, chain_y, lw, lh) + } else { + self.boxes + .get(&node_id) + .map_or((chain_x, chain_y, lw, lh), |b| { + (b.x, b.y, b.width, b.height) + }) + }; + + let mut radii = node.corner_radii; + if matches!(node.kind, NodeKind::Ellipse) && radii.is_zero() { + radii = doc::CornerRadii::uniform(width.min(height) * 0.5); + } + let blend = blend_mix(node.blend); + let has_filter_effects = node.effects.iter().any(|e| { + e.visible + && matches!( + e.kind, + doc::EffectKind::DropShadow + | doc::EffectKind::InnerShadow + | doc::EffectKind::LayerBlur + ) + }); + // Non-normal blends, and filtered nodes with partial opacity, composite + // through an explicit layer that carries the opacity. + let needs_layer = (blend != BlendMix::Normal + || (has_filter_effects && effective_opacity < 1.0)) + && width > 0.0 + && height > 0.0; + let content_opacity = if needs_layer { 1.0 } else { effective_opacity }; + let primitive_blend = if needs_layer { + BlendMix::Normal + } else { + blend.clone() + }; + + let fills: Vec<(FillPaint, BlendMix)> = fill_paints(&node.fills, scene) + .into_iter() + .map(|(f, b)| (f.with_opacity(content_opacity), b)) + .collect(); + let stroke = stroke_style(&node.stroke, content_opacity); + let mut effects = node_effects(&node.effects, content_opacity); + if self.options.emission_glow + && let Some(em) = &node.emission + && em.radius > 0.0 + && em.intensity > 0.0 + { + effects.push(NodeEffect { + kind: EffectKind::Glow, + color: rgba(&em.color, 1.0) + .with_opacity(clamp01(content_opacity * em.intensity * 0.85)), + offset_x: 0.0, + offset_y: 0.0, + radius: em.radius, + spread: 0.0, + }); + } + let mut merged_effects = inherited_blurs.to_vec(); + merged_effects.extend(effects.iter().cloned()); + let mut child_blurs = inherited_blurs.to_vec(); + child_blurs.extend( + effects + .iter() + .filter(|e| matches!(e.kind, EffectKind::LayerBlur)) + .cloned(), + ); + + let fill_paths = node.fill_geometry.clone(); + let stroke_paths = node.stroke_geometry.clone(); + let svg_base64 = snapshot_base64(node, scene); + let has_native_geometry = !fill_paths.is_empty() || !stroke_paths.is_empty(); + let has_vector_geometry = has_native_geometry || svg_base64.is_some(); + let instance_component = match &node.kind { + NodeKind::Instance { component } => Some(*component), + _ => None, + }; + let is_boolean = matches!(node.kind, NodeKind::BooleanOperation { .. }); + // Figma imports keep the exporter-compatible drawing parity is + // measured against; everything else is drawn as authored. + let authored = is_authored(node); + + // A container with a non-translation transform paints itself and its + // subtree inside a group carrying that transform, so children inherit + // it. Leaves keep their own `rotation` (no group, unchanged output). + let has_descendants = !node.children.is_empty() + || matches!(instance_component, Some(Some(main)) if scene.node(main).is_some_and(|m| !m.children.is_empty())); + let group = (has_descendants && propagates_transform(node)) + .then(|| group_transform(&node.transform, x, y)); + let rotation = if group.is_some() { + 0.0 + } else { + node.transform.rotation() + }; + + // Effects bookkeeping (only when a plan is collected). + let region_start = self.out.len(); + let outer_world = self + .groups + .last() + .copied() + .unwrap_or_else(AffineTransform2D::identity); + let shader_here = self.plan.is_some() + && !self.in_shader + && self.in_mask_def == 0 + && node.shader.as_ref().is_some_and(|s| !s.shader.is_empty()); + let saved_flicker = self.flicker.clone(); + if let Some(fx) = node.shader.as_ref().filter(|s| s.shader == "flicker") { + self.flicker = Some(fx.params.clone()); + } + if shader_here { + self.in_shader = true; + } + + if let Some(transform) = group { + self.out + .push(PaintPrimitive::GroupStart { node_id, transform }); + self.groups.push(outer_world * transform); + } + if needs_layer { + self.out.push(PaintPrimitive::LayerStart { + node_id, + x, + y, + width, + height, + blend: blend.clone(), + opacity: effective_opacity, + }); + } + + let child_overflows = node.clips_content + && width > 0.0 + && height > 0.0 + && node.children.iter().any(|c| { + const EPS: f64 = 1e-3; + let (cx, cy, cw, ch) = self.child_box(*c, x, y); + let outside = |(x0, y0, x1, y1): (f64, f64, f64, f64)| { + x0 < x - EPS || y0 < y - EPS || x1 > x + width + EPS || y1 > y + height + EPS + }; + outside((cx, cy, cx + cw, cy + ch)) + // Authored geometry is drawn as written, so it can reach + // past its own box. + || scene.node(*c).is_some_and(|n| { + authored_geometry_bounds(n).is_some_and(|(x0, y0, x1, y1)| { + !is_translation(&n.transform) || outside((cx + x0, cy + y0, cx + x1, cy + y1)) + }) + }) + }); + let clip_subtree = node.clips_content + && !node.children.is_empty() + && (fills.is_empty() || child_overflows); + let ancestor_clips = if self.plan.is_some() && node.emission.is_some() { + self.clips.clone() + } else { + Vec::new() + }; + // Clipping content clips the children. Figma imports open the clip + // before the node's own paint (as parity expects); authored nodes + // after it, so their own shadows and outside strokes aren't cut. + let open_clip = clip_subtree && width > 0.0 && height > 0.0; + if open_clip && !authored { + self.open_clip(node_id, x, y, width, height, radii); + } + + let mut atomic_svg = false; + let path = |x: f64, + y: f64, + normalize: bool, + outline: bool, + fp: Vec, + sp: Vec, + svg: Option>, + fills: Vec<(FillPaint, BlendMix)>, + stroke: Option| PaintPrimitive::Path { + node_id, + x, + y, + width, + height, + normalize_to_origin: normalize, + text_outline: outline, + fill_paths: fp, + stroke_paths: sp, + svg_base64: svg, + fills, + stroke, + effects: merged_effects.clone(), + blend: primitive_blend.clone(), + rotation, + authored, + }; + + let own_start = self.out.len(); + match (class(&node.kind), &node.kind) { + // Text with exported glyph outlines: paint the outlines (the + // exporter's own shaping), at raw transform-chain coordinates. + (Class::Text, NodeKind::Text(text)) if has_native_geometry => { + let mut outline_fills = fills; + if outline_fills.is_empty() { + outline_fills = text_outline_fills(text, scene, content_opacity); + } + if stroke_paths.is_empty() + && !fill_paths.is_empty() + && outline_fills.len() == fill_paths.len() + { + for (d, fill) in fill_paths.into_iter().zip(outline_fills) { + self.out.push(path( + chain_x, + chain_y, + true, + true, + vec![d], + Vec::new(), + None, + vec![fill], + stroke.clone(), + )); + } + } else { + self.out.push(path( + chain_x, + chain_y, + true, + true, + fill_paths, + stroke_paths, + svg_base64, + outline_fills, + stroke, + )); + } + } + (Class::Text, NodeKind::Text(text)) => { + if !text.characters.is_empty() { + let color = fills + .first() + .and_then(|(f, _)| first_color(f)) + .unwrap_or(Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: content_opacity, + }); + self.out.push(PaintPrimitive::Text { + node_id, + x, + y, + text: text.characters.clone(), + color, + font_size: text.font_size.max(1.0), + line_height: text.line_height, + letter_spacing: text.letter_spacing, + text_case: text_case(text.text_case), + font: text_font(text), + style_runs: text_style_runs(text, content_opacity), + blend: primitive_blend.clone(), + width, + align: match text.align { + doc::TextAlign::Center => TextAlignment::Center, + doc::TextAlign::Right => TextAlignment::Right, + doc::TextAlign::Left | doc::TextAlign::Justified => TextAlignment::Left, + }, + effects: effects + .iter() + .filter(|e| match e.kind { + EffectKind::Glow => true, + EffectKind::DropShadow | EffectKind::InnerShadow => authored, + EffectKind::LayerBlur | EffectKind::BackgroundBlur => false, + }) + .cloned() + .collect(), + }); + } + } + // A detached instance (main component not in this scene) or a + // boolean with only an exporter snapshot: draw the snapshot whole. + _ if svg_base64.is_some() + && !has_native_geometry + && node.children.is_empty() + && (instance_component == Some(None) || is_boolean) => + { + atomic_svg = true; + self.out.push(path( + x, + y, + false, + false, + fill_paths, + stroke_paths, + svg_base64, + fills, + stroke, + )); + } + _ if instance_component.is_some() => {} + (Class::Shape, _) if has_vector_geometry => { + self.out.push(path( + x, + y, + false, + false, + fill_paths, + stroke_paths, + svg_base64, + fills, + stroke, + )); + } + _ => { + if width > 0.0 && height > 0.0 { + self.out.push(PaintPrimitive::Rect { + node_id, + x, + y, + width, + height, + fills, + stroke, + corner_radii: radii, + effects: merged_effects.clone(), + blend: primitive_blend, + rotation, + authored, + }); + } + } + } + + if self.plan.is_some() + && self.in_mask_def == 0 + && let Some(em) = &node.emission + && em.intensity > 0.0 + && effective_opacity > 0.0 + { + let color = Rgba { + a: 1.0, + ..rgba(&em.color, 1.0) + }; + let mut primitives: Vec = self.out[own_start..] + .iter() + .filter_map(|p| recolor(p, &color)) + .collect(); + if primitives.is_empty() && width > 0.0 && height > 0.0 { + primitives.push(PaintPrimitive::Rect { + node_id, + x, + y, + width, + height, + fills: vec![(FillPaint::Solid(color.clone()), BlendMix::Normal)], + stroke: None, + corner_radii: radii, + effects: Vec::new(), + blend: BlendMix::Normal, + rotation, + authored, + }); + } + if !primitives.is_empty() { + let world = self + .groups + .last() + .copied() + .unwrap_or_else(AffineTransform2D::identity); + let flicker = self.flicker.clone(); + if let Some(plan) = &mut self.plan { + plan.emissive.push(EmissiveDraw { + node_id, + primitives, + world, + clips: ancestor_clips, + color, + strength: em.intensity * effective_opacity, + radius: em.radius.max(0.0), + flicker, + }); + } + } + } + + if open_clip && authored { + self.open_clip(node_id, x, y, width, height, radii); + } + if !atomic_svg { + self.children(node, x, y, content_opacity, &child_blurs); + // An instance exported without its subtree: draw the main + // component's children in its place. + if node.children.is_empty() + && let Some(Some(component)) = instance_component + && let Some(main) = scene.node(component) + { + for c in &main.children { + self.node(*c, x, y, true, content_opacity, &child_blurs); + } + } + } + + if open_clip { + if self.plan.is_some() { + self.clips.pop(); + } + self.out.push(PaintPrimitive::ClipEnd { node_id }); + } + if needs_layer { + self.out.push(PaintPrimitive::LayerEnd { node_id }); + } + if group.is_some() { + self.out.push(PaintPrimitive::GroupEnd { node_id }); + self.groups.pop(); + } + + self.flicker = saved_flicker; + if shader_here { + self.in_shader = false; + if let (Some(fx), Some(plan)) = (node.shader.as_ref(), self.plan.as_mut()) + && self.out.len() > region_start + { + // Node-local box → world: the container's linear part (its + // group) or the leaf's own rotation, about its box origin. + let local = if group.is_some() { + let t = &node.transform; + AffineTransform2D { + a: t.a, + b: t.b, + tx: x, + c: t.c, + d: t.d, + ty: y, + } + } else { + let (sin, cos) = rotation.sin_cos(); + AffineTransform2D { + a: cos, + b: -sin, + tx: x, + c: sin, + d: cos, + ty: y, + } + }; + plan.shaders.push(ShaderRegion { + node_id, + range: region_start..self.out.len(), + world: outer_world * local, + width, + height, + shader: fx.shader.clone(), + params: fx.params.clone(), + control: nearest_control(scene, node_id), + inputs: fx.inputs.clone(), + opacity: effective_opacity, + }); + } + } + } + + fn open_clip( + &mut self, + node_id: NodeId, + x: f64, + y: f64, + width: f64, + height: f64, + corner_radii: doc::CornerRadii, + ) { + let clip = PaintPrimitive::ClipStart { + node_id, + x, + y, + width, + height, + corner_radii, + }; + if self.plan.is_some() { + let world = self + .groups + .last() + .copied() + .unwrap_or_else(AffineTransform2D::identity); + self.clips.push((world, clip.clone())); + } + self.out.push(clip); + } + + /// Children in order; a mask child masks the siblings after it. + fn children(&mut self, node: &doc::Node, x: f64, y: f64, opacity: f64, blurs: &[NodeEffect]) { + let mut active_mask: Option = None; + for &child_id in &node.children { + let Some(child) = self.scene.node(child_id) else { + continue; + }; + let supported_mask = matches!( + child.mask, + Some(doc::MaskKind::Alpha | doc::MaskKind::Vector) + ); + if supported_mask { + if let Some(m) = active_mask.take() { + self.out.push(PaintPrimitive::MaskApplyEnd { node_id: m }); + } + let (mut mx, mut my, mut mw, mut mh) = self.child_box(child_id, x, y); + if !is_translation(&child.transform) { + // A transformed mask covers its transformed box. + let g = group_transform(&child.transform, mx, my); + let pts = [(mx, my), (mx + mw, my), (mx, my + mh), (mx + mw, my + mh)] + .map(|(px, py)| g.transform_point(px, py)); + let (x0, y0) = pts + .iter() + .fold((f64::INFINITY, f64::INFINITY), |(a, b), p| { + (a.min(p.0), b.min(p.1)) + }); + let (x1, y1) = pts + .iter() + .fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b), p| { + (a.max(p.0), b.max(p.1)) + }); + (mx, my, mw, mh) = (x0, y0, x1 - x0, y1 - y0); + } + if mw > 0.0 && mh > 0.0 { + self.out.push(PaintPrimitive::MaskDefStart { + node_id: child_id, + x: mx, + y: my, + width: mw, + height: mh, + luminance: false, + }); + self.in_mask_def += 1; + self.node(child_id, x, y, false, opacity, blurs); + self.in_mask_def -= 1; + self.out + .push(PaintPrimitive::MaskDefEnd { node_id: child_id }); + self.out.push(PaintPrimitive::MaskApplyStart { + node_id: child_id, + x: mx, + y: my, + width: mw, + height: mh, + }); + active_mask = Some(child_id); + } + continue; + } + self.node(child_id, x, y, false, opacity, blurs); + } + if let Some(m) = active_mask { + self.out.push(PaintPrimitive::MaskApplyEnd { node_id: m }); + } + } +} + +/// Bounds of SVG path data, parsed once: asked for every authored node on +/// every frame (culling), and parsing the path again each time was most of +/// a still face's cost to render. +fn cached_path_bounds(path_data: &String) -> Option { + use kurbo::Shape; + use std::collections::HashMap; + use std::hash::{Hash, Hasher}; + use std::sync::{LazyLock, Mutex}; + + static CACHE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + path_data.hash(&mut hasher); + let key = hasher.finish(); + if let Ok(cache) = CACHE.lock() + && let Some(cached) = cache.get(&key) + { + return *cached; + } + let bounds = kurbo::BezPath::from_svg(path_data) + .ok() + .map(|p| p.bounding_box()); + if let Ok(mut cache) = CACHE.lock() { + cache.insert(key, bounds); + } + bounds +} diff --git a/crates/frame/src/render/anyrender_renderer/soft.rs b/crates/frame/src/render/anyrender_renderer/soft.rs new file mode 100644 index 00000000..2ffe7425 --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/soft.rs @@ -0,0 +1,432 @@ +//! Soft effects that follow an outline: drop shadows, glows, inner shadows +//! and blurs of arbitrary shapes (paths, ellipses, glyphs). +//! +//! vello blurs only rounded rectangles, so these are rasterised on the CPU — +//! an antialiased coverage mask of the shape, blurred with a separable +//! Gaussian — and drawn as an image in the shape's own space. Masks are +//! rasterised at the target's resolution (coarser for wide blurs, which +//! don't need the detail) and cached, so a redraw of an unchanged shape +//! costs one image draw. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, LazyLock, Mutex}; + +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape}; +use peniko::{ + Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality, ImageSampler, +}; + +use super::types::Rgba; + +/// Largest mask side, px; bigger shapes rasterise coarser. +const MAX_SIDE: f64 = 2048.0; +/// A blur wider than this (σ, mask px) rasterises coarser instead. +const MAX_SIGMA_PX: f64 = 6.0; +/// Cached masks kept before the cache is reset. +const CACHE_CAP: usize = 512; + +/// Which soft effect to draw. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) enum Soft { + /// The shape grown by `spread`, blurred, drawn offset by + /// `(dx, dy)` — a drop shadow, a glow (no offset) or a layer blur of a + /// solid shape (no offset, no spread). + Outer { dx: f64, dy: f64, spread: f64 }, + /// Inside the shape: the colour, minus a blurred copy of the shape + /// shrunk by `spread` and offset by `(dx, dy)`. + Inner { dx: f64, dy: f64, spread: f64 }, +} + +/// A rasterised, blurred, coloured mask and where it goes. +struct Baked { + brush: ImageBrush, + /// Shape-space position of the mask's top-left corner. + origin: Point, + /// Mask px per shape unit. + scale: f64, +} + +/// Paint `soft` for the union of `shapes` (filled, non-zero), in `colour`, +/// blurred by `radius` (a CSS/Figma blur radius, σ = radius / 2). +/// `transform` maps shape space to the target. +pub(super) fn paint( + scene: &mut S, + transform: Affine, + shapes: &[&BezPath], + colour: &Rgba, + radius: f64, + soft: Soft, +) { + if colour.a <= 0.0 || shapes.iter().all(|s| s.elements().is_empty()) { + return; + } + let c = transform.as_coeffs(); + let device_scale = (c[0] * c[3] - c[1] * c[2]).abs().sqrt(); + if !device_scale.is_finite() || device_scale < 1e-6 { + return; + } + let Some(baked) = bake(shapes, colour, radius.max(0.0), soft, device_scale) else { + return; + }; + let offset = match soft { + Soft::Outer { dx, dy, .. } => (dx, dy), + Soft::Inner { .. } => (0.0, 0.0), + }; + let at = transform + * Affine::translate((baked.origin.x + offset.0, baked.origin.y + offset.1)) + * Affine::scale(1.0 / baked.scale); + scene.draw_image(&baked.brush, at); +} + +fn bake( + shapes: &[&BezPath], + colour: &Rgba, + radius: f64, + soft: Soft, + device_scale: f64, +) -> Option> { + static CACHE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + + let sigma = radius * 0.5; + let key = { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in shapes { + hash_path(s, &mut h); + 0xfeed_u32.hash(&mut h); + } + for v in [colour.r, colour.g, colour.b, colour.a, radius, device_scale] { + v.to_bits().hash(&mut h); + } + match soft { + Soft::Outer { spread, .. } => (0_u8, spread.to_bits(), 0_u64, 0_u64).hash(&mut h), + Soft::Inner { dx, dy, spread } => { + (1_u8, spread.to_bits(), dx.to_bits(), dy.to_bits()).hash(&mut h) + } + } + h.finish() + }; + if let Some(hit) = CACHE.lock().ok().and_then(|c| c.get(&key).cloned()) { + return Some(hit); + } + + let bounds = shapes + .iter() + .map(|s| s.bounding_box()) + .reduce(|a, b| a.union(b))?; + if !(bounds.x0.is_finite() + && bounds.y0.is_finite() + && bounds.x1.is_finite() + && bounds.y1.is_finite()) + { + return None; + } + let (spread, margin) = match soft { + Soft::Outer { spread, .. } => (spread, 3.0 * sigma + spread.max(0.0)), + // The inner canvas covers the shape; the hole's blur reaches in + // from up to 3σ (plus the offset) outside it. + Soft::Inner { dx, dy, spread } => { + (spread, 3.0 * sigma + dx.abs().max(dy.abs()) + spread.abs()) + } + }; + let area = bounds.inflate(margin + 1.0, margin + 1.0); + let mut scale = device_scale; + if sigma * scale > MAX_SIGMA_PX { + scale = MAX_SIGMA_PX / sigma; + } + let side = area.width().max(area.height()) * scale; + if side > MAX_SIDE { + scale *= MAX_SIDE / side; + } + let (w, h) = ( + (area.width() * scale).ceil().max(1.0) as usize, + (area.height() * scale).ceil().max(1.0) as usize, + ); + let origin = Point::new(area.x0, area.y0); + let to_mask = + |dx: f64, dy: f64| Affine::scale(scale) * Affine::translate((dx - origin.x, dy - origin.y)); + + let alpha: Vec = match soft { + Soft::Outer { .. } => { + let mut m = grown(shapes, spread, to_mask(0.0, 0.0), w, h); + blur(&mut m, w, h, sigma * scale); + m + } + Soft::Inner { dx, dy, .. } => { + let inside = grown(shapes, 0.0, to_mask(0.0, 0.0), w, h); + let mut hole = grown(shapes, -spread, to_mask(dx, dy), w, h); + blur(&mut hole, w, h, sigma * scale); + inside + .iter() + .zip(&hole) + .map(|(a, b)| a * (1.0 - b)) + .collect() + } + }; + + let (r, g, b) = (to_u8(colour.r), to_u8(colour.g), to_u8(colour.b)); + let mut rgba = Vec::with_capacity(w * h * 4); + for a in alpha { + rgba.extend_from_slice(&[r, g, b, to_u8(f64::from(a) * colour.a)]); + } + let brush = ImageBrush { + image: ImageData { + data: Blob::new(Arc::new(rgba)), + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width: w as u32, + height: h as u32, + }, + sampler: ImageSampler { + x_extend: peniko::Extend::Pad, + y_extend: peniko::Extend::Pad, + quality: ImageQuality::Medium, + alpha: 1.0, + }, + }; + let baked = Arc::new(Baked { + brush, + origin, + scale, + }); + if let Ok(mut cache) = CACHE.lock() { + if cache.len() >= CACHE_CAP { + cache.clear(); + } + cache.insert(key, Arc::clone(&baked)); + } + Some(baked) +} + +fn to_u8(v: f64) -> u8 { + (v * 255.0).round().clamp(0.0, 255.0) as u8 +} + +fn hash_path(path: &BezPath, h: &mut impl Hasher) { + for el in path.elements() { + let pts: &[Point] = match el { + PathEl::MoveTo(p) => { + 0_u8.hash(h); + std::slice::from_ref(p) + } + PathEl::LineTo(p) => { + 1_u8.hash(h); + std::slice::from_ref(p) + } + PathEl::QuadTo(a, b) => { + 2_u8.hash(h); + a.x.to_bits().hash(h); + a.y.to_bits().hash(h); + std::slice::from_ref(b) + } + PathEl::CurveTo(a, b, c) => { + 3_u8.hash(h); + for p in [a, b] { + p.x.to_bits().hash(h); + p.y.to_bits().hash(h); + } + std::slice::from_ref(c) + } + PathEl::ClosePath => { + 4_u8.hash(h); + &[] + } + }; + for p in pts { + p.x.to_bits().hash(h); + p.y.to_bits().hash(h); + } + } +} + +/// Coverage of the union of `shapes` grown by `spread` (shrunk when +/// negative), through `to_mask`, on a `w × h` mask. +fn grown(shapes: &[&BezPath], spread: f64, to_mask: Affine, w: usize, h: usize) -> Vec { + let scale = to_mask.as_coeffs()[0].abs().max(1e-9); + let tolerance = 0.1 / scale; + let mut out = vec![0.0_f32; w * h]; + for shape in shapes { + let mut m = coverage(shape, to_mask, w, h); + if spread.abs() * scale > 1e-3 { + let style = kurbo::Stroke::new(2.0 * spread.abs()) + .with_join(kurbo::Join::Round) + .with_caps(kurbo::Cap::Round); + let edge = kurbo::stroke( + shape.elements().iter().copied(), + &style, + &kurbo::StrokeOpts::default(), + tolerance, + ); + let e = coverage(&edge, to_mask, w, h); + if spread > 0.0 { + m.iter_mut().zip(&e).for_each(|(a, b)| *a = a.max(*b)); + } else { + m.iter_mut().zip(&e).for_each(|(a, b)| *a *= 1.0 - b); + } + } + out.iter_mut().zip(&m).for_each(|(a, b)| *a = a.max(*b)); + } + out +} + +/// Antialiased non-zero coverage of `path` (through `to_mask`) on a +/// `w × h` mask: signed-area accumulation, one row at a time. +pub(super) fn coverage(path: &BezPath, to_mask: Affine, w: usize, h: usize) -> Vec { + let stride = w + 2; + let mut acc = vec![0.0_f32; stride * h]; + let mut start = Point::ZERO; + let mut last = Point::ZERO; + let mut line = |a: Point, b: Point| accumulate_line(&mut acc, stride, w, h, a, b); + kurbo::flatten( + path.elements().iter().map(|el| to_mask * *el), + 0.05, + |el| match el { + PathEl::MoveTo(p) => { + if last != start { + line(last, start); + } + start = p; + last = p; + } + PathEl::LineTo(p) => { + line(last, p); + last = p; + } + PathEl::ClosePath => { + line(last, start); + last = start; + } + _ => {} + }, + ); + if last != start { + line(last, start); + } + let mut out = vec![0.0_f32; w * h]; + for y in 0..h { + let mut sum = 0.0_f32; + for x in 0..w { + sum += acc[y * stride + x]; + out[y * w + x] = sum.abs().min(1.0); + } + } + out +} + +/// Add the signed area a line contributes to each cell right of it +/// (font-rs's accumulation rasteriser, clipped to the mask). +fn accumulate_line(acc: &mut [f32], stride: usize, w: usize, h: usize, p0: Point, p1: Point) { + let clamp_x = |x: f64| x.clamp(0.0, w as f64) as f32; + let (x0, y0, x1, y1) = (clamp_x(p0.x), p0.y as f32, clamp_x(p1.x), p1.y as f32); + if (y0 - y1).abs() <= f32::EPSILON { + return; + } + let (dir, x0, y0, x1, y1) = if y0 < y1 { + (1.0, x0, y0, x1, y1) + } else { + (-1.0, x1, y1, x0, y0) + }; + let dxdy = (x1 - x0) / (y1 - y0); + let top = y0.max(0.0); + let mut x = x0 + (top - y0) * dxdy; + let first = top as usize; + let last = (y1.min(h as f32)).ceil().max(0.0) as usize; + for y in first..last.min(h) { + let row = y * stride; + let dy = ((y + 1) as f32).min(y1) - (y as f32).max(y0); + if dy <= 0.0 { + continue; + } + let xnext = x + dxdy * dy; + let d = dy * dir; + let (xa, xb) = if x < xnext { (x, xnext) } else { (xnext, x) }; + let xa_floor = xa.floor(); + let xa_i = xa_floor as usize; + let xb_ceil = xb.ceil(); + let xb_i = xb_ceil as usize; + if xb_i <= xa_i + 1 { + let xmf = 0.5 * (x + xnext) - xa_floor; + acc[row + xa_i] += d - d * xmf; + acc[row + xa_i + 1] += d * xmf; + } else { + let s = (xb - xa).recip(); + let xa_f = xa - xa_floor; + let a0 = 0.5 * s * (1.0 - xa_f) * (1.0 - xa_f); + let xb_f = xb - xb_ceil + 1.0; + let am = 0.5 * s * xb_f * xb_f; + acc[row + xa_i] += d * a0; + if xb_i == xa_i + 2 { + acc[row + xa_i + 1] += d * (1.0 - a0 - am); + } else { + let a1 = s * (1.5 - xa_f); + acc[row + xa_i + 1] += d * (a1 - a0); + for xi in xa_i + 2..xb_i - 1 { + acc[row + xi] += d * s; + } + let a2 = a1 + (xb_i - xa_i - 3) as f32 * s; + acc[row + xb_i - 1] += d * (1.0 - a2 - am); + } + acc[row + xb_i] += d * am; + } + x = xnext; + } +} + +/// Separable Gaussian blur (σ in mask px), zero outside the mask. +fn blur(m: &mut [f32], w: usize, h: usize, sigma: f64) { + if sigma < 0.2 { + return; + } + let r = (3.0 * sigma).ceil() as usize; + let kernel: Vec = { + let k: Vec = (0..=2 * r) + .map(|i| (-0.5 * ((i as f64 - r as f64) / sigma).powi(2)).exp()) + .collect(); + let sum: f64 = k.iter().sum(); + k.iter().map(|v| (v / sum) as f32).collect() + }; + let mut tmp = vec![0.0_f32; w * h]; + // Rows. + for y in 0..h { + let src = &m[y * w..(y + 1) * w]; + for x in 0..w { + let lo = x.saturating_sub(r); + let hi = (x + r).min(w - 1); + let mut s = 0.0; + for (sx, v) in src.iter().enumerate().take(hi + 1).skip(lo) { + s += v * kernel[sx + r - x]; + } + tmp[y * w + x] = s; + } + } + // Columns. + for x in 0..w { + for y in 0..h { + let lo = y.saturating_sub(r); + let hi = (y + r).min(h - 1); + let mut s = 0.0; + for sy in lo..=hi { + s += tmp[sy * w + x] * kernel[sy + r - y]; + } + m[y * w + x] = s; + } + } +} + +/// The rounded rectangle `rect` with per-corner `radii` as a path. +pub(super) fn rounded_rect_path( + rect: Rect, + radii: &crate::document::proto::CornerRadii, +) -> BezPath { + kurbo::RoundedRect::from_rect( + rect, + kurbo::RoundedRectRadii::new( + radii.top_left, + radii.top_right, + radii.bottom_right, + radii.bottom_left, + ), + ) + .to_path(0.1) +} diff --git a/crates/frame/src/render/anyrender_renderer/subpaths.rs b/crates/frame/src/render/anyrender_renderer/subpaths.rs new file mode 100644 index 00000000..2e9065b2 --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/subpaths.rs @@ -0,0 +1,38 @@ +//! Open and closed subpaths: an authored stroke strokes open subpaths as +//! lines and never fills them. + +/// `path`'s closed subpaths and its open ones, as two paths. +pub(crate) fn split_open_closed(path: &kurbo::BezPath) -> (kurbo::BezPath, kurbo::BezPath) { + use kurbo::PathEl; + let (mut closed, mut open) = (kurbo::BezPath::new(), kurbo::BezPath::new()); + let mut sub: Vec = Vec::new(); + let mut flush = |sub: &mut Vec| { + // A lone move-to draws nothing either way. + if sub.len() > 1 { + let target = if matches!(sub.last(), Some(PathEl::ClosePath)) { + &mut closed + } else { + &mut open + }; + sub.iter().for_each(|el| target.push(*el)); + } + sub.clear(); + }; + for el in path.elements() { + if matches!(el, PathEl::MoveTo(_)) { + flush(&mut sub); + } + sub.push(*el); + if matches!(el, PathEl::ClosePath) { + flush(&mut sub); + } + } + flush(&mut sub); + (closed, open) +} + +/// The closed subpaths of SVG path data `d` (`None` when it doesn't parse). +pub(crate) fn closed_subpaths_svg(d: &str) -> Option { + let path = kurbo::BezPath::from_svg(d).ok()?; + Some(split_open_closed(&path).0.to_svg()) +} diff --git a/crates/frame/src/render/anyrender_renderer/target.rs b/crates/frame/src/render/anyrender_renderer/target.rs new file mode 100644 index 00000000..1737d00a --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/target.rs @@ -0,0 +1,193 @@ +//! What frame's painter paints into. +//! +//! [`paint_primitives_into_scene_with`](super::paint_primitives_into_scene_with) +//! is written against [`PaintTarget`], not a renderer: the operations a +//! surface is drawn with — fills, strokes, layers and clips, blurred rects, +//! glyph runs — named and shaped as `vello::Scene`'s, so one painter serves +//! every output. +//! +//! - `vello::Scene` (always): frame's own renderer and the effects stage. +//! - A host's anyrender 0.11 scene (frame-live's `vectors`, feature +//! `anyrender-0-11`): Blitz's, drawn by whatever renderer the host runs — +//! vello-hybrid on the iOS simulator, which has no indirect execution for +//! Vello's compute stages. + +use kurbo::{Affine, Rect, Shape, Stroke, Vec2}; +use peniko::{BlendMode, BrushRef, Color, Fill, FontData}; + +/// One run of glyphs, in one face, size and brush. +pub struct GlyphRun<'a> { + pub font: &'a FontData, + pub font_size: f32, + pub normalized_coords: &'a [i16], + pub brush: BrushRef<'a>, + pub brush_alpha: f32, + pub transform: Affine, + pub glyph_transform: Option, + /// Synthetic bold: how far outlines grow, x and y (zero for none). + pub embolden: Vec2, + pub glyphs: &'a [vello::Glyph], +} + +/// The drawing operations frame's painter uses (see the module docs). +pub trait PaintTarget { + /// Called once before a surface is painted (vello's atlas workaround). + fn begin(&mut self) {} + + fn fill<'b>( + &mut self, + style: Fill, + transform: Affine, + brush: impl Into>, + brush_transform: Option, + shape: &impl Shape, + ); + + fn stroke<'b>( + &mut self, + style: &Stroke, + transform: Affine, + brush: impl Into>, + brush_transform: Option, + shape: &impl Shape, + ); + + fn push_layer( + &mut self, + clip_style: Fill, + blend: impl Into, + alpha: f32, + transform: Affine, + clip: &impl Shape, + ); + + fn push_clip_layer(&mut self, clip_style: Fill, transform: Affine, clip: &impl Shape); + + fn push_luminance_mask_layer( + &mut self, + clip_style: Fill, + alpha: f32, + transform: Affine, + clip: &impl Shape, + ); + + fn pop_layer(&mut self); + + fn draw_blurred_rounded_rect( + &mut self, + transform: Affine, + rect: Rect, + brush: Color, + radius: f64, + std_dev: f64, + ); + + fn draw_glyph_run(&mut self, run: &GlyphRun<'_>); + + /// Draw `image` at its own size under `transform`. + fn draw_image(&mut self, image: &peniko::ImageBrush, transform: Affine); + + /// Draw an SVG document (an image node's vector content) under + /// `transform`. Whether it parsed and drew. + fn draw_svg(&mut self, svg_text: &str, transform: Affine) -> bool; +} + +impl PaintTarget for vello::Scene { + fn begin(&mut self) { + super::paint::keep_image_atlas(self); + } + + fn fill<'b>( + &mut self, + style: Fill, + transform: Affine, + brush: impl Into>, + brush_transform: Option, + shape: &impl Shape, + ) { + vello::Scene::fill(self, style, transform, brush, brush_transform, shape); + } + + fn stroke<'b>( + &mut self, + style: &Stroke, + transform: Affine, + brush: impl Into>, + brush_transform: Option, + shape: &impl Shape, + ) { + vello::Scene::stroke(self, style, transform, brush, brush_transform, shape); + } + + fn push_layer( + &mut self, + clip_style: Fill, + blend: impl Into, + alpha: f32, + transform: Affine, + clip: &impl Shape, + ) { + vello::Scene::push_layer(self, clip_style, blend, alpha, transform, clip); + } + + fn push_clip_layer(&mut self, clip_style: Fill, transform: Affine, clip: &impl Shape) { + vello::Scene::push_clip_layer(self, clip_style, transform, clip); + } + + fn push_luminance_mask_layer( + &mut self, + clip_style: Fill, + alpha: f32, + transform: Affine, + clip: &impl Shape, + ) { + vello::Scene::push_luminance_mask_layer(self, clip_style, alpha, transform, clip); + } + + fn pop_layer(&mut self) { + vello::Scene::pop_layer(self); + } + + fn draw_blurred_rounded_rect( + &mut self, + transform: Affine, + rect: Rect, + brush: Color, + radius: f64, + std_dev: f64, + ) { + vello::Scene::draw_blurred_rounded_rect(self, transform, rect, brush, radius, std_dev); + } + + fn draw_glyph_run(&mut self, run: &GlyphRun<'_>) { + let mut draw = self + .draw_glyphs(run.font) + .font_size(run.font_size) + .hint(false) + .normalized_coords(run.normalized_coords) + .brush(run.brush) + .brush_alpha(run.brush_alpha) + .transform(run.transform) + .glyph_transform(run.glyph_transform); + if run.embolden != Vec2::ZERO { + draw = draw.font_embolden(vello::FontEmbolden::new(kurbo::Diagonal2::new( + run.embolden.x, + run.embolden.y, + ))); + } + draw.draw(Fill::NonZero, run.glyphs.iter().copied()); + } + + fn draw_image(&mut self, image: &peniko::ImageBrush, transform: Affine) { + vello::Scene::draw_image(self, image, transform); + } + + fn draw_svg(&mut self, svg_text: &str, transform: Affine) -> bool { + let Some(tree) = super::cache::cached_svg_tree(svg_text) else { + return false; + }; + let mut painter = anyrender_vello::VelloScenePainter::new(self); + anyrender_svg::render_svg_tree(&mut painter, &tree, transform); + true + } +} diff --git a/crates/frame/src/render/anyrender_renderer/transform.rs b/crates/frame/src/render/anyrender_renderer/transform.rs new file mode 100644 index 00000000..834f4a21 --- /dev/null +++ b/crates/frame/src/render/anyrender_renderer/transform.rs @@ -0,0 +1,183 @@ +/// A 2D affine transform in Figma row-major format. +/// +/// Figma stores transforms as `[[a, b, tx], [c, d, ty]]` where: +/// - Row 0: `[a, b, tx]` → `x' = a*x + b*y + tx` +/// - Row 1: `[c, d, ty]` → `y' = c*x + d*y + ty` +/// +/// Note: SVG's `matrix(a,b,c,d,e,f)` uses column-major convention — to emit +/// an SVG matrix string use [`AffineTransform2D::to_svg_matrix`] or the +/// [`std::fmt::Display`] impl, which reorders fields to `(a, c, b, d, tx, ty)`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AffineTransform2D { + /// Row 0, col 0: x-scale / cos(θ) + pub a: f64, + /// Row 0, col 1: -sin(θ) for pure rotation + pub b: f64, + /// Row 0, col 2: x-translation + pub tx: f64, + /// Row 1, col 0: sin(θ) for pure rotation + pub c: f64, + /// Row 1, col 1: y-scale / cos(θ) + pub d: f64, + /// Row 1, col 2: y-translation + pub ty: f64, +} + +impl AffineTransform2D { + /// The identity transform. + pub fn identity() -> Self { + Self { + a: 1.0, + b: 0.0, + tx: 0.0, + c: 0.0, + d: 1.0, + ty: 0.0, + } + } + + /// A pure rotation by `degrees` (counter-clockwise). + pub fn rotation(degrees: f64) -> Self { + let r = degrees.to_radians(); + let (sin_r, cos_r) = r.sin_cos(); + Self { + a: cos_r, + b: -sin_r, + tx: 0.0, + c: sin_r, + d: cos_r, + ty: 0.0, + } + } + + /// A pure translation. + pub fn translation(tx: f64, ty: f64) -> Self { + Self { + a: 1.0, + b: 0.0, + tx, + c: 0.0, + d: 1.0, + ty, + } + } + + /// A non-uniform scale. + pub fn scale(sx: f64, sy: f64) -> Self { + Self { + a: sx, + b: 0.0, + tx: 0.0, + c: 0.0, + d: sy, + ty: 0.0, + } + } + + /// Apply this transform to point `(u, v)` and return the transformed point. + /// + /// `x' = a*u + b*v + tx`, `y' = c*u + d*v + ty` + pub fn transform_point(&self, u: f64, v: f64) -> (f64, f64) { + ( + self.a * u + self.b * v + self.tx, + self.c * u + self.d * v + self.ty, + ) + } + + /// Determinant of the 2×2 linear part: `a*d - b*c`. + pub fn determinant(&self) -> f64 { + self.a * self.d - self.b * self.c + } + + /// Returns `true` if this is (approximately) the identity transform. + pub fn is_identity(&self) -> bool { + (self.a - 1.0).abs() <= f64::EPSILON + && self.b.abs() <= f64::EPSILON + && self.tx.abs() <= f64::EPSILON + && self.c.abs() <= f64::EPSILON + && (self.d - 1.0).abs() <= f64::EPSILON + && self.ty.abs() <= f64::EPSILON + } + + /// Extract the rotation angle in radians from the first column of the + /// linear part: `atan2(c, a)`. + /// + /// For a pure rotation matrix `[[cos θ, -sin θ], [sin θ, cos θ]]` this + /// recovers θ exactly. In the presence of scale the result is the rotation + /// of the x-axis. + pub fn decompose_rotation(&self) -> f64 { + self.c.atan2(self.a) + } + + /// Return the inverse transform, or `None` if the matrix is singular + /// (determinant ≈ 0). + pub fn inverse(&self) -> Option { + let det = self.determinant(); + if det.abs() < 1e-12 { + return None; + } + Some(Self { + a: self.d / det, + b: -self.b / det, + tx: (self.b * self.ty - self.d * self.tx) / det, + c: -self.c / det, + d: self.a / det, + ty: (self.c * self.tx - self.a * self.ty) / det, + }) + } + + /// Return the SVG `matrix()` coefficients in column-major order: + /// `(a, c, b, d, tx, ty)`. + /// + /// SVG `matrix(a,b,c,d,e,f)` encodes `x' = a*x + c*y + e` and + /// `y' = b*x + d*y + f`, so the column-major reordering is required. + pub fn to_svg_matrix_coeffs(&self) -> (f64, f64, f64, f64, f64, f64) { + (self.a, self.c, self.b, self.d, self.tx, self.ty) + } +} + +/// Compose two transforms: `self` applied *after* `rhs`. +/// +/// `(self * rhs)(v) = self(rhs(v))` +impl std::ops::Mul for AffineTransform2D { + type Output = Self; + + fn mul(self, rhs: Self) -> Self { + Self { + a: self.a * rhs.a + self.b * rhs.c, + b: self.a * rhs.b + self.b * rhs.d, + tx: self.a * rhs.tx + self.b * rhs.ty + self.tx, + c: self.c * rhs.a + self.d * rhs.c, + d: self.c * rhs.b + self.d * rhs.d, + ty: self.c * rhs.tx + self.d * rhs.ty + self.ty, + } + } +} + +/// Format as an SVG `matrix(a,b,c,d,e,f)` string using column-major order. +impl std::fmt::Display for AffineTransform2D { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (a, b, c, d, e, ff) = self.to_svg_matrix_coeffs(); + write!(f, "matrix({a} {b} {c} {d} {e} {ff})") + } +} + +/// Parse from Figma's row-major `[[a, b, tx], [c, d, ty]]` representation. +impl From<[[f64; 3]; 2]> for AffineTransform2D { + fn from(t: [[f64; 3]; 2]) -> Self { + Self { + a: t[0][0], + b: t[0][1], + tx: t[0][2], + c: t[1][0], + d: t[1][1], + ty: t[1][2], + } + } +} + +impl From for [[f64; 3]; 2] { + fn from(t: AffineTransform2D) -> Self { + [[t.a, t.b, t.tx], [t.c, t.d, t.ty]] + } +} diff --git a/crates/frame-ui/src/anyrender_renderer/types.rs b/crates/frame/src/render/anyrender_renderer/types.rs similarity index 61% rename from crates/frame-ui/src/anyrender_renderer/types.rs rename to crates/frame/src/render/anyrender_renderer/types.rs index 24e2885e..45a8bbd3 100644 --- a/crates/frame-ui/src/anyrender_renderer/types.rs +++ b/crates/frame/src/render/anyrender_renderer/types.rs @@ -1,5 +1,5 @@ -use figma_api::models; -use frame_proto::NodeId; +use crate::document::proto::NodeId; +pub use crate::render::font_book::FontSpec; pub use super::transform::AffineTransform2D; @@ -45,7 +45,10 @@ pub enum FillPaint { }, Image { image_hash: Option, - data_base64: String, + /// The image file, base64-encoded — shared: encoded once per asset + /// ([`asset_base64`](super::scene_collect::asset_base64)), so a + /// collect clones a pointer, and painters cache their decode by it. + data_base64: std::sync::Arc, alpha: f64, scale_mode: ImageScaleMode, image_transform: Option, @@ -119,6 +122,10 @@ pub enum EffectKind { InnerShadow, LayerBlur, BackgroundBlur, + /// Light a node gives off (its `emission`), drawn without the GPU effects + /// stage: a blurred halo in the emission colour behind the node, like a + /// drop shadow with no offset. + Glow, } #[derive(Debug, Clone, PartialEq)] @@ -131,13 +138,38 @@ pub struct NodeEffect { pub spread: f64, } -pub type StrokeCap = models::has_geometry_trait::StrokeCap; -pub type StrokeJoin = models::has_geometry_trait::StrokeJoin; -pub type StrokeAlign = models::has_geometry_trait::StrokeAlign; +pub use crate::document::proto::{StrokeAlign, StrokeCap, StrokeJoin}; + +/// The box a gradient stroke is laid across: the node's `(x, y, w, h)`, +/// with a side of zero length (a horizontal or vertical line) widened to +/// the stroke's width about the line, so the gradient still has an extent. +#[must_use] +pub fn stroke_gradient_box( + s: &StrokeStyle, + x: f64, + y: f64, + w: f64, + h: f64, +) -> (f64, f64, f64, f64) { + let grow = |at: f64, len: f64| { + if len > 1e-9 { + (at, len) + } else { + (at - s.width * 0.5, s.width.max(1e-6)) + } + }; + let ((x, w), (y, h)) = (grow(x, w), grow(y, h)); + (x, y, w, h) +} #[derive(Debug, Clone, PartialEq)] pub struct StrokeStyle { + /// The stroke's colour; for a gradient stroke its first stop (what + /// effects and blurs that need one colour use). pub color: Rgba, + /// A gradient paint (linear, radial or angular) laid across the node's + /// box as a fill's would be; `None` for a solid stroke. + pub gradient: Option, pub width: f64, pub cap: StrokeCap, pub join: StrokeJoin, @@ -177,6 +209,14 @@ pub enum TextCase { SmallCapsForced, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TextAlignment { + #[default] + Left, + Center, + Right, +} + #[derive(Debug, Clone, PartialEq)] pub struct TextStyleRun { pub text: String, @@ -185,10 +225,46 @@ pub struct TextStyleRun { pub letter_spacing: Option, pub font_style: Option, pub font_weight: Option, + pub font_family: Option, + pub font_width: Option, } +impl TextStyleRun { + /// The run's own font settings (unset fields inherit the node's). + #[must_use] + pub fn font(&self) -> FontSpec { + FontSpec { + family: self.font_family.clone(), + weight: self.font_weight, + style: self.font_style.clone(), + width: self.font_width, + } + } +} + +/// Paint primitives, in paint order. +/// +/// Coordinates are in *flattened scene space*: a child sits at its parent's +/// box origin plus its own `tx`/`ty`, in the parent's unrotated frame. A +/// container whose transform has a non-translation part (rotation, scale, +/// skew, flip) wraps its own primitives and its whole subtree in a +/// [`PaintPrimitive::GroupStart`]/[`PaintPrimitive::GroupEnd`] pair; painters +/// apply the group's matrix on top of everything between them, so children +/// inherit the parent's transform (world = parent world ∘ local). Scenes +/// without such containers emit no groups, so their primitives are exactly +/// what they were before groups existed. #[derive(Debug, Clone, PartialEq)] pub enum PaintPrimitive { + /// Apply `transform` (flattened scene space → flattened scene space, + /// pivot included) to every primitive until the matching `GroupEnd`, + /// composing with any enclosing group (`outer * transform`). + GroupStart { + node_id: NodeId, + transform: AffineTransform2D, + }, + GroupEnd { + node_id: NodeId, + }, LayerStart { node_id: NodeId, x: f64, @@ -207,7 +283,7 @@ pub enum PaintPrimitive { y: f64, width: f64, height: f64, - corner_radii: frame_proto::CornerRadii, + corner_radii: crate::document::proto::CornerRadii, }, ClipEnd { node_id: NodeId, @@ -241,10 +317,14 @@ pub enum PaintPrimitive { height: f64, fills: Vec<(FillPaint, BlendMix)>, stroke: Option, - corner_radii: frame_proto::CornerRadii, + corner_radii: crate::document::proto::CornerRadii, effects: Vec, blend: BlendMix, rotation: f64, + /// Drawn as authored rather than as a Figma import (see `authored` + /// on [`PaintPrimitive::Path`]): aligned strokes are exact and + /// shadows follow non-uniform corners. + authored: bool, }, Text { node_id: NodeId, @@ -256,8 +336,18 @@ pub enum PaintPrimitive { line_height: Option, letter_spacing: f64, text_case: TextCase, + /// The node's family, weight, style and width; runs override it. + font: FontSpec, style_runs: Vec, blend: BlendMix, + /// The text box width, for alignment. + width: f64, + align: TextAlignment, + /// Shadows and glows that follow the glyph outlines (drop shadows, + /// inner shadows and the emission [`EffectKind::Glow`]). Figma + /// imports draw text effects from their exported outlines instead, + /// so theirs carry only a glow. + effects: Vec, }, Path { node_id: NodeId, @@ -269,12 +359,23 @@ pub enum PaintPrimitive { text_outline: bool, fill_paths: Vec, stroke_paths: Vec, - svg_base64: Option, + /// The node's SVG snapshot, base64-encoded (shared, like image data). + svg_base64: Option>, fills: Vec<(FillPaint, BlendMix)>, stroke: Option, effects: Vec, blend: BlendMix, rotation: f64, + /// The node was authored in frame (markup, the surface kit, code) + /// rather than imported from Figma, so its geometry is taken + /// literally: path data is drawn in node-local coordinates as written + /// (never fitted into the node's box), a stroke without + /// `stroke_paths` strokes the fill geometry (open subpaths are + /// stroked, not filled), shadows and blurs follow the outline, and + /// aligned strokes are exact. Figma imports (`false`) keep the + /// exporter-compatible behaviour the parity corpus is measured + /// against. + authored: bool, }, } @@ -321,10 +422,3 @@ impl RenderDiagnostics { ) } } - -#[cfg(feature = "anyrender")] -#[derive(Debug, Clone, Copy)] -pub struct TextFontRef<'a> { - pub bytes: &'a [u8], - pub index: u32, -} diff --git a/crates/frame/src/render/canvas/canvas.rs b/crates/frame/src/render/canvas/canvas.rs new file mode 100644 index 00000000..46fc33a9 --- /dev/null +++ b/crates/frame/src/render/canvas/canvas.rs @@ -0,0 +1,393 @@ +//! The painting seam: what frame paints *into*. +//! +//! A host has its own scene type — session records into an anyrender 0.11 +//! `Scene` and replays it under a transform; REAPER hands over a LICE +//! bitmap; a test wants pixels. [`Canvas`] is the handful of calls all of +//! them share (anyrender's `PaintScene` shape, in kurbo 0.13 / peniko 0.6, +//! which every one of those hosts already links), so frame paints its +//! primitives straight into the host's scene rather than into a bitmap the +//! host then has to composite. +//! +//! [`Recording`] is frame's own replayable list of those calls: record once +//! when params change, replay every frame under whatever transform the +//! host's scroll and zoom put the strip at — the same model session's mixer +//! uses for everything else it draws. + +use kurbo::{Affine, BezPath, Rect, Shape, Stroke}; +use peniko::{Brush, Color, Fill}; + +/// A 2-D painter. Coordinates are the embed's own logical px; `transform` +/// maps them to the host's space. +pub trait Canvas { + /// Fill `shape` with `brush`. `brush_transform` maps the brush's space + /// to the shape's (a gradient laid out in a unit square, say). + fn fill( + &mut self, + transform: Affine, + rule: Fill, + brush: &Brush, + brush_transform: Option, + shape: &BezPath, + ); + fn stroke(&mut self, transform: Affine, style: &Stroke, brush: &Brush, shape: &BezPath); + /// Clip everything until the matching [`Canvas::pop`] to `shape`. + fn push_clip(&mut self, transform: Affine, shape: &BezPath); + /// Composite everything until the matching [`Canvas::pop`] at `alpha`, + /// clipped to `shape`. + fn push_opacity(&mut self, transform: Affine, alpha: f32, shape: &BezPath); + fn pop(&mut self); + + /// Whether this canvas draws text itself ([`Canvas::text`]) — a host + /// with its own fonts and shaping, so text in a theme looks like the + /// host's text rather than frame's outlines of it. + fn handles_text(&self) -> bool { + false + } + + /// Draw one line of text the host's way. False: not handled, and the + /// caller draws its outlines instead. + fn text(&mut self, transform: Affine, run: &TextRun) -> bool { + let _ = (transform, run); + false + } + + /// Draw `run`, or `fallback` (its outlines, already under `transform` + /// … `fallback_at` is what to replay them under) when the canvas does + /// not draw text. A [`Recording`] keeps both, so a host that draws text + /// gets the run back when the recording is replayed into it. + fn text_with_fallback( + &mut self, + transform: Affine, + run: &TextRun, + fallback: &Recording, + fallback_at: Affine, + ) { + if !self.text(transform, run) { + fallback.replay(self, fallback_at); + } + } +} + +/// One line of text, as a host that draws its own text needs it. +#[derive(Debug, Clone, PartialEq)] +pub struct TextRun { + pub text: String, + /// The box's left edge and top, in the canvas transform's space: a + /// host puts the baseline its own ascent below `top`. + pub left: f64, + pub top: f64, + /// The box's width, which `align` places the line within. + pub width: f64, + pub align: TextAlign, + pub size: f64, + pub weight: Option, + pub family: Option, + pub letter_spacing: f64, + pub color: Color, +} + +/// Where a line sits in its box. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextAlign { + Left, + Center, + Right, +} + +/// Convenience calls on any [`Canvas`]. +pub trait CanvasExt: Canvas { + fn fill_color(&mut self, transform: Affine, color: Color, shape: &impl Shape) { + self.fill( + transform, + Fill::NonZero, + &Brush::Solid(color), + None, + &shape.to_path(0.1), + ); + } + + fn stroke_color(&mut self, transform: Affine, color: Color, width: f64, shape: &impl Shape) { + self.stroke( + transform, + &Stroke::new(width), + &Brush::Solid(color), + &shape.to_path(0.1), + ); + } + + fn fill_rect(&mut self, transform: Affine, color: Color, rect: Rect) { + self.fill_color(transform, color, &rect); + } +} + +impl CanvasExt for C {} + +/// One recorded call. +#[derive(Debug, Clone, PartialEq)] +pub enum Cmd { + Fill { + transform: Affine, + rule: Fill, + brush: Brush, + brush_transform: Option, + shape: BezPath, + }, + Stroke { + transform: Affine, + style: Stroke, + brush: Brush, + shape: BezPath, + }, + PushClip { + transform: Affine, + shape: BezPath, + }, + PushOpacity { + transform: Affine, + alpha: f32, + shape: BezPath, + }, + Pop, + /// A line of text, and its outlines for a canvas that does not draw + /// text. + Text { + transform: Affine, + run: TextRun, + fallback: Recording, + }, +} + +/// A replayable list of canvas calls. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Recording { + pub cmds: Vec, +} + +impl Recording { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.cmds.clear(); + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.cmds.is_empty() + } + + #[must_use] + pub fn len(&self) -> usize { + self.cmds.len() + } + + /// Replay into `canvas` with `transform` applied on top of each call's + /// own (host space ← recording space). + pub fn replay(&self, canvas: &mut (impl Canvas + ?Sized), transform: Affine) { + for cmd in &self.cmds { + match cmd { + Cmd::Fill { + transform: t, + rule, + brush, + brush_transform, + shape, + } => canvas.fill(transform * *t, *rule, brush, *brush_transform, shape), + Cmd::Stroke { + transform: t, + style, + brush, + shape, + } => canvas.stroke(transform * *t, style, brush, shape), + Cmd::PushClip { + transform: t, + shape, + } => canvas.push_clip(transform * *t, shape), + Cmd::PushOpacity { + transform: t, + alpha, + shape, + } => canvas.push_opacity(transform * *t, *alpha, shape), + Cmd::Pop => canvas.pop(), + Cmd::Text { + transform: t, + run, + fallback, + } => { + canvas.text_with_fallback(transform * *t, run, fallback, transform); + } + } + } + } + + /// The bounding box of everything drawn, in recording space. + #[must_use] + pub fn bounds(&self) -> Option { + self.cmds + .iter() + .filter_map(|c| match c { + Cmd::Fill { + transform, shape, .. + } => Some(transform.transform_rect_bbox(shape.bounding_box())), + Cmd::Stroke { + transform, + style, + shape, + .. + } => Some( + transform.transform_rect_bbox( + shape + .bounding_box() + .inflate(style.width / 2.0, style.width / 2.0), + ), + ), + Cmd::Text { fallback, .. } => fallback.bounds(), + _ => None, + }) + .reduce(|a, b| a.union(b)) + } +} + +impl Canvas for Recording { + fn fill( + &mut self, + transform: Affine, + rule: Fill, + brush: &Brush, + brush_transform: Option, + shape: &BezPath, + ) { + self.cmds.push(Cmd::Fill { + transform, + rule, + brush: brush.clone(), + brush_transform, + shape: shape.clone(), + }); + } + + fn stroke(&mut self, transform: Affine, style: &Stroke, brush: &Brush, shape: &BezPath) { + self.cmds.push(Cmd::Stroke { + transform, + style: style.clone(), + brush: brush.clone(), + shape: shape.clone(), + }); + } + + fn push_clip(&mut self, transform: Affine, shape: &BezPath) { + self.cmds.push(Cmd::PushClip { + transform, + shape: shape.clone(), + }); + } + + fn push_opacity(&mut self, transform: Affine, alpha: f32, shape: &BezPath) { + self.cmds.push(Cmd::PushOpacity { + transform, + alpha, + shape: shape.clone(), + }); + } + + fn pop(&mut self) { + self.cmds.push(Cmd::Pop); + } + + fn text_with_fallback( + &mut self, + transform: Affine, + run: &TextRun, + fallback: &Recording, + fallback_at: Affine, + ) { + // Kept in recording space: the outlines under `fallback_at`, the + // run under `transform`, both relative to this recording. + let mut outlines = Recording::new(); + fallback.replay(&mut outlines, fallback_at); + self.cmds.push(Cmd::Text { + transform, + run: run.clone(), + fallback: outlines, + }); + } +} + +/// Counts calls and throws them away — for timing the paint path without a +/// rasteriser. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Tally { + pub fills: usize, + pub strokes: usize, + pub layers: usize, +} + +impl Canvas for Tally { + fn fill(&mut self, _: Affine, _: Fill, _: &Brush, _: Option, _: &BezPath) { + self.fills += 1; + } + fn stroke(&mut self, _: Affine, _: &Stroke, _: &Brush, _: &BezPath) { + self.strokes += 1; + } + fn push_clip(&mut self, _: Affine, _: &BezPath) { + self.layers += 1; + } + fn push_opacity(&mut self, _: Affine, _: f32, _: &BezPath) { + self.layers += 1; + } + fn pop(&mut self) {} +} + +#[cfg(feature = "anyrender-0-11")] +mod anyrender_0_11_impl { + //! Paint into any anyrender 0.11 `PaintScene` — session's recorded + //! `anyrender::Scene` included. + use anyrender_0_11 as anyrender; + use kurbo::{Affine, BezPath, Stroke}; + use peniko::{BlendMode, Brush, Fill}; + + use super::Canvas; + + /// Wraps a `PaintScene` as a [`Canvas`]. + pub struct AnyRender<'a, S: anyrender::PaintScene>(pub &'a mut S); + + fn paint(brush: &Brush) -> anyrender::PaintRef<'_> { + match brush { + Brush::Solid(c) => anyrender::PaintRef::from(*c), + Brush::Gradient(g) => anyrender::PaintRef::from(g), + Brush::Image(_) => anyrender::PaintRef::from(peniko::Color::TRANSPARENT), + } + } + + impl Canvas for AnyRender<'_, S> { + fn fill( + &mut self, + transform: Affine, + rule: Fill, + brush: &Brush, + brush_transform: Option, + shape: &BezPath, + ) { + self.0 + .fill(rule, transform, paint(brush), brush_transform, shape); + } + fn stroke(&mut self, transform: Affine, style: &Stroke, brush: &Brush, shape: &BezPath) { + self.0.stroke(style, transform, paint(brush), None, shape); + } + fn push_clip(&mut self, transform: Affine, shape: &BezPath) { + self.0.push_clip_layer(transform, shape); + } + fn push_opacity(&mut self, transform: Affine, alpha: f32, shape: &BezPath) { + self.0 + .push_layer(BlendMode::default(), alpha, transform, shape, None, None); + } + fn pop(&mut self) { + self.0.pop_layer(); + } + } +} + +#[cfg(feature = "anyrender-0-11")] +pub use anyrender_0_11_impl::AnyRender; diff --git a/crates/frame/src/render/canvas/mod.rs b/crates/frame/src/render/canvas/mod.rs new file mode 100644 index 00000000..7aaba289 --- /dev/null +++ b/crates/frame/src/render/canvas/mod.rs @@ -0,0 +1,21 @@ +//! frame's paint primitives, painted into whatever a host draws with. +//! +//! - [`canvas`]: the [`Canvas`] seam — an anyrender 0.11 `PaintScene` +//! (feature `anyrender-0-11`, session's mixer), frame's replayable +//! [`Recording`], or anything else with the same handful of calls. +//! - [`paint`]: `render`'s [`PaintPrimitive`](crate::render::PaintPrimitive)s → +//! a canvas. +//! - [`text`]: the fonts text is painted (as outlines) with. +//! +//! Shared by `frame-embed` (surfaces in a host's strip) and the theme +//! runtime (a host's own views drawn from documents). + +pub mod canvas; +pub mod paint; +pub mod text; + +#[cfg(feature = "anyrender-0-11")] +pub use canvas::AnyRender; +pub use canvas::{Canvas, CanvasExt, Cmd, Recording, Tally, TextAlign, TextRun}; +pub use paint::{node_of, paint_filtered, paint_primitives}; +pub use text::Font; diff --git a/crates/frame/src/render/canvas/paint.rs b/crates/frame/src/render/canvas/paint.rs new file mode 100644 index 00000000..5514b569 --- /dev/null +++ b/crates/frame/src/render/canvas/paint.rs @@ -0,0 +1,576 @@ +//! frame paint primitives → a [`Canvas`]. +//! +//! The same primitives `render` paints into a vello scene +//! (`crate::render::build_scene_primitives`), painted through the host-agnostic +//! [`Canvas`] instead. It covers what surfaces are built from — frames and +//! rectangles with solid and gradient fills, aligned strokes and corner +//! radii, authored vectors (fills, strokes, dashes), text (as outlines), +//! clips, opacity layers and group transforms — and approximates the rest: +//! a glow is a soft wider copy of the shape, drop/inner shadows and blurs +//! are skipped, and masks draw their content unmasked. At strip sizes those +//! are the effects that do not read anyway. + +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; + +use crate::document::proto::{CornerRadii, NodeId}; +use crate::render::anyrender_renderer::{ + AffineTransform2D, EffectKind, FillPaint, GradientStop, NodeEffect, StrokeAlign, StrokeCap, + StrokeJoin, StrokeStyle, TextAlignment, TextCase, +}; +use crate::render::{PaintPrimitive, Rgba}; +use kurbo::{Affine, BezPath, Point, Rect, RoundedRect, RoundedRectRadii, Shape, Stroke}; +use peniko::{Brush, Color, ColorStop, Fill, Gradient}; + +use crate::render::canvas::canvas::{Canvas, Recording, TextAlign, TextRun}; +use crate::render::canvas::text::Font; + +/// Paint `primitives` into `canvas` under `root` (canvas ← primitive space). +/// Primitives whose node is in `skip` (with everything they open) are left +/// out; `only`, when given, keeps just those nodes' primitives. +pub fn paint_primitives( + canvas: &mut (impl Canvas + ?Sized), + primitives: &[PaintPrimitive], + root: Affine, + font: &Font, +) { + paint_filtered(canvas, primitives, root, font, &|_| true); +} + +/// [`paint_primitives`], keeping only the primitives `keep` says yes to. +/// Structure (groups, clips, layers) is kept whenever anything inside it is. +pub fn paint_filtered( + canvas: &mut (impl Canvas + ?Sized), + primitives: &[PaintPrimitive], + root: Affine, + font: &Font, + keep: &dyn Fn(NodeId) -> bool, +) { + let mut groups: Vec = Vec::new(); + // Open structural primitives: whether each was pushed on the canvas. + let mut open: Vec = Vec::new(); + for p in primitives { + let t = groups.last().copied().unwrap_or(root); + match p { + PaintPrimitive::GroupStart { transform, .. } => groups.push(t * affine(transform)), + PaintPrimitive::GroupEnd { .. } => { + groups.pop(); + } + PaintPrimitive::ClipStart { + x, + y, + width, + height, + corner_radii, + .. + } => { + canvas.push_clip( + t, + &rounded(Rect::new(*x, *y, x + width, y + height), corner_radii).to_path(0.05), + ); + open.push(true); + } + PaintPrimitive::LayerStart { + x, + y, + width, + height, + opacity, + .. + } => { + if (*opacity - 1.0).abs() > 1e-6 { + canvas.push_opacity( + t, + *opacity as f32, + &Rect::new(*x, *y, x + width, y + height).to_path(0.05), + ); + open.push(true); + } else { + open.push(false); + } + } + PaintPrimitive::ClipEnd { .. } | PaintPrimitive::LayerEnd { .. } => { + if open.pop() == Some(true) { + canvas.pop(); + } + } + // Masks: the content is drawn, unmasked; the definition is not. + PaintPrimitive::MaskDefStart { .. } + | PaintPrimitive::MaskApplyStart { .. } + | PaintPrimitive::MaskApplyEnd { .. } => {} + PaintPrimitive::MaskDefEnd { .. } => {} + PaintPrimitive::Rect { + node_id, + x, + y, + width, + height, + fills, + stroke, + corner_radii, + effects, + rotation, + .. + } => { + if !keep(*node_id) { + continue; + } + let t = if rotation.abs() > 1e-9 { + t * Affine::translate((*x, *y)) + * Affine::rotate(*rotation) + * Affine::translate((-*x, -*y)) + } else { + t + }; + let rect = Rect::new(*x, *y, x + width, y + height); + let shape = rounded(rect, corner_radii).to_path(0.05); + glow(canvas, t, &shape, effects); + for (fill, _) in fills { + fill_paint(canvas, t, &shape, rect, fill); + } + if let Some(s) = stroke.as_ref().filter(|s| s.width > 0.0) { + let r = match s.align { + StrokeAlign::Inside => rect.inset(-s.width / 2.0), + StrokeAlign::Outside => rect.inset(s.width / 2.0), + StrokeAlign::Center => rect, + }; + let radii = shift_radii( + corner_radii, + match s.align { + StrokeAlign::Inside => -s.width / 2.0, + StrokeAlign::Outside => s.width / 2.0, + StrokeAlign::Center => 0.0, + }, + ); + stroke_shape(canvas, t, &rounded(r, &radii).to_path(0.05), rect, s); + } + } + PaintPrimitive::Path { + node_id, + x, + y, + width, + height, + fill_paths, + stroke_paths, + fills, + stroke, + effects, + rotation, + .. + } => { + if !keep(*node_id) { + continue; + } + let t = t + * Affine::translate((*x, *y)) + * if rotation.abs() > 1e-9 { + Affine::rotate(*rotation) + } else { + Affine::IDENTITY + }; + let box_rect = Rect::new(0.0, 0.0, *width, *height); + let fill_geo: Vec> = + fill_paths.iter().filter_map(|d| parsed(d)).collect(); + let stroke_geo: Vec> = + stroke_paths.iter().filter_map(|d| parsed(d)).collect(); + let stroke = stroke.as_ref().filter(|s| s.width > 0.0); + for g in &fill_geo { + glow(canvas, t, g, effects); + } + if !fills.is_empty() { + for g in &fill_geo { + // A stroked outline's open subpaths are lines, not areas. + let area = if stroke.is_some() && stroke_geo.is_empty() { + closed_only(g) + } else { + (**g).clone() + }; + if area.elements().is_empty() { + continue; + } + for (fill, _) in fills { + fill_paint(canvas, t, &area, box_rect, fill); + } + } + } + if let Some(s) = stroke { + if stroke_geo.is_empty() { + for g in &fill_geo { + stroke_shape(canvas, t, g, box_rect, s); + } + } else { + // Stroke geometry is the stroke's own outline. + let paint = s + .gradient + .clone() + .unwrap_or_else(|| FillPaint::Solid(s.color.clone())); + for g in &stroke_geo { + fill_paint(canvas, t, g, box_rect, &paint); + } + } + } + if fill_geo.is_empty() && stroke_geo.is_empty() { + let shape = box_rect.to_path(0.05); + for (fill, _) in fills { + fill_paint(canvas, t, &shape, box_rect, fill); + } + } + } + PaintPrimitive::Text { + node_id, + x, + y, + text, + color: c, + font_size, + line_height, + letter_spacing, + text_case, + font: spec, + style_runs, + width, + align, + .. + } => { + if !keep(*node_id) || text.is_empty() { + continue; + } + // One plain line: the host may draw it itself. + let run = (style_runs.is_empty() && !text.contains('\n')).then(|| TextRun { + text: cased(text, text_case), + left: x + 1.0, + top: y + 1.0, + width: *width, + align: match align { + TextAlignment::Left => TextAlign::Left, + TextAlignment::Center => TextAlign::Center, + TextAlignment::Right => TextAlign::Right, + }, + size: *font_size, + weight: spec.weight, + family: spec.family.clone(), + letter_spacing: *letter_spacing, + color: color(c), + }); + if let Some(run) = &run + && canvas.handles_text() + && canvas.text(t, run) + { + continue; + } + let mut outlines = Recording::new(); + let target: &mut dyn Canvas = &mut outlines; + let lh = line_height.unwrap_or(font_size * 1.2).max(1.0); + // Styled spans (the whole text as one without runs), each in + // its own family, weight, size and colour, split into lines. + let runs: Vec<(String, crate::render::fonts::FontSpec, f64, f64, &Rgba)> = + if style_runs.is_empty() { + vec![( + cased(text, text_case), + spec.clone(), + *font_size, + *letter_spacing, + c, + )] + } else { + style_runs + .iter() + .map(|r| { + ( + cased(&r.text, text_case), + r.font().over(spec), + r.font_size.unwrap_or(*font_size), + r.letter_spacing.unwrap_or(*letter_spacing), + r.color.as_ref().unwrap_or(c), + ) + }) + .collect() + }; + // Per line: (pieces placed from the line's pen start, width). + let mut lines: Vec> = vec![(Vec::new(), 0.0)]; + for (s, run_spec, size, spacing, run_color) in &runs { + let ascent = font.ascent_of(run_spec, *size); + for (i, part) in s.split('\n').enumerate() { + if i > 0 { + lines.push((Vec::new(), 0.0)); + } + let laid = font.line_of(run_spec, part, *size, *spacing); + if let Some((pieces, w)) = lines.last_mut() { + let at = Affine::translate((*w, ascent)) * laid.path; + pieces.push((at, *run_color)); + *w += laid.width; + } + } + } + for (i, (pieces, w)) in lines.iter().enumerate() { + let dx = match align { + TextAlignment::Left => 0.0, + TextAlignment::Center => ((width - w) / 2.0).max(0.0), + TextAlignment::Right => (width - w).max(0.0), + }; + let at = Affine::translate((x + 1.0 + dx, y + 1.0 + i as f64 * lh)); + for (path, run_color) in pieces { + target.fill( + t * at, + Fill::NonZero, + &Brush::Solid(color(run_color)), + None, + path, + ); + } + } + match &run { + Some(run) => canvas.text_with_fallback(t, run, &outlines, Affine::IDENTITY), + None => outlines.replay(canvas, Affine::IDENTITY), + } + } + } + } + // Close anything a truncated list left open. + for pushed in open.into_iter().rev() { + if pushed { + canvas.pop(); + } + } +} + +/// Primitive `node_id`s in paint order, for tests and filters. +#[must_use] +pub fn node_of(p: &PaintPrimitive) -> Option { + match p { + PaintPrimitive::Rect { node_id, .. } + | PaintPrimitive::Path { node_id, .. } + | PaintPrimitive::Text { node_id, .. } => Some(*node_id), + _ => None, + } +} + +// ── helpers ────────────────────────────────────────────────────────────── + +fn affine(t: &AffineTransform2D) -> Affine { + Affine::new([t.a, t.c, t.b, t.d, t.tx, t.ty]) +} + +#[must_use] +pub(crate) fn color(c: &Rgba) -> Color { + Color::new([ + c.r as f32, + c.g as f32, + c.b as f32, + c.a.clamp(0.0, 1.0) as f32, + ]) +} + +fn rounded(rect: Rect, r: &CornerRadii) -> RoundedRect { + let max = rect.width().min(rect.height()) / 2.0; + let c = |v: f64| v.clamp(0.0, max.max(0.0)); + RoundedRect::from_rect( + rect, + RoundedRectRadii::new( + c(r.top_left), + c(r.top_right), + c(r.bottom_right), + c(r.bottom_left), + ), + ) +} + +fn shift_radii(r: &CornerRadii, by: f64) -> CornerRadii { + let s = |v: f64| if v > 0.0 { (v + by).max(0.0) } else { 0.0 }; + CornerRadii { + top_left: s(r.top_left), + top_right: s(r.top_right), + bottom_right: s(r.bottom_right), + bottom_left: s(r.bottom_left), + } +} + +fn kurbo_stroke(s: &StrokeStyle) -> Stroke { + let cap = match s.cap { + StrokeCap::Round => kurbo::Cap::Round, + StrokeCap::Square => kurbo::Cap::Square, + _ => kurbo::Cap::Butt, + }; + let join = match s.join { + StrokeJoin::Round => kurbo::Join::Round, + StrokeJoin::Bevel => kurbo::Join::Bevel, + StrokeJoin::Miter => kurbo::Join::Miter, + }; + let mut k = Stroke::new(s.width) + .with_caps(cap) + .with_join(join) + .with_miter_limit(s.miter_limit.max(1.0)); + if !s.dash_pattern.is_empty() { + k = k.with_dashes(s.dash_offset, s.dash_pattern.iter().copied()); + } + k +} + +fn stops(s: &[GradientStop]) -> Vec { + s.iter() + .map(|g| ColorStop::from((g.offset as f32, color(&g.color)))) + .collect() +} + +fn uv(t: Option<&AffineTransform2D>, u: f64, v: f64) -> (f64, f64) { + t.map_or((u, v), |t| t.transform_point(u, v)) +} + +/// Stroke `shape` with `s`: a solid stroke directly, a gradient one as its +/// outline filled with the gradient laid across the node's box `b`. +fn stroke_shape( + canvas: &mut (impl Canvas + ?Sized), + t: Affine, + shape: &BezPath, + b: Rect, + s: &StrokeStyle, +) { + let style = kurbo_stroke(s); + match &s.gradient { + Some(g) => { + let outline = kurbo::stroke( + shape.elements().iter().copied(), + &style, + &kurbo::StrokeOpts::default(), + 0.05, + ); + fill_paint(canvas, t, &outline, b, g); + } + None => canvas.stroke(t, &style, &Brush::Solid(color(&s.color)), shape), + } +} + +fn fill_paint( + canvas: &mut (impl Canvas + ?Sized), + t: Affine, + shape: &BezPath, + b: Rect, + fill: &FillPaint, +) { + let (x, y, w, h) = (b.x0, b.y0, b.width().max(1e-6), b.height().max(1e-6)); + match fill { + FillPaint::Solid(c) => { + if c.a > 0.0 { + canvas.fill(t, Fill::NonZero, &Brush::Solid(color(c)), None, shape); + } + } + FillPaint::GradientLinear { + transform, + stops: s, + } if !s.is_empty() => { + let (sx, sy) = uv(transform.as_ref(), 0.0, 0.5); + let (ex, ey) = uv(transform.as_ref(), 1.0, 0.5); + let g = Gradient::new_linear( + Point::new(x + sx * w, y + sy * h), + Point::new(x + ex * w, y + ey * h), + ) + .with_stops(stops(s).as_slice()); + canvas.fill(t, Fill::NonZero, &Brush::Gradient(g), None, shape); + } + FillPaint::GradientRadial { + transform, + stops: s, + } if !s.is_empty() => { + let (cx, cy) = uv(transform.as_ref(), 0.5, 0.5); + let centre = Point::new(x + cx * w, y + cy * h); + let g = Gradient::new_two_point_radial(Point::ZERO, 0.0, Point::ZERO, 1.0) + .with_stops(stops(s).as_slice()); + let bt = match transform { + Some(m) => { + let ax = (m.a * w, m.c * h); + let ay = (m.b * w, m.d * h); + let rx = (ax.0.hypot(ax.1) * 0.5).max(0.5); + let ry = (ay.0.hypot(ay.1) * 0.5).max(0.5); + Affine::translate(centre.to_vec2()) + * Affine::rotate(ax.1.atan2(ax.0)) + * Affine::scale_non_uniform(rx, ry) + } + None => { + Affine::translate(centre.to_vec2()) * Affine::scale((w.min(h) * 0.5).max(0.5)) + } + }; + canvas.fill(t, Fill::NonZero, &Brush::Gradient(g), Some(bt), shape); + } + FillPaint::GradientAngular { + transform, + stops: s, + } if !s.is_empty() => { + let (cx, cy) = uv(transform.as_ref(), 0.5, 0.5); + let g = Gradient::new_sweep( + Point::new(x + cx * w, y + cy * h), + 0.0, + std::f32::consts::TAU, + ) + .with_stops(stops(s).as_slice()); + canvas.fill(t, Fill::NonZero, &Brush::Gradient(g), None, shape); + } + // Images, and gradients without stops: nothing an embed draws. + _ => {} + } +} + +/// Light a shape gives off: a soft, wider copy of it under it. +fn glow(canvas: &mut (impl Canvas + ?Sized), t: Affine, shape: &BezPath, effects: &[NodeEffect]) { + for e in effects + .iter() + .filter(|e| matches!(e.kind, EffectKind::Glow) && e.color.a > 0.0) + { + let r = e.radius.clamp(0.5, 12.0); + for (k, a) in [(1.0, 0.18), (0.5, 0.28)] { + let c = Rgba { + a: e.color.a * a, + ..e.color.clone() + }; + let style = Stroke::new(r * k * 2.0) + .with_caps(kurbo::Cap::Round) + .with_join(kurbo::Join::Round); + canvas.stroke(t, &style, &Brush::Solid(color(&c)), shape); + } + } +} + +fn closed_only(p: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let mut cur: Vec = Vec::new(); + for el in p.elements() { + if matches!(el, kurbo::PathEl::MoveTo(_)) { + cur.clear(); + } + cur.push(*el); + if matches!(el, kurbo::PathEl::ClosePath) { + out.extend(cur.drain(..)); + } + } + out +} + +/// A laid-out line of text: its coloured pieces and its width. +type Line<'a> = (Vec<(BezPath, &'a Rgba)>, f64); + +fn cased(text: &str, case: &TextCase) -> String { + match case { + TextCase::Upper | TextCase::SmallCaps | TextCase::SmallCapsForced => text.to_uppercase(), + TextCase::Lower => text.to_lowercase(), + _ => text.to_string(), + } +} + +static PATHS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// SVG path data, parsed once. +fn parsed(d: &str) -> Option> { + if let Some(hit) = PATHS.lock().ok()?.get(d) { + return Some(hit.clone()); + } + let p = Arc::new(BezPath::from_svg(d).ok()?); + if let Ok(mut m) = PATHS.lock() { + // Plots are new path data every frame; don't hoard them. + if m.len() > 2048 { + m.clear(); + } + m.insert(d.to_string(), p.clone()); + } + Some(p) +} diff --git a/crates/frame/src/render/canvas/text.rs b/crates/frame/src/render/canvas/text.rs new file mode 100644 index 00000000..f2927022 --- /dev/null +++ b/crates/frame/src/render/canvas/text.rs @@ -0,0 +1,174 @@ +//! Text as outlines. +//! +//! A host scene may have no text of its own (a LICE bitmap, a CPU +//! rasteriser without a glyph cache), and a glyph run's font handle does +//! not survive being replayed into a different anyrender version. Paths +//! do: every glyph is drawn as its outline — from the face frame's +//! [`FontBook`] resolves for the text's family, weight, italic and width, +//! instanced on its axes — cached per (face, glyph, size, instance), so an +//! embed's labels cost a few fills and replay anywhere. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +use crate::render::fonts::Resolved; +pub use crate::render::fonts::{FontBook, FontSpec}; +use kurbo::{Affine, BezPath}; + +/// frame's UI face (Inter, variable). Hosts may hand in their own book. +pub static INTER: &[u8] = crate::render::font_book::INTER; + +type Cache = Mutex>>; + +/// A font book and its outline cache. +#[derive(Clone)] +pub struct Font { + book: Cow<'static, FontBook>, + cache: Arc, +} + +impl std::fmt::Debug for Font { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Font") + .field("families", &self.book.families()) + .finish() + } +} + +impl Default for Font { + /// frame's bundled book. + fn default() -> Self { + Self::from_book(Cow::Borrowed(FontBook::bundled())) + } +} + +/// One line laid out: each glyph's outline, already placed. +#[derive(Debug, Clone, Default)] +pub struct Line { + pub path: BezPath, + pub width: f64, +} + +fn spec_of(weight: f64) -> FontSpec { + FontSpec { + weight: Some(weight), + ..FontSpec::default() + } +} + +impl Font { + /// One font file (every face in it) as the whole book. + #[must_use] + pub fn new(bytes: Vec, _index: u32) -> Self { + Self::from_book(Cow::Owned(FontBook::single(bytes))) + } + + #[must_use] + pub fn from_book(book: Cow<'static, FontBook>) -> Self { + Self { + book, + cache: Arc::new(Mutex::new(HashMap::new())), + } + } + + #[must_use] + pub fn book(&self) -> &FontBook { + &self.book + } + + /// This font with the fonts `scene` carries registered over it (the + /// outline cache is shared). + #[must_use] + pub fn for_scene(&self, scene: &crate::document::proto::Scene) -> Cow<'_, Font> { + match crate::render::book_for_scene(&self.book, scene) { + Cow::Borrowed(_) => Cow::Borrowed(self), + Cow::Owned(book) => Cow::Owned(Self { + book: Cow::Owned(book), + cache: self.cache.clone(), + }), + } + } + + /// Ascent of regular text at `size`, px. + #[must_use] + pub fn ascent(&self, size: f64) -> f64 { + self.ascent_of(&FontSpec::default(), size) + } + + /// Ascent of `spec` at `size`, px. + #[must_use] + pub fn ascent_of(&self, spec: &FontSpec, size: f64) -> f64 { + self.book + .resolve(&spec.query()) + .map_or(size * 0.8, |r| r.ascent(size)) + } + + /// How wide regular `text` sets at `size` with `spacing` px between + /// letters. + #[must_use] + pub fn width(&self, text: &str, size: f64, spacing: f64) -> f64 { + self.width_of(&FontSpec::default(), text, size, spacing) + } + + /// How wide `text` sets in `spec`. + #[must_use] + pub fn width_of(&self, spec: &FontSpec, text: &str, size: f64, spacing: f64) -> f64 { + crate::render::text_advance(&self.book, spec, text, size, spacing) + } + + /// `text` at `weight` as one path, its pen starting at the origin on + /// the baseline. + #[must_use] + pub fn line(&self, text: &str, size: f64, spacing: f64, weight: f64) -> Line { + self.line_of(&spec_of(weight), text, size, spacing) + } + + /// `text` set in `spec` as one path, pen from the origin on the + /// baseline. + #[must_use] + pub fn line_of(&self, spec: &FontSpec, text: &str, size: f64, spacing: f64) -> Line { + let chain = self.book.chain(&spec.query()); + let mut path = BezPath::new(); + let mut pen_x = 0.0; + for ch in text.chars().filter(|c| *c != '\n') { + let Some((face, gid)) = chain.pick(ch) else { + continue; + }; + if let Some(glyph) = self.glyph(face, gid, size) { + path.extend( + (Affine::translate((pen_x, 0.0)) * (*glyph).clone()) + .elements() + .iter() + .copied(), + ); + } + pen_x += face.advance(gid, size) + spacing; + } + Line { path, width: pen_x } + } + + fn glyph(&self, face: &Resolved, gid: skrifa::GlyphId, size: f64) -> Option> { + let mut h = std::collections::hash_map::DefaultHasher::new(); + face.coords.hash(&mut h); + (face.synthetic_bold, face.synthetic_italic).hash(&mut h); + let key = ( + face.face.id(), + gid.to_u32(), + (size * 64.0).round() as u32, + h.finish(), + ); + if let Some(hit) = self.cache.lock().ok()?.get(&key) { + return Some(hit.clone()); + } + let path = Arc::new(face.outline(gid, size)?); + if let Ok(mut cache) = self.cache.lock() { + if cache.len() > 4096 { + cache.clear(); + } + cache.insert(key, path.clone()); + } + Some(path) + } +} diff --git a/crates/frame/src/render/font_book/bundled.rs b/crates/frame/src/render/font_book/bundled.rs new file mode 100644 index 00000000..a313fdba --- /dev/null +++ b/crates/frame/src/render/font_book/bundled.rs @@ -0,0 +1,183 @@ +//! The families frame ships, embedded. All are SIL Open Font License 1.1; +//! each family's `OFL.txt` sits beside its fonts in `assets/`. +//! +//! Fonts without a Reserved Font Name are subset to Latin-1 plus the +//! punctuation, arrows, maths and music signs surfaces print (see +//! `assets/README.md`); fonts with one ship as released. + +/// frame's UI face (Inter, variable `opsz`/`wght`). +pub static INTER: &[u8] = include_bytes!("../../../assets/inter/Inter-Variable.ttf"); +/// Inter's italic. +pub static INTER_ITALIC: &[u8] = include_bytes!("../../../assets/inter/Inter-Italic-Variable.ttf"); + +/// A bundled family: its name, what it is for, and its font files. +#[derive(Debug, Clone, Copy)] +pub struct BundledFamily { + /// The family name a `` uses. + pub name: &'static str, + /// What kind of face it is, and what it suits. + pub role: &'static str, + /// Weights it draws: `(min, max)`. + pub weights: (u16, u16), + /// Widths (percent) it draws. + pub widths: (u16, u16), + /// Has an italic of its own (or an italic axis). + pub italic: bool, + pub files: &'static [&'static [u8]], +} + +macro_rules! font { + ($path:literal) => { + include_bytes!(concat!("../../../assets/", $path)) as &[u8] + }; +} + +/// Every bundled family, UI face first. +pub static BUNDLED: &[BundledFamily] = &[ + BundledFamily { + name: "Inter", + role: "UI sans (default): labels, values, legends", + weights: (100, 900), + widths: (100, 100), + italic: true, + files: &[INTER, INTER_ITALIC], + }, + BundledFamily { + name: "Oswald", + role: "condensed display sans: model names, big legends, amp badges", + weights: (200, 700), + widths: (100, 100), + italic: false, + files: &[font!("oswald/Oswald-Variable.ttf")], + }, + BundledFamily { + name: "Saira", + role: "technical sans with a width axis (50–125): condensed to extended rack lettering", + weights: (100, 900), + widths: (50, 125), + italic: false, + files: &[font!("saira/Saira-Variable.ttf")], + }, + BundledFamily { + name: "Michroma", + role: "wide engraved/geometric caps: brushed-metal panels, hi-fi faceplates", + weights: (400, 400), + widths: (100, 100), + italic: false, + files: &[font!("michroma/Michroma-Regular.ttf")], + }, + BundledFamily { + name: "Orbitron", + role: "geometric tech caps: synth and modern-metal logos", + weights: (400, 900), + widths: (100, 100), + italic: false, + files: &[font!("orbitron/Orbitron-Variable.ttf")], + }, + BundledFamily { + name: "Dancing Script", + role: "flowing script: signature logos, boutique and vintage badges", + weights: (400, 700), + widths: (100, 100), + italic: false, + files: &[font!("dancing-script/DancingScript-Variable.ttf")], + }, + BundledFamily { + name: "Pacifico", + role: "fat retro brush script: surf, funk and 50s logos", + weights: (400, 400), + widths: (100, 100), + italic: false, + files: &[font!("pacifico/Pacifico-Regular.ttf")], + }, + BundledFamily { + name: "Caveat Brush", + role: "marker / handwriting: scribbled labels, tape strips, notes", + weights: (400, 400), + widths: (100, 100), + italic: false, + files: &[font!("caveat-brush/CaveatBrush-Regular.ttf")], + }, + BundledFamily { + name: "Knewave", + role: "heavy brush marker: punk, funk and graffiti logos", + weights: (400, 400), + widths: (100, 100), + italic: false, + files: &[font!("knewave/Knewave-Regular.ttf")], + }, + BundledFamily { + name: "Playfair Display", + role: "high-contrast serif: boutique, hi-fi and valve-era script plates", + weights: (400, 900), + widths: (100, 100), + italic: false, + files: &[font!("playfair-display/PlayfairDisplay-Variable.ttf")], + }, + BundledFamily { + name: "DSEG7 Classic", + role: "7-segment LCD/LED digits: tuners, BPM and time readouts (digits, a few letters)", + weights: (300, 700), + widths: (100, 100), + italic: true, + files: &[ + font!("dseg/DSEG7Classic-Light.ttf"), + font!("dseg/DSEG7Classic-Regular.ttf"), + font!("dseg/DSEG7Classic-Bold.ttf"), + font!("dseg/DSEG7Classic-Italic.ttf"), + font!("dseg/DSEG7Classic-BoldItalic.ttf"), + ], + }, + BundledFamily { + name: "DSEG14 Classic", + role: "14-segment alphanumeric LCD: preset names, patch displays", + weights: (400, 700), + widths: (100, 100), + italic: true, + files: &[ + font!("dseg/DSEG14Classic-Regular.ttf"), + font!("dseg/DSEG14Classic-Bold.ttf"), + font!("dseg/DSEG14Classic-Italic.ttf"), + ], + }, + BundledFamily { + name: "Doto", + role: "dot-matrix (variable weight): VFD and LED-matrix displays", + weights: (100, 900), + widths: (100, 100), + italic: false, + files: &[font!("doto/Doto-Variable.ttf")], + }, + BundledFamily { + name: "VT323", + role: "pixel terminal: CRT readouts, retro digital units", + weights: (400, 400), + widths: (100, 100), + italic: false, + files: &[font!("vt323/VT323-Regular.ttf")], + }, + BundledFamily { + name: "JetBrains Mono", + role: "monospace: numeric readouts that must not jitter, code, tables", + weights: (100, 800), + widths: (100, 100), + italic: false, + files: &[font!("jetbrains-mono/JetBrainsMono-Variable.ttf")], + }, + BundledFamily { + name: "UnifrakturCook", + role: "blackletter: metal and gothic logos", + weights: (700, 700), + widths: (100, 100), + italic: false, + files: &[font!("unifraktur-cook/UnifrakturCook-Bold.ttf")], + }, + BundledFamily { + name: "Pirata One", + role: "condensed gothic/blackletter display: metal and pirate-flag logos", + weights: (400, 400), + widths: (100, 100), + italic: false, + files: &[font!("pirata-one/PirataOne-Regular.ttf")], + }, +]; diff --git a/crates/frame/src/render/font_book/mod.rs b/crates/frame/src/render/font_book/mod.rs new file mode 100644 index 00000000..a6f2ede0 --- /dev/null +++ b/crates/frame/src/render/font_book/mod.rs @@ -0,0 +1,865 @@ +//! frame's fonts. +//! +//! A [`FontBook`] holds font families — variable or static, several faces +//! each — and picks the face a text style asks for: by family name, weight, +//! italic and width ([`FontQuery`]), falling back through a chain of +//! families when the named one is missing or lacks a glyph. +//! +//! Variable faces are instanced on their axes (`wght`, `wdth`, and `ital` +//! or `slnt` for italics); a style a family can't draw is synthesised — an +//! oblique skew for a missing italic, an outline expansion for a missing +//! bold — the way a browser does. +//! +//! [`FontBook::bundled`] is the default book: every family in +//! [`BUNDLED`] embedded in the binary, with `Inter` as the fallback. Hosts +//! clone it and [`register`](FontBook::register) more; a document carries +//! its own fonts as `font/*` assets, which the renderer registers over the +//! host's book for that document. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock}; + +use kurbo::{Affine, BezPath}; +use skrifa::attribute::{Attributes, Style}; +use skrifa::instance::{Location, LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FileRef; +use skrifa::string::StringId; +use skrifa::{FontRef, GlyphId, MetadataProvider, Tag}; + +mod bundled; +pub use bundled::{BUNDLED, BundledFamily, INTER, INTER_ITALIC}; + +/// The shear of a synthetic oblique (x += `SKEW` · height above baseline). +pub const SYNTHETIC_SKEW: f64 = 0.22; + +/// How far a synthetic bold grows each side of a glyph, per px of size. +pub const SYNTHETIC_BOLD: f64 = 0.024; + +/// The family every lookup falls back to in the bundled book. +pub const DEFAULT_FAMILY: &str = "Inter"; + +// ── bytes ──────────────────────────────────────────────────────────────── + +/// A font file's bytes: embedded, or shared (registered at run time). +#[derive(Clone)] +pub enum FontBytes { + Static(&'static [u8]), + Shared(Arc<[u8]>), +} + +impl AsRef<[u8]> for FontBytes { + fn as_ref(&self) -> &[u8] { + match self { + Self::Static(b) => b, + Self::Shared(b) => b, + } + } +} + +impl From<&'static [u8]> for FontBytes { + fn from(b: &'static [u8]) -> Self { + Self::Static(b) + } +} + +impl From> for FontBytes { + fn from(b: Vec) -> Self { + Self::Shared(b.into()) + } +} + +impl From> for FontBytes { + fn from(b: Arc<[u8]>) -> Self { + Self::Shared(b) + } +} + +impl std::fmt::Debug for FontBytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FontBytes({} bytes)", self.as_ref().len()) + } +} + +// ── faces ──────────────────────────────────────────────────────────────── + +/// A variation axis: `(min, default, max)` in user units. +pub type AxisRange = (f32, f32, f32); + +/// One face of a family: a font in a file, with the styles it covers. +#[derive(Debug)] +pub struct Face { + /// The family it belongs to (the font's typographic family name, or the + /// name it was registered under). + pub family: String, + bytes: FontBytes, + /// Index in a collection (0 for a plain font file). + pub index: u32, + /// Weights it draws: `(min, max)`, equal for a static face. + pub weight: (f32, f32), + /// Widths it draws, percent of normal: `(min, max)`. + pub width: (f32, f32), + /// An italic (or oblique) face. + pub italic: bool, + pub wght: Option, + pub wdth: Option, + pub slnt: Option, + pub ital: Option, + id: u64, +} + +static NEXT_FACE: AtomicU64 = AtomicU64::new(1); + +fn axis(font: &FontRef<'_>, tag: &[u8; 4]) -> Option { + let tag = Tag::new(tag); + font.axes() + .iter() + .find(|a| a.tag() == tag) + .map(|a| (a.min_value(), a.default_value(), a.max_value())) +} + +fn name(font: &FontRef<'_>, id: StringId) -> Option { + let s = font.localized_strings(id).english_or_first()?.to_string(); + (!s.trim().is_empty()).then_some(s) +} + +impl Face { + /// Read face `index` of `bytes`; `family` overrides the font's own name. + #[must_use] + pub fn read(bytes: FontBytes, index: u32, family: Option<&str>) -> Option { + let font = font_ref(bytes.as_ref(), index)?; + let family = match family { + Some(f) => f.to_string(), + None => name(&font, StringId::TYPOGRAPHIC_FAMILY_NAME) + .or_else(|| name(&font, StringId::FAMILY_NAME))?, + }; + let attrs = Attributes::new(&font); + let (wght, wdth, slnt, ital) = ( + axis(&font, b"wght"), + axis(&font, b"wdth"), + axis(&font, b"slnt"), + axis(&font, b"ital"), + ); + let w = attrs.weight.value(); + let weight = wght.map_or((w, w), |(lo, _, hi)| (lo, hi)); + let s = attrs.stretch.percentage(); + let width = wdth.map_or((s, s), |(lo, _, hi)| (lo, hi)); + let italic = + !matches!(attrs.style, Style::Normal) || ital.is_some_and(|(_, d, _)| d >= 0.5); + Some(Self { + family, + bytes, + index, + weight, + width, + italic, + wght, + wdth, + slnt, + ital, + id: NEXT_FACE.fetch_add(1, Ordering::Relaxed), + }) + } + + /// The font file's bytes. + #[must_use] + pub fn bytes(&self) -> &[u8] { + self.bytes.as_ref() + } + + /// The shared bytes (cheap to clone). + #[must_use] + pub fn font_bytes(&self) -> &FontBytes { + &self.bytes + } + + /// A process-unique id, for caches keyed by face. + #[must_use] + pub fn id(&self) -> u64 { + self.id + } + + #[must_use] + pub fn font(&self) -> Option> { + font_ref(self.bytes.as_ref(), self.index) + } + + /// Whether it can draw italics itself (an italic face, or an `ital` / + /// `slnt` axis). + #[must_use] + pub fn can_italic(&self) -> bool { + self.italic || self.ital.is_some() || self.slnt.is_some_and(|(lo, _, _)| lo < 0.0) + } +} + +fn font_ref(bytes: &[u8], index: u32) -> Option> { + match FileRef::new(bytes).ok()? { + FileRef::Font(f) => Some(f), + FileRef::Collection(c) => c.get(index).ok(), + } +} + +// ── queries ────────────────────────────────────────────────────────────── + +/// What a text style asks for. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FontQuery<'a> { + /// Family name (case, spaces and hyphens don't matter), a generic name + /// (`sans-serif`, `serif`, `monospace`, `cursive`, `fantasy`, + /// `system-ui`) or an alias; `None` = the book's first fallback. + pub family: Option<&'a str>, + /// 1–1000; 400 regular, 700 bold. + pub weight: f32, + pub italic: bool, + /// Percent of normal width (CSS `font-stretch`): 75 condensed, 125 + /// expanded. + pub width: f32, +} + +impl Default for FontQuery<'_> { + fn default() -> Self { + Self { + family: None, + weight: 400.0, + italic: false, + width: 100.0, + } + } +} + +impl<'a> FontQuery<'a> { + #[must_use] + pub fn family(family: &'a str) -> Self { + Self { + family: Some(family), + ..Self::default() + } + } + + #[must_use] + pub fn weight(mut self, weight: f32) -> Self { + self.weight = weight; + self + } + + #[must_use] + pub fn italic(mut self, italic: bool) -> Self { + self.italic = italic; + self + } + + #[must_use] + pub fn width(mut self, width: f32) -> Self { + self.width = width; + self + } +} + +/// A text style as a document states it: every field optional, the style +/// name free-form (`"Bold Italic"`, `"Condensed Light"`, `"italic"`). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct FontSpec { + pub family: Option, + pub weight: Option, + pub style: Option, + pub width: Option, +} + +impl FontSpec { + /// This spec with `base` filling the fields it leaves unset (a run over + /// its node). A run's style name only overrides what it names: its + /// weight words set the weight unless the base states one explicitly. + #[must_use] + pub fn over(&self, base: &FontSpec) -> FontSpec { + let own = self.style.as_deref().map(parse_style).unwrap_or_default(); + let weight = self.weight.or(own.weight.map(f64::from)).or(base.weight); + let width = self.width.or(own.width.map(f64::from)).or(base.width); + let style = match (&self.style, &base.style) { + (Some(s), Some(b)) if !own.italic && parse_style(b).italic => { + Some(format!("{s} italic")) + } + (Some(s), _) => Some(s.clone()), + (None, b) => b.clone(), + }; + FontSpec { + family: self.family.clone().or_else(|| base.family.clone()), + weight, + style, + width, + } + } + + /// The query this spec makes (unset: regular, upright, normal width). + #[must_use] + pub fn query(&self) -> FontQuery<'_> { + let hints = self.style.as_deref().map(parse_style).unwrap_or_default(); + FontQuery { + family: self.family.as_deref(), + weight: self + .weight + .map(|w| w as f32) + .or(hints.weight) + .unwrap_or(400.0), + italic: hints.italic, + width: self + .width + .map(|w| w as f32) + .or(hints.width) + .unwrap_or(100.0), + } + } +} + +/// What a style name says. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct StyleHints { + pub weight: Option, + pub italic: bool, + pub width: Option, +} + +/// Read a style name — Figma's `"Semi Bold Italic"`, CSS-ish `"italic"`, +/// `"condensed bold"` — into weight, italic and width. +#[must_use] +pub fn parse_style(style: &str) -> StyleHints { + let s: String = style + .to_ascii_lowercase() + .chars() + .filter(char::is_ascii_alphanumeric) + .collect(); + const WIDTHS: &[(&str, f32)] = &[ + ("ultracondensed", 50.0), + ("extracondensed", 62.5), + ("semicondensed", 87.5), + ("condensed", 75.0), + ("narrow", 75.0), + ("compressed", 62.5), + ("ultraexpanded", 200.0), + ("extraexpanded", 150.0), + ("semiexpanded", 112.5), + ("expanded", 125.0), + ("extended", 125.0), + ("wide", 125.0), + ]; + const WEIGHTS: &[(&str, f32)] = &[ + ("hairline", 100.0), + ("thin", 100.0), + ("extralight", 200.0), + ("ultralight", 200.0), + ("semilight", 350.0), + ("light", 300.0), + ("regular", 400.0), + ("normal", 400.0), + ("book", 400.0), + ("medium", 500.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("bold", 700.0), + ("extrablack", 950.0), + ("ultrablack", 950.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + let mut rest = s.clone(); + let mut width = None; + for (k, v) in WIDTHS { + if rest.contains(k) { + width = Some(*v); + rest = rest.replacen(k, "", 1); + break; + } + } + let weight = WEIGHTS + .iter() + .find(|(k, _)| rest.contains(k)) + .map(|(_, v)| *v); + StyleHints { + weight, + italic: s.contains("italic") || s.contains("oblique"), + width, + } +} + +/// Fold a family name for matching: lower case, letters and digits only. +#[must_use] +pub fn fold(name: &str) -> String { + name.chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +// ── the book ───────────────────────────────────────────────────────────── + +/// Font families, looked up by name, weight, italic and width. +/// +/// Cheap to clone (faces are shared). Faces registered later win ties, so a +/// document's own `Inter` overrides the bundled one. +#[derive(Debug, Clone, Default)] +pub struct FontBook { + faces: Vec>, + fallback: Vec, + aliases: Vec<(String, String)>, +} + +static BUNDLED_BOOK: LazyLock = LazyLock::new(|| { + let mut book = FontBook::new(); + for fam in BUNDLED { + for bytes in fam.files { + book.register_as(Some(fam.name), FontBytes::Static(bytes)); + } + } + for (alias, family) in [ + ("sans-serif", "Inter"), + ("sans", "Inter"), + ("system-ui", "Inter"), + ("ui", "Inter"), + ("serif", "Playfair Display"), + ("monospace", "JetBrains Mono"), + ("mono", "JetBrains Mono"), + ("cursive", "Dancing Script"), + ("script", "Dancing Script"), + ("fantasy", "Pirata One"), + ("blackletter", "UnifrakturCook"), + ("condensed", "Oswald"), + ("DSEG7", "DSEG7 Classic"), + ("DSEG14", "DSEG14 Classic"), + ("seven-segment", "DSEG7 Classic"), + ("fourteen-segment", "DSEG14 Classic"), + ("lcd", "DSEG7 Classic"), + ("dot-matrix", "Doto"), + ("marker", "Caveat Brush"), + ] { + book.alias(alias, family); + } + book.set_fallback([DEFAULT_FAMILY]); + book +}); + +impl FontBook { + /// An empty book. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The default book: every [`BUNDLED`] family, `Inter` the fallback. + #[must_use] + pub fn bundled() -> &'static FontBook { + &BUNDLED_BOOK + } + + /// A book of one font file (every face in it), which is also the + /// fallback — the single-font setup hosts had before the book. + #[must_use] + pub fn single(bytes: impl Into) -> Self { + let mut book = Self::new(); + let families = book.register(bytes); + book.set_fallback(families); + book + } + + /// Register every face in a font file (or collection) under its own + /// family name; returns the families added. + pub fn register(&mut self, bytes: impl Into) -> Vec { + self.register_as(None, bytes.into()) + } + + /// Register a font file's faces under `family` (or their own names). + pub fn register_as(&mut self, family: Option<&str>, bytes: FontBytes) -> Vec { + let count = match FileRef::new(bytes.as_ref()) { + Ok(FileRef::Collection(c)) => c.len(), + Ok(FileRef::Font(_)) => 1, + Err(_) => 0, + }; + let mut added = Vec::new(); + for index in 0..count { + if let Some(face) = Face::read(bytes.clone(), index, family) { + if !added.contains(&face.family) { + added.push(face.family.clone()); + } + self.faces.push(Arc::new(face)); + } + } + added + } + + /// Add an already-read face (shared with another book). + pub fn insert_face(&mut self, face: Arc) { + self.faces.push(face); + } + + /// Let `name` stand for `family`. + pub fn alias(&mut self, name: &str, family: &str) { + let key = fold(name); + self.aliases.retain(|(a, _)| *a != key); + self.aliases.push((key, family.to_string())); + } + + /// Families tried, in order, when a lookup's family is missing or lacks + /// a glyph. + pub fn set_fallback>(&mut self, families: impl IntoIterator) { + self.fallback = families.into_iter().map(Into::into).collect(); + } + + #[must_use] + pub fn fallback(&self) -> &[String] { + &self.fallback + } + + #[must_use] + pub fn faces(&self) -> &[Arc] { + &self.faces + } + + /// Every family, in registration order. + #[must_use] + pub fn families(&self) -> Vec<&str> { + let mut out: Vec<&str> = Vec::new(); + for f in &self.faces { + if !out.iter().any(|o| fold(o) == fold(&f.family)) { + out.push(&f.family); + } + } + out + } + + /// The family `name` (or its alias) names, when the book has it. + #[must_use] + pub fn family_name(&self, name: &str) -> Option<&str> { + let key = fold(name); + let key = self + .aliases + .iter() + .rev() + .find(|(a, _)| *a == key) + .map_or(key, |(_, f)| fold(f)); + self.faces + .iter() + .rev() + .find(|f| fold(&f.family) == key) + .map(|f| f.family.as_str()) + } + + #[must_use] + pub fn has_family(&self, name: &str) -> bool { + self.family_name(name).is_some() + } + + /// The best face of `family` for `q`, instanced. + fn best_in(&self, family: &str, q: &FontQuery<'_>) -> Option { + let key = fold(family); + let mut best: Option<(&Arc, Score)> = None; + for face in self.faces.iter().rev().filter(|f| fold(&f.family) == key) { + let s = score(face, q); + if best.as_ref().is_none_or(|(_, b)| s < *b) { + best = Some((face, s)); + } + } + best.map(|(face, _)| Resolved::new(face.clone(), q)) + } + + /// The face `q` gets: its family's best match, else the first fallback + /// family's, else any face. + #[must_use] + pub fn resolve(&self, q: &FontQuery<'_>) -> Option { + q.family + .and_then(|f| self.family_name(f)) + .and_then(|f| self.best_in(f, q)) + .or_else(|| self.fallback.iter().find_map(|f| self.best_in(f, q))) + .or_else(|| self.faces.last().map(|f| Resolved::new(f.clone(), q))) + } + + /// The faces tried for each character: the resolved face, then each + /// fallback family (instanced for the same style), no face twice. + #[must_use] + pub fn chain(&self, q: &FontQuery<'_>) -> Chain { + let mut faces: Vec = Vec::new(); + let mut push = |r: Option| { + if let Some(r) = r + && !faces.iter().any(|f| f.face.id == r.face.id) + { + faces.push(r); + } + }; + push(self.resolve(q)); + for f in &self.fallback { + push(self.best_in(f, q)); + } + Chain { faces } + } +} + +/// (italic mismatch, width distance, weight distance); lower is better. +type Score = (u8, f32, f32); + +/// How well `face` matches `q`, lower is better: (italic mismatch, width +/// distance, weight distance — CSS font matching's order and direction). +fn score(face: &Face, q: &FontQuery<'_>) -> Score { + // A real italic beats an italic axis beats a synthetic slant. + let italic = match (q.italic, face.italic) { + (true, true) | (false, false) => 0, + (true, false) if face.can_italic() => 1, + _ => 2, + }; + let (wlo, whi) = face.width; + let width = if q.width < wlo { + (wlo - q.width) + if q.width <= 100.0 { 1000.0 } else { 0.0 } + } else if q.width > whi { + (q.width - whi) + if q.width > 100.0 { 1000.0 } else { 0.0 } + } else { + 0.0 + }; + let (lo, hi) = face.weight; + let w = q.weight; + let weight = if (lo..=hi).contains(&w) { + 0.0 + } else if w > 500.0 { + if lo > w { lo - w } else { 1000.0 + (w - hi) } + } else if w < 400.0 { + if hi < w { w - hi } else { 1000.0 + (lo - w) } + } else if lo > w && lo <= 500.0 { + lo - w + } else if hi < w { + 500.0 + (w - hi) + } else { + 1000.0 + (lo - w) + }; + (italic, width, weight) +} + +// ── resolved faces ─────────────────────────────────────────────────────── + +/// A face instanced for a query: its variation location and whatever has +/// to be synthesised. +#[derive(Debug, Clone)] +pub struct Resolved { + pub face: Arc, + location: Location, + /// Normalized design coordinates (F2Dot14 bits, fvar order) — what a + /// glyph-run API takes. Empty for the default instance. + pub coords: Vec, + /// Draw it slanted: the query wanted italic and the face has none. + pub synthetic_italic: bool, + /// Embolden it: the query wanted ≥ 200 more weight than the face has. + pub synthetic_bold: bool, +} + +impl Resolved { + fn new(face: Arc, q: &FontQuery<'_>) -> Self { + let mut settings: Vec<(&str, f32)> = Vec::new(); + if let Some((lo, _, hi)) = face.wght { + settings.push(("wght", q.weight.clamp(lo, hi))); + } + if let Some((lo, _, hi)) = face.wdth { + settings.push(("wdth", q.width.clamp(lo, hi))); + } + let mut synthetic_italic = false; + if q.italic && !face.italic { + if let Some((lo, _, hi)) = face.ital { + settings.push(("ital", hi.max(lo))); + } else if let Some((lo, _, _)) = face.slnt.filter(|(lo, _, _)| *lo < 0.0) { + settings.push(("slnt", lo.max(-12.0))); + } else { + synthetic_italic = true; + } + } + let location = face.font().map_or_else(Location::default, |f| { + f.axes().location(settings.iter().copied()) + }); + let coords: Vec = location.coords().iter().map(|c| c.to_bits()).collect(); + let coords = if coords.iter().all(|c| *c == 0) { + Vec::new() + } else { + coords + }; + let synthetic_bold = q.weight >= 600.0 && face.weight.1 + 200.0 <= q.weight; + Self { + face, + location, + coords, + synthetic_italic, + synthetic_bold, + } + } + + #[must_use] + pub fn font(&self) -> Option> { + self.face.font() + } + + #[must_use] + pub fn location(&self) -> LocationRef<'_> { + LocationRef::from(&self.location) + } + + /// The glyph for `ch`, when the face has one. + #[must_use] + pub fn glyph(&self, ch: char) -> Option { + self.font()?.charmap().map(ch).filter(|g| g.to_u32() != 0) + } + + /// The synthetic-bold growth each side at `size`, px (0 without). + #[must_use] + pub fn embolden(&self, size: f64) -> f64 { + if self.synthetic_bold { + SYNTHETIC_BOLD * size + } else { + 0.0 + } + } + + /// `gid`'s advance at `size` px (a synthetic bold is that much wider). + #[must_use] + pub fn advance(&self, gid: GlyphId, size: f64) -> f64 { + let Some(font) = self.font() else { + return size * 0.6; + }; + let gm = font.glyph_metrics(Size::new(size.max(1.0) as f32), self.location()); + f64::from(gm.advance_width(gid).unwrap_or_default()) + 2.0 * self.embolden(size) + } + + /// Ascent at `size`, px. + #[must_use] + pub fn ascent(&self, size: f64) -> f64 { + self.font().map_or(size * 0.8, |f| { + f64::from( + f.metrics(Size::new(size.max(1.0) as f32), self.location()) + .ascent, + ) + }) + } + + /// Descent at `size`, px (negative: below the baseline). + #[must_use] + pub fn descent(&self, size: f64) -> f64 { + self.font().map_or(-size * 0.2, |f| { + f64::from( + f.metrics(Size::new(size.max(1.0) as f32), self.location()) + .descent, + ) + }) + } + + /// The per-glyph transform a synthetic italic needs (y-down space). + #[must_use] + pub fn skew(&self) -> Affine { + if self.synthetic_italic { + Affine::new([1.0, 0.0, -SYNTHETIC_SKEW, 1.0, 0.0, 0.0]) + } else { + Affine::IDENTITY + } + } + + /// `gid`'s outline at `size` px, y down, pen at the origin on the + /// baseline — instanced, slanted and emboldened as drawn. + #[must_use] + pub fn outline(&self, gid: GlyphId, size: f64) -> Option { + let font = self.font()?; + let glyph = font.outline_glyphs().get(gid)?; + let mut pen = Pen(BezPath::new()); + glyph + .draw( + DrawSettings::unhinted(Size::new(size.max(1.0) as f32), self.location()), + &mut pen, + ) + .ok()?; + let mut path = pen.0; + let grow = self.embolden(size); + if grow > 0.0 { + path = kurbo::expand_path( + &path, + kurbo::Diagonal2::new(grow, grow * 0.5), + kurbo::Join::Round, + 4.0, + 0.05, + ); + path.apply_affine(Affine::translate((grow, 0.0))); + } + if self.synthetic_italic { + path.apply_affine(self.skew()); + } + Some(path) + } +} + +struct Pen(BezPath); + +impl OutlinePen for Pen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((f64::from(x), -f64::from(y))); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((f64::from(x), -f64::from(y))); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.quad_to( + (f64::from(cx0), -f64::from(cy0)), + (f64::from(x), -f64::from(y)), + ); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to( + (f64::from(cx0), -f64::from(cy0)), + (f64::from(cx1), -f64::from(cy1)), + (f64::from(x), -f64::from(y)), + ); + } + fn close(&mut self) { + self.0.close_path(); + } +} + +/// The faces a style tries, in order (see [`FontBook::chain`]). +#[derive(Debug, Clone, Default)] +pub struct Chain { + pub faces: Vec, +} + +impl Chain { + /// The face the style resolved to. + #[must_use] + pub fn primary(&self) -> Option<&Resolved> { + self.faces.first() + } + + /// The first face with a glyph for `ch` and that glyph; the primary + /// face's missing glyph when none has one. + #[must_use] + pub fn pick(&self, ch: char) -> Option<(&Resolved, GlyphId)> { + self.faces + .iter() + .find_map(|f| f.glyph(ch).map(|g| (f, g))) + .or_else(|| self.primary().map(|f| (f, GlyphId::new(0)))) + } + + /// How wide `text` sets at `size` px with `spacing` px after each + /// character (newlines skipped). + #[must_use] + pub fn advance(&self, text: &str, size: f64, spacing: f64) -> f64 { + text.chars() + .filter(|c| *c != '\n') + .map(|c| self.pick(c).map_or(size * 0.6, |(f, g)| f.advance(g, size)) + spacing) + .sum() + } + + /// One line of `text` as a single path, pen from the origin on the + /// baseline; returns the path and its advance. + #[must_use] + pub fn line(&self, text: &str, size: f64, spacing: f64) -> (BezPath, f64) { + let mut out = BezPath::new(); + let mut x = 0.0; + for c in text.chars().filter(|c| *c != '\n') { + let Some((face, gid)) = self.pick(c) else { + continue; + }; + if let Some(p) = face.outline(gid, size) { + out.extend((Affine::translate((x, 0.0)) * p).elements().iter().copied()); + } + x += face.advance(gid, size) + spacing; + } + (out, x) + } +} diff --git a/crates/frame/src/render/fonts.rs b/crates/frame/src/render/fonts.rs new file mode 100644 index 00000000..e63351f3 --- /dev/null +++ b/crates/frame/src/render/fonts.rs @@ -0,0 +1,90 @@ +//! Fonts for rendering: the host's [`FontBook`], plus the fonts a +//! document carries as `font/*` assets. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::document::proto::Scene; +pub use crate::render::font_book::{ + BUNDLED, BundledFamily, Chain, DEFAULT_FAMILY, Face, FontBook, FontBytes, FontQuery, FontSpec, + Resolved, StyleHints, parse_style, +}; + +pub use crate::render::anyrender_renderer::text_font; + +/// Whether an asset's mime type is a font. +#[must_use] +pub fn is_font_mime(mime: &str) -> bool { + let m = mime.to_ascii_lowercase(); + m.starts_with("font/") + || matches!( + m.as_str(), + "application/font-sfnt" + | "application/x-font-ttf" + | "application/x-font-otf" + | "application/vnd.ms-opentype" + ) +} + +/// Faces read from document assets, by asset id — read once, so their ids +/// (and every glyph cache keyed on them) stay put frame to frame. +type Faces = Arc>>; + +static DOC_FACES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +fn doc_faces(id: &str, bytes: &[u8]) -> Faces { + if let Some(hit) = DOC_FACES.lock().ok().and_then(|c| c.get(id).cloned()) { + return hit; + } + let mut book = FontBook::new(); + book.register(bytes.to_vec()); + let faces = Arc::new(book.faces().to_vec()); + if let Ok(mut c) = DOC_FACES.lock() { + if c.len() > 256 { + c.clear(); + } + c.insert(id.to_string(), faces.clone()); + } + faces +} + +/// `base` with the fonts `scene` carries (its `font/*` assets) registered +/// over it — a shared surface brings its own lettering. Borrowed as is +/// when the document has none. +#[must_use] +pub fn book_for_scene<'a>(base: &'a FontBook, scene: &Scene) -> Cow<'a, FontBook> { + let mut fonts = scene + .assets + .values() + .filter(|a| is_font_mime(&a.mime)) + .peekable(); + if fonts.peek().is_none() { + return Cow::Borrowed(base); + } + let mut book = base.clone(); + for asset in fonts { + for face in doc_faces(&asset.id.0, &asset.bytes).iter() { + book.insert_face(face.clone()); + } + } + Cow::Owned(book) +} + +/// The advance width of `text` set in `font` at `size` px with +/// `letter_spacing` px after each character, measured the way the painter +/// sets it (the resolved face's instance, per-glyph fallback, synthetic +/// bold) — so a host puts a caret exactly where the painted glyphs end. +#[must_use] +pub fn text_advance( + fonts: &FontBook, + font: &FontSpec, + text: &str, + size: f64, + letter_spacing: f64, +) -> f64 { + fonts + .chain(&font.query()) + .advance(text, size.max(1.0), letter_spacing) +} diff --git a/crates/frame/src/render/fx/CONTRACT.md b/crates/frame/src/render/fx/CONTRACT.md new file mode 100644 index 00000000..3fd60a29 --- /dev/null +++ b/crates/frame/src/render/fx/CONTRACT.md @@ -0,0 +1,265 @@ +# Effect shader contract + +A node's `shader={{"shader":"","params":{…}}}` runs a WGSL effect over the +node's subtree on the GPU effects stage (`render::fx`, `Mode::Gpu`). `` is +a built-in (`glass`, `crt`, `lighting`, `flicker`, the visualizers, +`knob.3d`) or `asset:`: the +UTF-8 WGSL stored in the document's asset table. Custom and built-in effects +use exactly the same contract. + +## What the stage does + +1. The node's subtree (its own paint and its descendants', with the enclosing + groups, clips, layers and masks reapplied) renders alone into a + transparent, frame-sized texture — the **source**. +2. A pass runs over the node's box (after its world transform — + rotation/scale included — as a pixel rectangle; the rest of the frame + gets the source passed through unchanged, so overflowing children still + draw plainly). It calls your `effect` for every pixel of that rectangle, + in uniform control flow (derivatives — `fwidth`, `dpdx` — are allowed), + and keeps the result inside the box. +3. The result is composited *over* what was painted before the node, in paint + order; later siblings paint on top of it. + +The effect sees only its own subtree, not what's behind the node. Effects +must be optional: renderers without the stage (SVG export, `--fx fallback`, +`--fx off`) draw the subtree plainly. + +## What you write + +The body of one function (plus any helpers of your own): + +```wgsl +fn effect(uv: vec2) -> vec4 +``` + +- `uv` — node-local, `0..1` across the node's box (`(0,0)` = its top-left). +- Return a **premultiplied** RGBA colour. The stage clamps `rgb` to `0..a` + and scales the result by the node's effective opacity (its own × its + ancestors'), as the subtree it replaces would have been. +- Sample with `textureSampleLevel` (or the helpers below). +- Don't declare bindings or entry points; the stage prepends the prelude + below and appends `fx_vs_main`/`fx_fs_main`. Names starting with `fx_` are + reserved. + +## Prelude (in scope for your code) + +```wgsl +struct FxUniforms { + time_s: f32, // seconds since the host started (animates `flicker`) + value: f32, // position 0..1 of the nearest control (this node or + // an ancestor); its default, else 0 + px_per_unit: f32, // target px per node unit (render scale × transform) + param_mask: u32, // bit i set = param slot i given by the document + resolution: vec2, // target size, px + node_size: vec2, // the node's box, node units + to_local_x: vec4, // target px → node-local units (rows of a 2×3; + // `.w` = 1 − the node's effective opacity + to_local_y: vec4, // affine: local = (dot(x.xyz, (px,1)), dot(y.xyz, (px,1)))) + to_px_x: vec4, // node-local units → target px + to_px_y: vec4, + params: array, 4>, // the 16 param slots, `params[i / 4][i % 4]` + inputs: array, 8>, // input slot i: (offset, len) into fx_data at + // inputs[i / 2].xy (even i) / .zw (odd i) +} + +@group(0) @binding(0) var fx: FxUniforms; +@group(0) @binding(1) var src_tex: texture_2d; // the source, straight alpha +@group(0) @binding(2) var src_samp: sampler; // linear, clamp-to-edge +@group(0) @binding(4) var fx_data: array; // the inputs' values +``` + +Helpers: + +| helper | returns | +|---|---| +| `sample_src(uv)` | source colour at node-local `uv`, premultiplied, bilinear | +| `sample_src_px(px)` | source colour at target pixel `px`, premultiplied | +| `uv_to_px(uv)`, `local_to_px(l)`, `px_to_local(px)` | coordinate mappings | +| `param(i)`, `param_set(i)`, `param_or(i, fallback)` | param slot `i` | +| `luminance(rgb)` | Rec. 709 luma | +| `hash11(x)` | cheap `0..1` hash | +| `node_px()` | the node's box in target pixels (`node_size · px_per_unit`) | +| `input_len(IN_X)` | how many values input `x` carries this frame (0 = no data) | +| `input_raw(IN_X, k)` | its value `k` (clamped to its length; 0 with no data) | +| `input_or(IN_X, fallback)` | its first value, or `fallback` with no data — a param, a reading | +| `input_at(IN_X, u)` | read across `u` 0..1 (first value at 0, last at 1), linear | + +## Params + +`params` in the document are named; each name has a fixed slot (and a +`P_` constant). Names not in this table are ignored. + +| slot | name | constant | +|---:|---|---| +| 0 | `strength` | `P_STRENGTH` | +| 1 | `speed` | `P_SPEED` | +| 2 | `amount` | `P_AMOUNT` | +| 3 | `scale` | `P_SCALE` | +| 4 | `angle` | `P_ANGLE` | +| 5 | `elevation` | `P_ELEVATION` | +| 6 | `softness` | `P_SOFTNESS` | +| 7 | `radius` | `P_RADIUS` | +| 8–11 | `tint_r`, `tint_g`, `tint_b`, `tint_a` | `P_TINT_R` … `P_TINT_A` | +| 12–15 | `p0`, `p1`, `p2`, `p3` | `P_P0` … `P_P3` | + +Use `param_or(P_STRENGTH, 0.6)` to give a param a default. + +## Inputs: live data + +A shader can read arrays of live data — feed frames (spectrum bins, a +gain-reduction history, a scope, envelope voices), param values, a curve +provider's series — uploaded every frame. The document names them on the +node, in `ShaderFx::inputs` (additive; absent = none, the old shape): + +```text +shader={{"shader":"viz.comp","params":{…},"inputs":[ + {"name":"threshold","source":{"Param":{"addr":"comp/threshold"}}}, + {"name":"gr","source":{"Stream":{"id":"comp/gr_history","channel":null}}, + "len":128,"map":{"lo":0,"hi":-60}}, + {"name":"left","source":{"Curve":{"provider":"delay.taps","inputs":[…],"series":"left"}}}]}} +``` + +| source | values | +|---|---| +| `Param { addr }` | the param in engineering units (a choice's index, a toggle's 0/1): one value | +| `Stream { id, channel }` | the feed's latest frame — all of it, or `channel`'s value | +| `Curve { provider, inputs, series }` | a curve provider's series (the runtime's registry, as a `Curve` control evaluates it), flattened `x0, y0, x1, y1, …` | +| `Const { values }` | fixed numbers | + +`map: { lo, hi }` maps each value `lo..hi → 0..1` (clamped unless +`unclamped`; `hi < lo` runs the other way), then `len: n` resamples to +exactly `n` values (linear, both ends kept — a fixed-length uniform array's +shape); non-finite values arrive as 0. At most 1024 values per input, 16 +inputs and 4096 floats (16 KiB) per node per frame. + +**Naming.** Every `IN_` your source mentions gets a slot — sorted, +declared by the stage as `const IN_: u32` — and is fed by the input +whose `name` upper-cases to `` (`gr_trace` → `IN_GR_TRACE`). An input +the document doesn't bind, or that has no data yet, reads as empty +(`input_len == 0`), so give every read a default (`input_or`). + +**Who resolves them.** The host: `surface_runtime::Runtime::shader_inputs()` +returns every shader node's values (`render::fx::FxInputs`), which go in +`Frame::inputs`; `Runtime::set_clock(seconds)` is the clock animated curve +providers read. Without a host (a plain render of a document) every input is +empty — except `Const` inputs, which the stage takes from the document +itself (mapped and resampled the same way) when the host gave none: a +shader's fixed look (a preset table) needs no host. Renderers without the +stage ignore inputs entirely: the node's subtree — the vector fallback — +draws. + +## Built-ins + +| id | does | params (defaults) | +|---|---|---| +| `glass` | shallow lens refraction + diagonal specular sheen and top rim | `strength` (0.6) | +| `crt` | scanlines, slight barrel, phosphor bloom, vignette | `strength` (0.7), `scale` = scanline period in node units (3) | +| `lighting` | directional light over a height field from luminance (screen-space normals, so a turning cap keeps its light) | `strength` (0.8), `angle` (135° = from the top-left), `elevation` (45°), `scale` = bump depth (1) | +| `flicker` | tube-lamp brightness jitter from `time_s`; the node's (and its subtree's) `emission` flickers with it | `strength` = depth (0.5), `speed` Hz (10) | + +### Visualizers + +processor's panel shaders (`comp-ui`, `delay-ui`, `reverb-ui`, +`modulation-ui`, `eq-ui`), ported with their maths and colours unchanged +(`wgsl/viz/`; `common.wgsl` is processor's `hotter` / `tonemap`). They draw +their own picture from their inputs and **replace** the subtree — which is +the picture's vector fallback for renderers without the stage. The kit wraps +each as a component (`viz.comp_scope`, `viz.delay_taps`, `viz.reverb_space`, +`viz.mod_lfo`, `viz.eq_glow`; see `surface/kit/KIT.md`). All animate +(`time_s`); colour is `tint_r/g/b` (0..1). + +| id | inputs | params | +|---|---|---| +| `viz.comp` | `threshold`, `ratio`, `knee` (dB), `in_db` (the ball), `gr_db` (≥ 0), `input`, `gr` (traces 0..1, 128), `on`, `grabbable` | `p0` = dB range (60) | +| `viz.delay` | `left`, `right` (taps, `at_s, level` pairs), `lane` (`window_s, beat_s`), `family` (0 Digital … 5 Special), `on` | — (rules its own beat grid) | +| `viz.reverb` | `decay` (s), `predelay` (ms), `density`, `damp`, `mix`, `bpm`, `family` (0 Room … 7 Convolution), `on` | — (rules its own beat grid) | +| `viz.modulation` | `rate` (Hz), `depth`, `mix`, `engine` (0 Chorus … 5 Rotary), `on` | — | +| `eq.glow` | `spectrum` (dB bins, 128), `bands` (`freq_hz, gain_db` pairs), `db_range`, `floor` | `strength` node glow, `amount` spectrum glow, `softness` hue spread, `radius` bloom (all 1); `p0`/`p1` the frequency axis (20, 20 000) | + +### Rendered parts + +| id | does | params | inputs | +|---|---|---|---| +| `knob.3d` | a knob sphere-traced per pixel from a distance field: cap, skirt, insert, pointer; PBR-ish shading under the kit's light; turns with `value`. **Replaces** the subtree (the vector `knob.render` knob, its fallback) | see below | `shape`, `skirt`, `insert`, `pointer`, `light` (`Const`), `value` | + +**`knob.3d`.** The knob stands in the middle of the node's box: its +footprint (skirt, or the cap without one) is `radius` × half the box's +shorter side, and the rest of the box holds its lean and its shadow. The +view is oblique — a camera a little in front of the panel (`elevation`° +from straight down): the panel stays square, what stands off it leans up +and shows its front wall. Light: the kit's key from the upper left +(azimuth −35°, 50° up), a cool fill from the lower right, a rim/strip +light behind; a studio environment (softbox, strip, ceiling, lit panel) +for reflections; GGX specular (anisotropic for spun metal: highlights +stretch across the turning rings), a clearcoat lobe, one-tap occlusion in +grooves, a soft shadow on the skirt under the cap, and an analytic contact ++ cast shadow on the panel, faded before the box edge. The ribs, knurl, +beak and pointer turn by `(value − 0.5) × angle`; the light, highlights +and shadow don't. Edges are anti-aliased from the ray's closest approach +(`fwidth`), ribs finer than ~2.5 px average out. Output is premultiplied; +knob units have the footprint's radius 1. + +| slot | meaning (default) | +|---|---| +| `strength` | specular strength (1) | +| `speed` | the cap's anisotropy, 0..1 — spun metal (0) | +| `amount` | the cap's metalness, 0..1 (0) | +| `scale` | the top edge's bevel radius (0.07) | +| `angle` | travel, degrees (300) | +| `elevation` | view tilt, degrees (30) | +| `softness` | the cap's roughness (0.38) | +| `radius` | footprint ÷ half the box (0.66) | +| `tint_r/g/b` | the cap's colour, sRGB 0..1 | +| `tint_a` | the cap's clearcoat, 0..1 (0) | +| `p0` | profile: 0 flat · 1 domed · 2 fluted · 3 knurled · 4 chicken head · 5 teardrop pointer cap · 6 wing (dome + fin) · 7 stepped · 8 fluted body + teardrop insert | +| `p1` | flutes or knurl teeth (0 = the profile's: 8, 60, 12) | +| `p2` | skirt: 0 none · 1 plain · 2 flared · 3 knurled · 4 metal · 5 dished | +| `p3` | pointer: 0 none · 1 line on the top · 2 dot · 3 line down the side too · 4 engraved groove (1) | + +| input (`Const`) | values (defaults) | +|---|---| +| `shape` | cap radius (0.72), height (0.5), dome (0; 0.16 domed), rib depth (0.045; 0.02 knurled), knurl band 0..1 from the panel (1), step radius (0.5), step height (0.14), diamond knurl 0/1 (0), skirt teeth (48) | +| `skirt` | r, g, b, metal, roughness, clearcoat, radius (1), height (0.16) | +| `insert` | r, g, b, metal (1), roughness (0.2), anisotropy, size (0.62), height (0.09) | +| `pointer` | r, g, b, width (0.075), inner and outer end in cap radii (0.3, 0.92) | +| `light` | shadow (1), occlusion (1), fill (1), rim (1), key (1) | +| `value` | the position 0..1, overriding `value` (bind a param with a `map`) | + +The kit's `knob.3d` component (`surface-kit`, `materials::knob3d`) fills +these from a preset table. Cost (RTX 4080, GPU timestamps): 30 knobs in +97-unit boxes ≈ 0.25 ms at 1×, 0.29 ms at 2× — each pass is a single +latency-bound wave, so the cost follows the number of knobs more than +their size. + +## Generators + +An effect that never reads its source — its body mentions neither +`sample_src*` nor `src_tex` (`knob.3d`, the visualizers) — draws its own +picture. The stage doesn't render its node's subtree at all on the GPU +path: no offscreen vello render and no blits, just the effect's pass +(`src_tex` is bound to a transparent texture). So a generator's subtree +doesn't overflow its box there either: it is only the fallback, for +renderers without the stage. + +## Failure + +A shader that doesn't parse, validate or build a pipeline (or an `asset:` id +with no such asset) is reported once on stderr (`frame fx: shader … drawing +its subtree plainly`) and its subtree draws as if it had no shader. Compiled +pipelines are cached by id (asset ids are content hashes). + +## Time + +`time_s` is the host's clock. A scene with a `flicker`, or a shader (built-in +or custom) whose source mentions `time_s`, is *time-dependent* +(`render::fx::time_dependent`): hosts redraw it continuously. + +## Example: invert + +```wgsl +fn effect(uv: vec2) -> vec4 { + let c = sample_src(uv); + return vec4(vec3(c.a) - c.rgb, c.a); +} +``` diff --git a/crates/frame/src/render/fx/mod.rs b/crates/frame/src/render/fx/mod.rs new file mode 100644 index 00000000..f1eecb95 --- /dev/null +++ b/crates/frame/src/render/fx/mod.rs @@ -0,0 +1,250 @@ +//! The effects stage: optional light (`emission`) and shader (`shader`) +//! effects on top of the vector renderer. +//! +//! - [`Mode::Gpu`] — the scene renders as usual; emissive nodes render again in +//! their emission colour at half resolution, are Gaussian-blurred (with +//! downsampling for large radii) and added over the frame (bloom). Shader +//! nodes render their subtree to its own texture, run a WGSL effect over it +//! (built-ins in `wgsl/`, or `asset:` custom WGSL — see `CONTRACT.md`) +//! and composite back in paint order. +//! - [`Mode::Fallback`] — no GPU passes; emission draws as a soft glow in the +//! vector path ([`EffectKind::Glow`](crate::render::anyrender_renderer::EffectKind)), +//! shaders are skipped. The SVG exporter always does this. +//! - [`Mode::Off`] — neither. + +mod passes; +mod stage; + +use std::collections::{BTreeMap, HashMap}; + +pub use crate::render::anyrender_renderer::{ + EffectsPlan, EmissiveDraw, ShaderRegion, flicker_level, +}; +pub use stage::{Check, Frame, SharedPasses, Stage}; + +/// Live shader data for one frame: per shader node, each input's values by +/// input name (see `crate::document::proto::ShaderInput`; hosts resolve them — +/// `crate::surface::runtime::Runtime::shader_inputs`). +pub type FxInputs = HashMap>>; + +/// The most floats one shader region uploads per frame (16 KiB). +pub const MAX_DATA: usize = 4096; + +/// How a renderer treats `emission`/`shader`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Mode { + /// GPU bloom + WGSL shaders. + #[default] + Gpu, + /// Soft vector glows for emission; shaders skipped. + Fallback, + /// Neither. + Off, +} + +impl std::str::FromStr for Mode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "gpu" => Ok(Self::Gpu), + "fallback" => Ok(Self::Fallback), + "off" => Ok(Self::Off), + other => Err(format!("unknown fx mode `{other}` (gpu|fallback|off)")), + } + } +} + +/// The 16 named shader params, in uniform-slot order (`params[i / 4][i % 4]` +/// in WGSL, with `P_` constants). Names not listed are ignored. +pub const PARAM_SLOTS: [&str; 16] = [ + "strength", + "speed", + "amount", + "scale", + "angle", + "elevation", + "softness", + "radius", + "tint_r", + "tint_g", + "tint_b", + "tint_a", + "p0", + "p1", + "p2", + "p3", +]; + +/// The built-in effects, by id: post-effects over a subtree, then the +/// visualizers (which draw their own picture from live inputs and replace +/// the subtree, their vector fallback — see `CONTRACT.md`). +pub const BUILTINS: [&str; 10] = [ + "glass", + "crt", + "lighting", + "flicker", + "viz.comp", + "viz.delay", + "viz.reverb", + "viz.modulation", + "eq.glow", + "knob.3d", +]; + +/// The slot of a param name, if it has one. +#[must_use] +pub fn param_slot(name: &str) -> Option { + PARAM_SLOTS.iter().position(|n| *n == name) +} + +/// Pack a node's params into the uniform layout: 16 floats and a bit mask of +/// the slots the document set. +#[must_use] +pub fn pack_params(params: &BTreeMap) -> ([f32; 16], u32) { + let mut values = [0.0_f32; 16]; + let mut mask = 0_u32; + for (name, v) in params { + if let Some(i) = param_slot(name) { + values[i] = *v as f32; + mask |= 1 << i; + } + } + (values, mask) +} + +/// The WGSL body of a built-in effect. +#[must_use] +pub fn builtin_source(id: &str) -> Option<&'static str> { + Some(match id { + "glass" => include_str!("wgsl/glass.wgsl"), + "crt" => include_str!("wgsl/crt.wgsl"), + "lighting" => include_str!("wgsl/lighting.wgsl"), + "flicker" => include_str!("wgsl/flicker.wgsl"), + "viz.comp" => concat!( + include_str!("wgsl/viz/common.wgsl"), + include_str!("wgsl/viz/comp.wgsl") + ), + "viz.delay" => concat!( + include_str!("wgsl/viz/common.wgsl"), + include_str!("wgsl/viz/delay.wgsl") + ), + "viz.reverb" => concat!( + include_str!("wgsl/viz/common.wgsl"), + include_str!("wgsl/viz/reverb.wgsl") + ), + "viz.modulation" => concat!( + include_str!("wgsl/viz/common.wgsl"), + include_str!("wgsl/viz/modulation.wgsl") + ), + "eq.glow" => concat!( + include_str!("wgsl/viz/common.wgsl"), + include_str!("wgsl/viz/eq_glow.wgsl") + ), + "knob.3d" => include_str!("wgsl/knob3d.wgsl"), + _ => return None, + }) +} + +/// Whether an effect body draws its own picture without reading its source +/// (no `sample_src*`, no `src_tex`): the stage then skips rendering the +/// node's subtree for it (see `CONTRACT.md`, "Generators"). +#[must_use] +pub fn generates(body: &str) -> bool { + !body.contains("sample_src") && !body.contains("src_tex") +} + +/// The inputs an effect body reads: every `IN_` it mentions, sorted, +/// at most 16 — slot `i` of the uniform table is the `i`-th. +#[must_use] +pub fn effect_inputs(body: &str) -> Vec { + let bytes = body.as_bytes(); + let ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_'; + let mut names = std::collections::BTreeSet::new(); + let mut i = 0; + while let Some(at) = body[i..].find("IN_") { + let start = i + at; + let mut end = start + 3; + while end < bytes.len() && ident(bytes[end]) { + end += 1; + } + if (start == 0 || !ident(bytes[start - 1])) && end > start + 3 { + names.insert(body[start..end].to_string()); + } + i = end; + } + names.into_iter().take(16).collect() +} + +/// The effect body for `id` in `doc`: a built-in, or the UTF-8 WGSL of the +/// asset an `asset:` names. +#[must_use] +pub fn effect_source(doc: &crate::document::proto::Scene, id: &str) -> Option { + if let Some(asset) = id.strip_prefix("asset:") { + let a = doc.asset(&crate::document::proto::AssetId(asset.to_string()))?; + return String::from_utf8(a.bytes.clone()).ok(); + } + builtin_source(id).map(str::to_string) +} + +/// A complete effect module: the contract prelude, the effect, the entry +/// points. +#[must_use] +pub fn effect_module(body: &str) -> String { + use std::fmt::Write as _; + let mut consts = String::new(); + for (i, name) in effect_inputs(body).iter().enumerate() { + let _ = writeln!(consts, "const {name}: u32 = {i}u;"); + } + format!( + "{}\n{consts}\n{body}\n{}", + include_str!("wgsl/prelude.wgsl"), + include_str!("wgsl/entry.wgsl") + ) +} + +/// Pack a region's live inputs: the slot table (`(offset, len)` per slot, +/// in [`effect_inputs`] order) and the values back to back, at most +/// [`MAX_DATA`] floats (later inputs are cut short, then empty). A `Const` +/// input the host didn't resolve is taken from the document. +#[must_use] +pub fn pack_inputs( + slots: &[String], + inputs: &[crate::document::proto::ShaderInput], + data: Option<&BTreeMap>>, +) -> ([[u32; 4]; 8], Vec) { + let mut table = [[0_u32; 4]; 8]; + let mut values = Vec::new(); + for (i, slot) in slots.iter().enumerate().take(16) { + let Some(input) = inputs.iter().find(|n| n.constant_name() == *slot) else { + continue; + }; + // A `Const` input needs no host: its values are in the document. + let constant; + let v = match (data.and_then(|d| d.get(&input.name)), &input.source) { + (Some(v), _) => v, + (None, crate::document::proto::ShaderInputSource::Const { values }) => { + constant = input.shape(values); + &constant + } + (None, _) => continue, + }; + let take = v.len().min(MAX_DATA - values.len()); + let offset = values.len() as u32; + values.extend_from_slice(&v[..take]); + table[i / 2][(i % 2) * 2] = offset; + table[i / 2][(i % 2) * 2 + 1] = take as u32; + } + (table, values) +} + +/// Whether `doc` under `root` needs continuous redraws with effects on (a +/// time-dependent shader or flickering light). +#[must_use] +pub fn time_dependent( + doc: &crate::document::proto::Scene, + root: crate::document::proto::NodeId, +) -> bool { + let (_, plan) = crate::render::build_scene_primitives_and_effects(doc, root); + plan.time_dependent(|id| effect_source(doc, id)) +} diff --git a/crates/frame/src/render/fx/passes.rs b/crates/frame/src/render/fx/passes.rs new file mode 100644 index 00000000..37bc317e --- /dev/null +++ b/crates/frame/src/render/fx/passes.rs @@ -0,0 +1,642 @@ +//! GPU pipelines of the effects stage and the fullscreen passes over them. + +use std::collections::{HashMap, HashSet}; + +use vello::wgpu; +use wgpu::util::DeviceExt as _; + +/// The frame's texture format (vello's target). +pub(super) const TARGET: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +/// Blur intermediates: premultiplied light, with headroom. +pub(super) const LIGHT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; + +/// `Pass` in `util.wgsl`. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub(super) struct PassUniforms { + pub dir: [f32; 2], + pub sigma: f32, + pub taps: i32, + pub step: u32, + pub premul: u32, + pub gain: f32, + pub inside: f32, +} + +impl PassUniforms { + fn bytes(&self) -> Vec { + let mut b = Vec::with_capacity(32); + for f in self.dir { + b.extend_from_slice(&f.to_le_bytes()); + } + b.extend_from_slice(&self.sigma.to_le_bytes()); + b.extend_from_slice(&self.taps.to_le_bytes()); + b.extend_from_slice(&self.step.to_le_bytes()); + b.extend_from_slice(&self.premul.to_le_bytes()); + b.extend_from_slice(&self.gain.to_le_bytes()); + b.extend_from_slice(&self.inside.to_le_bytes()); + b + } +} + +/// `FxUniforms` in `prelude.wgsl` (288 bytes). +#[derive(Clone, Copy, Default)] +pub(super) struct FxUniforms { + pub time_s: f32, + pub value: f32, + pub px_per_unit: f32, + pub param_mask: u32, + pub resolution: [f32; 2], + pub node_size: [f32; 2], + pub to_local: [[f32; 4]; 2], + pub to_px: [[f32; 4]; 2], + pub params: [f32; 16], + /// `(offset, len)` into the data buffer per input slot, two per row. + pub inputs: [[u32; 4]; 8], +} + +impl FxUniforms { + fn bytes(&self) -> Vec { + let head = [self.time_s, self.value, self.px_per_unit]; + let tail = self + .resolution + .iter() + .chain(&self.node_size) + .chain(self.to_local.iter().chain(&self.to_px).flatten()) + .chain(&self.params); + let mut b = Vec::with_capacity(288); + head.iter() + .for_each(|v| b.extend_from_slice(&v.to_le_bytes())); + b.extend_from_slice(&self.param_mask.to_le_bytes()); + tail.for_each(|v| b.extend_from_slice(&v.to_le_bytes())); + self.inputs + .iter() + .flatten() + .for_each(|v| b.extend_from_slice(&v.to_le_bytes())); + debug_assert_eq!(b.len(), 288); + b + } +} + +/// How a pass writes its target. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(super) enum Blend { + /// Replace. + None, + /// Premultiplied source over the target. + Over, + /// Add the source colour, keep the target alpha. + Add, +} + +impl Blend { + fn state(self) -> Option { + match self { + Self::None => None, + Self::Over => Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + Self::Add => Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }), + } + } +} + +/// A compiled effect: its pipeline, the input slots its source reads, and +/// whether it generates (never reads its source); `None` = failed. +type Compiled = Option<(wgpu::RenderPipeline, Vec, bool)>; + +/// Clones share one cache of compiled effects: see `SharedPasses`. +#[derive(Clone)] +pub(super) struct Passes { + layout: wgpu::BindGroupLayout, + pipeline_layout: wgpu::PipelineLayout, + sampler: wgpu::Sampler, + blit: wgpu::RenderPipeline, + down: wgpu::RenderPipeline, + blur: wgpu::RenderPipeline, + bloom: wgpu::RenderPipeline, + /// Compiled effects by shader id, with the input slots their source + /// reads; `None` = failed (drawn plainly). + effects: HashMap)>>, + /// Compiled effects that never read their source (`sample_src`, + /// `src_tex`): generators, drawing their own picture. + generators: HashSet, + /// Bound as the data buffer where a pass has no inputs. + empty_data: wgpu::Buffer, + /// Shader ids whose failure was already reported. + reported: HashSet, + /// The effects compiled by this and every clone of it, by shader id. + compiled: std::sync::Arc>>, +} + +impl Passes { + pub fn new(device: &wgpu::Device) -> Self { + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("frame-fx"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // A second texture (the bloom's emitter mask); other passes + // bind their source again. + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // Effect inputs (`fx_data`); other passes bind a stub. + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let empty_data = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("frame-fx-no-data"), + contents: &[0; 16], + usage: wgpu::BufferUsages::STORAGE, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("frame-fx"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("frame-fx-linear"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let util = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("frame-fx-util"), + source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/util.wgsl").into()), + }); + let make = |fs: &str, format, blend: Blend| { + pipeline( + device, + &pipeline_layout, + &util, + "vs_main", + fs, + format, + blend, + ) + }; + Self { + blit: make("fs_blit", TARGET, Blend::Over), + down: make("fs_down", LIGHT, Blend::None), + blur: make("fs_blur", LIGHT, Blend::None), + bloom: make("fs_bloom", TARGET, Blend::Add), + layout, + pipeline_layout, + sampler, + effects: HashMap::new(), + generators: HashSet::new(), + empty_data, + reported: HashSet::new(), + compiled: std::sync::Arc::default(), + } + } + + /// The compiled effect for `id` (compiling and caching it on first use); + /// `None` when it has no source or fails to compile — reported once. + pub fn effect( + &mut self, + device: &wgpu::Device, + doc: &crate::document::proto::Scene, + id: &str, + ) -> Option<&wgpu::RenderPipeline> { + if !self.effects.contains_key(id) { + let Some(body) = super::effect_source(doc, id) else { + // Not cached: a missing asset may appear on a reload. + self.report( + id, + &format!("no such shader (built-ins: {})", super::BUILTINS.join(", ")), + ); + return None; + }; + // Built-in and asset ids (content hashes) name fixed sources, so + // the result — pipeline or failure — is cached for good, and + // shared with the stages this one's passes are shared with. + let known = self.compiled.lock().ok().and_then(|c| c.get(id).cloned()); + let entry = match known { + Some(entry) => entry, + None => { + let compiled = + compile(device, &self.pipeline_layout, &super::effect_module(&body)); + let entry = compiled + .map_err(|e| self.report(id, &e)) + .ok() + .map(|p| (p, super::effect_inputs(&body), super::generates(&body))); + if let Ok(mut c) = self.compiled.lock() { + c.insert(id.to_string(), entry.clone()); + } + entry + } + }; + if entry.as_ref().is_some_and(|(_, _, generates)| *generates) { + self.generators.insert(id.to_string()); + } + self.effects + .insert(id.to_string(), entry.map(|(p, slots, _)| (p, slots))); + } + self.cached_effect(id) + } + + /// Whether `id` was compiled by this or a clone of it. + #[cfg(test)] + // Test helper whose tests were cut in Studio (FastTrackStudios/studio#203). + #[allow(dead_code)] + pub fn has_compiled(&self, id: &str) -> bool { + self.compiled + .lock() + .is_ok_and(|c| c.get(id).is_some_and(Option::is_some)) + } + + /// The effect for `id` if it's already compiled. + pub fn cached_effect(&self, id: &str) -> Option<&wgpu::RenderPipeline> { + self.effects + .get(id) + .and_then(Option::as_ref) + .map(|(p, _)| p) + } + + /// Whether a compiled effect draws without reading its source. + pub fn generates(&self, id: &str) -> bool { + self.generators.contains(id) + } + + /// The input slots (`IN_`, in slot order) a compiled effect reads. + pub fn effect_slots(&self, id: &str) -> &[String] { + self.effects + .get(id) + .and_then(Option::as_ref) + .map_or(&[], |(_, s)| s.as_slice()) + } + + fn report(&mut self, id: &str, error: &str) { + if self.reported.insert(id.to_string()) { + eprintln!("frame fx: shader `{id}` unavailable, drawing its subtree plainly: {error}"); + } + } + + fn bind( + &self, + device: &wgpu::Device, + uniforms: &[u8], + src: &wgpu::TextureView, + ) -> wgpu::BindGroup { + self.bind2(device, uniforms, src, src, &self.empty_data) + } + + fn bind2( + &self, + device: &wgpu::Device, + uniforms: &[u8], + src: &wgpu::TextureView, + second: &wgpu::TextureView, + data: &wgpu::Buffer, + ) -> wgpu::BindGroup { + let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("frame-fx-uniforms"), + contents: uniforms, + usage: wgpu::BufferUsages::UNIFORM, + }); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("frame-fx"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(src), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(second), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: data.as_entire_binding(), + }, + ], + }) + } + + fn run( + encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::RenderPipeline, + bind: &wgpu::BindGroup, + dst: &wgpu::TextureView, + clear: bool, + ) { + Self::run_in(encoder, pipeline, bind, dst, clear, None); + } + + /// [`Self::run`], only inside `scissor` (`[x, y, w, h]`, target px). + fn run_in( + encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::RenderPipeline, + bind: &wgpu::BindGroup, + dst: &wgpu::TextureView, + clear: bool, + scissor: Option<[u32; 4]>, + ) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("frame-fx-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: dst, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: if clear { + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) + } else { + wgpu::LoadOp::Load + }, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bind, &[]); + if let Some([x, y, w, h]) = scissor { + pass.set_scissor_rect(x, y, w, h); + } + pass.draw(0..3, 0..1); + } + + /// Straight-alpha `src` over `dst`, inside each of `rects` (`[x, y, w, + /// h]`, target px). + pub fn blit_over_in( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + src: &wgpu::TextureView, + dst: &wgpu::TextureView, + rects: &[[u32; 4]], + ) { + let bind = self.bind(device, &PassUniforms::default().bytes(), src); + for r in rects.iter().filter(|r| r[2] > 0 && r[3] > 0) { + Self::run_in(encoder, &self.blit, &bind, dst, false, Some(*r)); + } + } + + /// Straight-alpha `src` over `dst`. + pub fn blit_over( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + src: &wgpu::TextureView, + dst: &wgpu::TextureView, + ) { + let bind = self.bind(device, &PassUniforms::default().bytes(), src); + Self::run(encoder, &self.blit, &bind, dst, false); + } + + /// Straight-alpha `src` over `dst`, faded to `opacity`. + pub fn blit_over_faded( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + src: &wgpu::TextureView, + dst: &wgpu::TextureView, + opacity: f32, + ) { + let u = PassUniforms { + premul: 1, + gain: opacity, + ..Default::default() + }; + let bind = self.bind(device, &u.bytes(), src); + Self::run(encoder, &self.blit, &bind, dst, false); + } + + /// `src` box-downsampled by `step` into `dst` (premultiplying first when + /// `premul`). + pub fn down( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + src: &wgpu::TextureView, + dst: &wgpu::TextureView, + step: u32, + premul: bool, + ) { + let u = PassUniforms { + step, + premul: u32::from(premul), + ..Default::default() + }; + let bind = self.bind(device, &u.bytes(), src); + Self::run(encoder, &self.down, &bind, dst, true); + } + + /// One Gaussian axis (`dir` = (1,0) or (0,1)), `sigma` in texels. + pub fn blur( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + src: &wgpu::TextureView, + dst: &wgpu::TextureView, + dir: [f32; 2], + sigma: f32, + ) { + let sigma = sigma.max(0.5); + let taps = (sigma * 3.0).ceil().clamp(1.0, 32.0) as i32; + let u = PassUniforms { + dir, + sigma, + taps, + ..Default::default() + }; + let bind = self.bind(device, &u.bytes(), src); + Self::run(encoder, &self.blur, &bind, dst, true); + } + + /// Blurred light `src` added over `dst`, times `gain` — times `inside` + /// where `mask` (the emitters) covers. + #[allow(clippy::too_many_arguments)] + pub fn bloom( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + src: &wgpu::TextureView, + mask: &wgpu::TextureView, + dst: &wgpu::TextureView, + gain: f32, + inside: f32, + ) { + let u = PassUniforms { + gain, + inside, + ..Default::default() + }; + let bind = self.bind2(device, &u.bytes(), src, mask, &self.empty_data); + Self::run(encoder, &self.bloom, &bind, dst, false); + } + + /// Run the effect `pipeline` over the subtree in `src`, over `dst`, + /// with its input values `data`, inside `scissor` (the node's box). + #[allow(clippy::too_many_arguments)] + pub fn effect_pass( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::RenderPipeline, + uniforms: &FxUniforms, + data: &[f32], + src: &wgpu::TextureView, + dst: &wgpu::TextureView, + scissor: Option<[u32; 4]>, + ) { + if scissor.is_some_and(|s| s[2] == 0 || s[3] == 0) { + return; + } + let buffer = (!data.is_empty()).then(|| { + let bytes: Vec = data.iter().flat_map(|v| v.to_le_bytes()).collect(); + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("frame-fx-data"), + contents: &bytes, + usage: wgpu::BufferUsages::STORAGE, + }) + }); + let bind = self.bind2( + device, + &uniforms.bytes(), + src, + src, + buffer.as_ref().unwrap_or(&self.empty_data), + ); + Self::run_in(encoder, pipeline, &bind, dst, false, scissor); + } +} + +fn pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + module: &wgpu::ShaderModule, + vs: &str, + fs: &str, + format: wgpu::TextureFormat, + blend: Blend, +) -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("frame-fx"), + layout: Some(layout), + vertex: wgpu::VertexState { + module, + entry_point: Some(vs), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fs), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: blend.state(), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} + +/// Compile an effect module; validation errors come back as `Err` instead of +/// reaching the device's uncaptured-error handler. +fn compile( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + source: &str, +) -> Result { + // Parse + validate with naga first for a readable message (and so an + // invalid module never reaches the backend). + let module = + wgpu::naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?; + wgpu::naga::valid::Validator::new( + wgpu::naga::valid::ValidationFlags::all(), + wgpu::naga::valid::Capabilities::default(), + ) + .validate(&module) + .map_err(|e| e.emit_to_string(source))?; + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("frame-fx-effect"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let p = pipeline( + device, + layout, + &module, + "fx_vs_main", + "fx_fs_main", + TARGET, + Blend::Over, + ); + match pollster::block_on(scope.pop()) { + Some(e) => Err(e.to_string()), + None => Ok(p), + } +} diff --git a/crates/frame/src/render/fx/stage.rs b/crates/frame/src/render/fx/stage.rs new file mode 100644 index 00000000..444154d4 --- /dev/null +++ b/crates/frame/src/render/fx/stage.rs @@ -0,0 +1,1384 @@ +//! Running an [`EffectsPlan`] on the GPU around vello. + +use std::collections::{BTreeMap, HashMap}; +use std::ops::Range; + +use crate::document::proto::{NodeId, Scene}; +use vello::{AaConfig, Renderer, wgpu}; + +use super::passes::{FxUniforms, LIGHT, Passes, TARGET}; +use super::{EmissiveDraw, Mode, ShaderRegion, flicker_level, pack_params}; +use crate::render::anyrender_renderer::{ + AffineTransform2D, CollectOptions, PaintPrimitive, build_scene_primitives_and_effects, + build_scene_primitives_with, paint_primitives_into_scene_with, +}; + +/// One frame to render through the effects stage. +#[derive(Clone, Copy)] +pub struct Frame<'a> { + pub doc: &'a Scene, + /// The subtree to paint (usually a page). + pub root: NodeId, + /// Scene → target px. + pub transform: kurbo::Affine, + pub width: u32, + pub height: u32, + /// The colour under everything. + pub base: peniko::Color, + /// The host's fonts (the document's own are added); `None` draws text + /// as baseline bars. + pub fonts: Option<&'a crate::render::FontBook>, + pub mode: Mode, + /// Seconds, for time-dependent shaders (`flicker`, custom ones). + pub time_s: f64, + /// Control positions (0..1) by control node, for a shader's `value`. + pub values: Option<&'a HashMap>, + /// Shader nodes' live inputs this frame (see `CONTRACT.md`, "Inputs"). + pub inputs: Option<&'a super::FxInputs>, + /// Painted last, over everything (a status banner), in target px. + pub overlay: Option<&'a vello::Scene>, + pub antialiasing: AaConfig, + /// Check each vello pass for buffer overflow (a readback of one row, + /// which waits on the GPU) and band it if so. A live host drawing a + /// small surface every frame turns it off once the surface has drawn + /// whole. + pub checked: bool, + /// With `checked`, don't wait for the check: start it, and read it + /// with [`Stage::check`] on a later frame. + pub deferred: bool, +} + +impl<'a> Frame<'a> { + /// `root` of `doc` at `width × height`, identity transform, black base, + /// GPU effects at t = 0. + #[must_use] + pub fn new(doc: &'a Scene, root: NodeId, width: u32, height: u32) -> Self { + Self { + doc, + root, + transform: kurbo::Affine::IDENTITY, + width, + height, + base: peniko::Color::from_rgba8(0, 0, 0, 255), + fonts: None, + mode: Mode::Gpu, + time_s: 0.0, + values: None, + inputs: None, + overlay: None, + antialiasing: AaConfig::Area, + checked: true, + deferred: false, + } + } +} + +/// The effects stage's GPU state: pipelines (with the compiled-shader cache) +/// and intermediate textures, reused across frames. +pub struct Stage { + passes: Passes, + textures: HashMap, + scene: vello::Scene, + time_dependent: bool, + /// Each vello render of the last frame (a segment, a bloom bucket), + /// by name, with what it drew: a frame drawing the same again reuses + /// the texture instead of rendering it. + renders: HashMap, + /// Deferred checks of the vello renders of a frame, not read yet. + pending: Vec, +} + +/// What the deferred checks of a frame (see [`Frame::deferred`]) said. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Check { + /// None outstanding, none overflowed. + Clean, + /// Still on the GPU. + Pending, + /// A render overflowed vello's buffers (or its check failed): it drew + /// nothing, and the frame is to be rendered again checked and waited + /// for, which bands it. + Overflowed, +} + +/// A vello render kept: what went into it and the texture it made. +struct CachedRender { + key: RenderKey, + texture: wgpu::Texture, +} + +/// Everything a render's picture depends on. +#[derive(PartialEq)] +enum RenderKey { + /// A run of primitives painted under a transform. + Prims { + prims: Vec, + transform: kurbo::Affine, + base: peniko::Color, + size: (u32, u32), + }, + /// A bloom bucket's emitters, each at its share of the strength. + Emit { + draws: Vec<(EmissiveDraw, f64)>, + transform: kurbo::Affine, + size: (u32, u32), + }, +} + +/// One device's effect pipelines, for several stages to share: built once, +/// and each effect compiled once by whichever stage needs it first. A host +/// drawing many surfaces on one device (a rack of plugin faces) keeps one +/// beside its renderer — sixteen faces each compiling the same shaders +/// paid 3–5 ms a face on its first frame. +#[derive(Clone)] +pub struct SharedPasses(Passes); + +impl SharedPasses { + #[must_use] + pub fn new(device: &wgpu::Device) -> Self { + Self(Passes::new(device)) + } +} + +impl Stage { + #[must_use] + pub fn new(device: &wgpu::Device) -> Self { + Self::with_passes(&SharedPasses::new(device)) + } + + /// A stage on `passes`' device, sharing its pipelines and compiled + /// effects with every other stage made from them. + #[must_use] + pub fn with_passes(passes: &SharedPasses) -> Self { + Self { + passes: passes.0.clone(), + textures: HashMap::new(), + scene: vello::Scene::new(), + time_dependent: false, + renders: HashMap::new(), + pending: Vec::new(), + } + } + + /// Read the deferred checks the GPU has finished; never waits. On an + /// overflow the kept renders are dropped, so the next frame renders + /// them again rather than reusing what drew nothing. + pub fn check(&mut self, device: &wgpu::Device) -> Check { + let mut overflowed = false; + self.pending.retain(|check| match check.poll(device) { + None => true, + Some(Ok(drew_nothing)) => { + overflowed |= drew_nothing; + false + } + Some(Err(_)) => { + overflowed = true; + false + } + }); + if overflowed { + self.pending.clear(); + self.renders.clear(); + Check::Overflowed + } else if self.pending.is_empty() { + Check::Clean + } else { + Check::Pending + } + } + + /// Whether deferred checks are still out: the host draws again. + #[must_use] + pub fn checking(&self) -> bool { + !self.pending.is_empty() + } + + /// Whether the last frame rendered had a time-dependent effect (a + /// `flicker`, a custom shader reading `time_s`): hosts keep redrawing. + #[must_use] + pub fn time_dependent(&self) -> bool { + self.time_dependent + } + + /// Render `frame`; the result is an `Rgba8Unorm` texture (straight alpha, + /// like vello's output) owned by the stage, valid until the next call. + /// It can be sampled, copied from and used as a render attachment. + /// + /// # Errors + /// When vello fails to render. + pub fn render( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut Renderer, + frame: &Frame<'_>, + ) -> Result { + let (w, h) = (frame.width.clamp(1, 8192), frame.height.clamp(1, 8192)); + let out = self.texture(device, "out", w, h, TARGET); + let mut scene = std::mem::take(&mut self.scene); + self.time_dependent = false; + let result = match frame.mode { + Mode::Fallback | Mode::Off => { + let options = CollectOptions { + emission_glow: frame.mode == Mode::Fallback, + }; + let prims = build_scene_primitives_with(frame.doc, frame.root, options); + self.render_prims( + "all", + prims, + frame.overlay, + frame, + renderer, + device, + queue, + &mut scene, + (w, h), + frame.base, + ) + .map(|kept| copy_whole(device, queue, &kept, &out)) + } + Mode::Gpu => self.render_gpu(device, queue, renderer, frame, &mut scene, &out), + }; + self.scene = scene; + result.map(|()| out) + } + + fn render_gpu( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut Renderer, + frame: &Frame<'_>, + scene: &mut vello::Scene, + out: &wgpu::Texture, + ) -> Result<(), String> { + let (prims, plan) = build_scene_primitives_and_effects(frame.doc, frame.root); + self.time_dependent = plan.time_dependent(|id| super::effect_source(frame.doc, id)); + // Shaders that fail to compile draw plainly: they stay in the main + // segments. + let regions: Vec<&ShaderRegion> = plan + .shaders + .iter() + .filter(|r| self.passes.effect(device, frame.doc, &r.shader).is_some()) + .collect(); + let (w, h) = (out.width(), out.height()); + let out_view = out.create_view(&wgpu::TextureViewDescriptor::default()); + let clear = peniko::Color::TRANSPARENT; + let aa = frame.antialiasing; + + let _ = aa; + if regions.is_empty() { + let kept = self.render_prims( + "all", + prims, + frame.overlay, + frame, + renderer, + device, + queue, + scene, + (w, h), + frame.base, + )?; + copy_whole(device, queue, &kept, out); + } else { + // What a generator's pass binds as its source: never drawn to, + // so transparent (its box's 1 px margin reads it). + let blank_view = regions + .iter() + .any(|r| self.passes.generates(&r.shader)) + .then(|| { + self.texture(device, "blank", 1, 1, TARGET) + .create_view(&wgpu::TextureViewDescriptor::default()) + }); + let mut cursor = 0; + for (i, r) in regions.iter().enumerate() { + let seg = slice_with_context(&prims, cursor..r.range.start); + if i == 0 { + let kept = self.render_prims( + "seg0", + seg, + None, + frame, + renderer, + device, + queue, + scene, + (w, h), + frame.base, + )?; + copy_whole(device, queue, &kept, out); + } else if has_draws(&seg) { + let kept = self.render_prims( + &format!("seg{i}"), + seg, + None, + frame, + renderer, + device, + queue, + scene, + (w, h), + clear, + )?; + let kept_view = kept.create_view(&wgpu::TextureViewDescriptor::default()); + let mut enc = encoder(device); + self.passes + .blit_over(device, &mut enc, &kept_view, &out_view); + queue.submit([enc.finish()]); + } + // A generator (an effect that never reads its source) draws + // its own picture: its subtree, the vector fallback, isn't + // rendered at all — the region costs one pass, not a vello + // render and two blits. + let generator = self.passes.generates(&r.shader); + let sub_view = if generator { + None + } else { + let sub = slice_with_context(&prims, r.range.clone()); + let kept = self.render_prims( + &format!("region{i}"), + sub, + None, + frame, + renderer, + device, + queue, + scene, + (w, h), + clear, + )?; + Some(kept.create_view(&wgpu::TextureViewDescriptor::default())) + }; + let Some(src_view) = sub_view.as_ref().or(blank_view.as_ref()) else { + continue; + }; + if let Some(pipeline) = self.passes.cached_effect(&r.shader) { + let (mut uniforms, box_px) = region_uniforms(r, frame, w, h); + let data = frame.inputs.and_then(|i| i.get(&r.node_id)); + let (table, values) = + super::pack_inputs(self.passes.effect_slots(&r.shader), &r.inputs, data); + uniforms.inputs = table; + let mut enc = encoder(device); + // The effect runs over the node's box only; the rest of + // the subtree (children overflowing it) draws plainly. + self.passes.effect_pass( + device, + &mut enc, + pipeline, + &uniforms, + &values, + src_view, + &out_view, + Some(box_px), + ); + if let Some(sub_view) = &sub_view { + self.passes.blit_over_in( + device, + &mut enc, + sub_view, + &out_view, + &outside(box_px, w, h), + ); + } + queue.submit([enc.finish()]); + } + cursor = r.range.end; + } + let tail = slice_with_context(&prims, cursor..prims.len()); + if has_draws(&tail) || frame.overlay.is_some() { + let kept = self.render_prims( + "tail", + tail, + frame.overlay, + frame, + renderer, + device, + queue, + scene, + (w, h), + clear, + )?; + let kept_view = kept.create_view(&wgpu::TextureViewDescriptor::default()); + let mut enc = encoder(device); + self.passes + .blit_over(device, &mut enc, &kept_view, &out_view); + queue.submit([enc.finish()]); + } + } + + self.bloom( + device, + queue, + renderer, + frame, + scene, + &plan.emissive, + &out_view, + w, + h, + ) + } + + /// Emissive draws, bucketed by radius: each bucket renders at half + /// resolution, blurs at a tight and a wide radius, and adds over `out`. + #[allow(clippy::too_many_arguments)] + fn bloom( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut Renderer, + frame: &Frame<'_>, + scene: &mut vello::Scene, + emissive: &[EmissiveDraw], + out_view: &wgpu::TextureView, + w: u32, + h: u32, + ) -> Result<(), String> { + let mut buckets: BTreeMap> = BTreeMap::new(); + for d in emissive { + let s = d.strength + * d.flicker + .as_ref() + .map_or(1.0, |p| flicker_level(frame.time_s, p)); + if s > 0.0 && d.radius > 0.0 { + buckets + .entry((d.radius * 2.0).round() as i64) + .or_default() + .push((d, s)); + } + } + if buckets.is_empty() { + return Ok(()); + } + let scale = frame.transform.determinant().abs().sqrt(); + let (ew, eh) = (w.div_ceil(2), h.div_ceil(2)); + let to_emit = kurbo::Affine::scale_non_uniform( + f64::from(ew) / f64::from(w), + f64::from(eh) / f64::from(h), + ) * frame.transform; + let clip = kurbo::Rect::new(0.0, 0.0, f64::from(ew), f64::from(eh)); + for (key, draws) in buckets { + let radius = key as f64 / 2.0; + let max_s = draws.iter().map(|(_, s)| *s).fold(0.0, f64::max); + // The bucket's light, rendered once while its emitters hold + // still (a lamp that isn't pulsing). + let name = format!("emit{key}"); + let render_key = RenderKey::Emit { + // By shape: the bucket's strength is applied in the blur, so a + // lamp pulsing (strength only) keeps its render. + draws: draws + .iter() + .map(|(d, s)| { + ( + EmissiveDraw { + strength: 0.0, + ..(*d).clone() + }, + s / max_s, + ) + }) + .collect(), + transform: to_emit, + size: (ew, eh), + }; + let emit = match self.renders.get(&name) { + Some(kept) if kept.key == render_key => kept.texture.clone(), + _ => { + let emit = self.kept_texture(device, &name, ew, eh); + self.render_emit( + frame, renderer, device, queue, scene, &draws, max_s, to_emit, clip, &emit, + )?; + self.renders.insert( + name, + CachedRender { + key: render_key, + texture: emit.clone(), + }, + ); + emit + } + }; + let emit_view = emit.create_view(&wgpu::TextureViewDescriptor::default()); + // Emission radius is a CSS-style blur radius (≈ 2σ), in scene px. + let sigma = radius * 0.5 * scale * f64::from(ew) / f64::from(w); + for (sigma, weight) in [(sigma * 0.3, 0.9), (sigma, 0.9)] { + self.blur_add( + device, + queue, + &emit_view, + (ew, eh), + sigma as f32, + (max_s * weight) as f32, + out_view, + ); + } + } + Ok(()) + } + + /// One bloom bucket's emitters, painted into `emit`. + #[allow(clippy::too_many_arguments)] + fn render_emit( + &mut self, + frame: &Frame<'_>, + renderer: &mut Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &mut vello::Scene, + draws: &[(&EmissiveDraw, f64)], + max_s: f64, + to_emit: kurbo::Affine, + clip: kurbo::Rect, + emit: &wgpu::Texture, + ) -> Result<(), String> { + { + scene.reset(); + for (d, s) in draws { + scene.push_layer( + peniko::Fill::NonZero, + peniko::Mix::Normal, + (s / max_s) as f32, + kurbo::Affine::IDENTITY, + &clip, + ); + for (world, c) in &d.clips { + push_clip(scene, c, to_emit * to_kurbo(world)); + } + paint_primitives_into_scene_with( + scene, + &d.primitives, + to_emit * to_kurbo(&d.world), + frame + .fonts + .map(|f| crate::render::book_for_scene(f, frame.doc)) + .as_deref(), + ); + for _ in &d.clips { + scene.pop_layer(); + } + scene.pop_layer(); + } + vello_into( + Checking::of(frame, &mut self.pending), + renderer, + device, + queue, + scene, + emit, + peniko::Color::TRANSPARENT, + frame.antialiasing, + )?; + } + Ok(()) + } + + /// Blur the straight-alpha light in `src` by `sigma` texels (downsampling + /// while it's wide) and add it over `dst` × `gain`. + #[allow(clippy::too_many_arguments)] + fn blur_add( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + src: &wgpu::TextureView, + (mut lw, mut lh): (u32, u32), + mut sigma: f32, + gain: f32, + dst: &wgpu::TextureView, + ) { + const MAX_SIGMA: f32 = 6.0; + let mut enc = encoder(device); + let step = if sigma > MAX_SIGMA && lw > 8 && lh > 8 { + 2 + } else { + 1 + }; + (lw, lh, sigma) = (lw.div_ceil(step), lh.div_ceil(step), sigma / step as f32); + let mut level_view = self + .texture(device, &format!("lvl-{lw}x{lh}"), lw, lh, LIGHT) + .create_view(&wgpu::TextureViewDescriptor::default()); + self.passes + .down(device, &mut enc, src, &level_view, step, true); + while sigma > MAX_SIGMA && lw > 8 && lh > 8 { + (lw, lh, sigma) = (lw.div_ceil(2), lh.div_ceil(2), sigma / 2.0); + let next_view = self + .texture(device, &format!("lvl-{lw}x{lh}"), lw, lh, LIGHT) + .create_view(&wgpu::TextureViewDescriptor::default()); + self.passes + .down(device, &mut enc, &level_view, &next_view, 2, false); + level_view = next_view; + } + let a = self.texture(device, &format!("blur-a-{lw}x{lh}"), lw, lh, LIGHT); + let b = self.texture(device, &format!("blur-b-{lw}x{lh}"), lw, lh, LIGHT); + let (a_view, b_view) = ( + a.create_view(&wgpu::TextureViewDescriptor::default()), + b.create_view(&wgpu::TextureViewDescriptor::default()), + ); + self.passes + .blur(device, &mut enc, &level_view, &a_view, [1.0, 0.0], sigma); + self.passes + .blur(device, &mut enc, &a_view, &b_view, [0.0, 1.0], sigma); + self.passes + .bloom(device, &mut enc, &b_view, src, dst, gain, BLOOM_INSIDE); + queue.submit([enc.finish()]); + } + + /// A cached texture by role and size (recreated when either changes). + /// Paint `prims` with vello into the texture kept as `name`, unless it + /// already holds exactly them (same primitives, transform, base, size): + /// a still face's body, rendered once. `overlay` (a host's drawing + /// over the frame) is never kept. + #[allow(clippy::too_many_arguments)] + fn render_prims( + &mut self, + name: &str, + prims: Vec, + overlay: Option<&vello::Scene>, + frame: &Frame<'_>, + renderer: &mut Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &mut vello::Scene, + (w, h): (u32, u32), + base: peniko::Color, + ) -> Result { + let key = RenderKey::Prims { + prims, + transform: frame.transform, + base, + size: (w, h), + }; + if overlay.is_none() + && let Some(kept) = self.renders.get(name) + && kept.key == key + { + return Ok(kept.texture.clone()); + } + // Only a run changed (a lamp, a needle): render that run alone and + // put it between the kept renders of what's under and over it. + if overlay.is_none() + && let Some((a, b)) = self.changed_run(name, &key) + { + let RenderKey::Prims { prims, .. } = &key else { + unreachable!() + }; + let n = prims.len(); + let under = self.render_run( + &format!("{name}/under"), + slice_with_context(prims, 0..a), + frame, + renderer, + device, + queue, + scene, + (w, h), + base, + )?; + // A run that is one faded layer (a lamp's glow, pulsing): + // rendered opaque once, faded as it's laid over. + let mut run_prims = slice_with_context(prims, a..b); + let fade = whole_layer(prims, a, b).and_then(|_| { + match run_prims + .iter_mut() + .find(|p| matches!(p, PaintPrimitive::LayerStart { .. })) + { + Some(PaintPrimitive::LayerStart { opacity, .. }) => { + Some(std::mem::replace(opacity, 1.0) as f32) + } + _ => None, + } + }); + let run = self.render_run( + &format!("{name}/run"), + run_prims, + frame, + renderer, + device, + queue, + scene, + (w, h), + peniko::Color::TRANSPARENT, + )?; + let over = (b < n) + .then(|| { + self.render_run( + &format!("{name}/over"), + slice_with_context(prims, b..n), + frame, + renderer, + device, + queue, + scene, + (w, h), + peniko::Color::TRANSPARENT, + ) + }) + .transpose()?; + let texture = self.kept_texture(device, name, w, h); + copy_whole(device, queue, &under, &texture); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut enc = encoder(device); + let run_view = run.create_view(&wgpu::TextureViewDescriptor::default()); + match fade { + Some(opacity) => self + .passes + .blit_over_faded(device, &mut enc, &run_view, &view, opacity), + None => self.passes.blit_over(device, &mut enc, &run_view, &view), + } + if let Some(over) = &over { + let over_view = over.create_view(&wgpu::TextureViewDescriptor::default()); + self.passes.blit_over(device, &mut enc, &over_view, &view); + } + queue.submit([enc.finish()]); + self.renders.insert( + name.to_string(), + CachedRender { + key, + texture: texture.clone(), + }, + ); + return Ok(texture); + } + let texture = self.kept_texture(device, name, w, h); + let RenderKey::Prims { prims, .. } = &key else { + unreachable!() + }; + paint(scene, prims, frame, overlay); + vello_into( + Checking::of(frame, &mut self.pending), + renderer, + device, + queue, + scene, + &texture, + base, + frame.antialiasing, + )?; + if overlay.is_none() { + self.renders.insert( + name.to_string(), + CachedRender { + key, + texture: texture.clone(), + }, + ); + } else { + self.renders.remove(name); + } + Ok(texture) + } + + /// A run's render, kept as `name` like [`Self::render_prims`] but never + /// split again. + #[allow(clippy::too_many_arguments)] + fn render_run( + &mut self, + name: &str, + prims: Vec, + frame: &Frame<'_>, + renderer: &mut Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &mut vello::Scene, + (w, h): (u32, u32), + base: peniko::Color, + ) -> Result { + let key = RenderKey::Prims { + prims, + transform: frame.transform, + base, + size: (w, h), + }; + if let Some(kept) = self.renders.get(name) + && kept.key == key + { + return Ok(kept.texture.clone()); + } + let texture = self.kept_texture(device, name, w, h); + let RenderKey::Prims { prims, .. } = &key else { + unreachable!() + }; + paint(scene, prims, frame, None); + vello_into( + Checking::of(frame, &mut self.pending), + renderer, + device, + queue, + scene, + &texture, + base, + frame.antialiasing, + )?; + self.renders.insert( + name.to_string(), + CachedRender { + key, + texture: texture.clone(), + }, + ); + Ok(texture) + } + + /// The one run `a..b` of primitives that differs from the kept render + /// `name`'s, when everything else is the same and drawing the run + /// separately and laying it over is exact: every draw from `a` on + /// blends normally, and no group open across either end is faded, + /// blended or masked. `None`: render it all. + fn changed_run(&self, name: &str, key: &RenderKey) -> Option<(usize, usize)> { + let RenderKey::Prims { + prims, + transform, + base, + size, + } = key + else { + return None; + }; + let kept = self.renders.get(name)?; + let RenderKey::Prims { + prims: old, + transform: t0, + base: b0, + size: s0, + } = &kept.key + else { + return None; + }; + if old.len() != prims.len() || t0 != transform || b0 != base || s0 != size { + return None; + } + let a = prims.iter().zip(old).position(|(p, q)| p != q)?; + let b = prims.len() + - prims + .iter() + .rev() + .zip(old.iter().rev()) + .position(|(p, q)| p != q)?; + // Containers the run opens, it closes: extend it to their ends. + let mut b = b; + let mut depth = 0_i64; + for (i, p) in prims.iter().enumerate().skip(a) { + if i >= b && depth <= 0 { + break; + } + depth += match p { + PaintPrimitive::GroupStart { .. } + | PaintPrimitive::LayerStart { .. } + | PaintPrimitive::ClipStart { .. } + | PaintPrimitive::MaskDefStart { .. } + | PaintPrimitive::MaskApplyStart { .. } => 1, + PaintPrimitive::GroupEnd { .. } + | PaintPrimitive::LayerEnd { .. } + | PaintPrimitive::ClipEnd { .. } + | PaintPrimitive::MaskDefEnd { .. } + | PaintPrimitive::MaskApplyEnd { .. } => -1, + _ => 0, + }; + b = b.max(i + 1); + } + if a == 0 || b <= a { + return None; + } + // A run to the end has nothing over it: only where it starts splits. + let b = b.min(prims.len()); + let over_end = if b < prims.len() { Some(b) } else { None }; + let exact = prims[a..].iter().all(blends_normally) + && open_at(prims, a) + .chain(over_end.into_iter().flat_map(|b| open_at(prims, b))) + .all(splits_cleanly); + exact.then_some((a, b)) + } + + /// A texture of its own for the kept render `name` (reused while the + /// size holds, so what it holds stays until rendered over). + fn kept_texture(&mut self, device: &wgpu::Device, name: &str, w: u32, h: u32) -> wgpu::Texture { + if let Some(kept) = self.renders.get(name) + && kept.texture.width() == w + && kept.texture.height() == h + { + return kept.texture.clone(); + } + self.texture(device, &format!("kept:{name}"), w, h, TARGET) + } + + fn texture( + &mut self, + device: &wgpu::Device, + key: &str, + w: u32, + h: u32, + format: wgpu::TextureFormat, + ) -> wgpu::Texture { + if let Some(t) = self.textures.get(key) + && t.width() == w + && t.height() == h + && t.format() == format + { + return t.clone(); + } + if self.textures.len() > 64 { + // Window resizes leave per-size levels behind. + self.textures.clear(); + } + let usage = if format == TARGET { + wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST + } else { + wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT + }; + let t = device.create_texture(&wgpu::TextureDescriptor { + label: Some("frame-fx"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }); + self.textures.insert(key.to_string(), t.clone()); + t + } +} + +/// How much of the bloom lands on the emitters themselves (the rest is the +/// halo around them): large lit areas (a backlit VU face) keep their detail. +const BLOOM_INSIDE: f32 = 0.2; + +fn encoder(device: &wgpu::Device) -> wgpu::CommandEncoder { + device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame-fx"), + }) +} + +fn to_kurbo(t: &AffineTransform2D) -> kurbo::Affine { + kurbo::Affine::new([t.a, t.c, t.b, t.d, t.tx, t.ty]) +} + +fn paint( + scene: &mut vello::Scene, + prims: &[PaintPrimitive], + frame: &Frame<'_>, + overlay: Option<&vello::Scene>, +) { + scene.reset(); + paint_primitives_into_scene_with( + scene, + prims, + frame.transform, + frame + .fonts + .map(|f| crate::render::book_for_scene(f, frame.doc)) + .as_deref(), + ); + if let Some(o) = overlay { + scene.append(o, None); + } +} + +/// How a vello render is checked for overflow. +enum Checking<'a> { + /// Not at all: the surface drew whole at this size before. + Off, + /// Waited for, and banded on an overflow. + Wait, + /// Started, and read later (see [`Frame::deferred`]). + Defer(&'a mut Vec), +} + +impl<'a> Checking<'a> { + fn of(frame: &Frame<'_>, pending: &'a mut Vec) -> Self { + match (frame.checked, frame.deferred) { + (false, _) => Self::Off, + (true, false) => Self::Wait, + (true, true) => Self::Defer(pending), + } + } +} + +fn vello_into( + checking: Checking<'_>, + renderer: &mut Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &vello::Scene, + target: &wgpu::Texture, + base: peniko::Color, + antialiasing: AaConfig, +) -> Result<(), String> { + // Banded when the scene overflows vello's buffers, never left blank + // (or holding the texture's previous frame). + match checking { + Checking::Off => {} + Checking::Wait => { + return crate::render::offscreen::render_checked( + renderer, + device, + queue, + scene, + target, + base, + antialiasing, + ) + .map(|_| ()); + } + Checking::Defer(pending) => { + pending.push(crate::render::offscreen::render_deferred( + renderer, + device, + queue, + scene, + target, + base, + antialiasing, + )?); + return Ok(()); + } + } + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_texture( + device, + queue, + scene, + &view, + &vello::RenderParams { + base_color: base, + width: target.width(), + height: target.height(), + antialiasing_method: antialiasing, + }, + ) + .map_err(|e| format!("render_to_texture failed: {e}")) +} + +/// The uniform block for one shader region, and the region's box in target +/// px (`[x, y, w, h]`, clamped to the target) — where its effect runs. +fn region_uniforms(r: &ShaderRegion, frame: &Frame<'_>, w: u32, h: u32) -> (FxUniforms, [u32; 4]) { + let to_px = frame.transform * to_kurbo(&r.world); + let to_local = if to_px.determinant().abs() > 1e-12 { + to_px.inverse() + } else { + kurbo::Affine::IDENTITY + }; + let rows = |t: kurbo::Affine| { + let c = t.as_coeffs(); + [ + [c[0] as f32, c[2] as f32, c[4] as f32, 0.0], + [c[1] as f32, c[3] as f32, c[5] as f32, 0.0], + ] + }; + let value = r + .control + .and_then(|(id, default)| frame.values.and_then(|v| v.get(&id)).copied().or(default)) + .unwrap_or(0.0); + let (params, param_mask) = pack_params(&r.params); + let corners = [ + (0.0, 0.0), + (r.width, 0.0), + (0.0, r.height), + (r.width, r.height), + ] + .map(|p| to_px * kurbo::Point::from(p)); + let (x0, y0) = corners + .iter() + .fold((f64::INFINITY, f64::INFINITY), |(x, y), p| { + (x.min(p.x), y.min(p.y)) + }); + let (x1, y1) = corners + .iter() + .fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), p| { + (x.max(p.x), y.max(p.y)) + }); + let clamp = |v: f64, hi: u32| { + if v.is_finite() { + v.clamp(0.0, f64::from(hi)) as u32 + } else { + 0 + } + }; + let (bx0, by0) = (clamp(x0.floor() - 1.0, w), clamp(y0.floor() - 1.0, h)); + let (bx1, by1) = (clamp(x1.ceil() + 1.0, w), clamp(y1.ceil() + 1.0, h)); + let box_px = [bx0, by0, bx1.saturating_sub(bx0), by1.saturating_sub(by0)]; + let uniforms = FxUniforms { + time_s: frame.time_s as f32, + value: value as f32, + px_per_unit: to_px.determinant().abs().sqrt() as f32, + param_mask, + resolution: [w as f32, h as f32], + node_size: [r.width as f32, r.height as f32], + to_local: rows(to_local), + to_px: rows(to_px), + params, + inputs: [[0; 4]; 8], + }; + let mut uniforms = uniforms; + // `to_local_x.w` (otherwise unused) carries how much the region fades: + // 1 − its effective opacity (0 for an opaque node). + uniforms.to_local[0][3] = (1.0 - r.opacity.clamp(0.0, 1.0)) as f32; + (uniforms, box_px) +} + +/// The target outside `[x, y, w, h]`, as up to four bands. +fn outside([x, y, bw, bh]: [u32; 4], w: u32, h: u32) -> [[u32; 4]; 4] { + let (x1, y1) = ((x + bw).min(w), (y + bh).min(h)); + [ + [0, 0, w, y], + [0, y1, w, h.saturating_sub(y1)], + [0, y, x, y1 - y], + [x1, y, w.saturating_sub(x1), y1 - y], + ] +} + +fn has_draws(prims: &[PaintPrimitive]) -> bool { + prims.iter().any(|p| { + matches!( + p, + PaintPrimitive::Rect { .. } | PaintPrimitive::Path { .. } | PaintPrimitive::Text { .. } + ) + }) +} + +/// Open a `ClipStart`'s clip on `scene` under `transform` (other primitives: +/// an unbounded clip, so the caller's pops stay balanced). +fn push_clip(scene: &mut vello::Scene, clip: &PaintPrimitive, transform: kurbo::Affine) { + let PaintPrimitive::ClipStart { + x, + y, + width, + height, + corner_radii, + .. + } = clip + else { + scene.push_clip_layer( + peniko::Fill::NonZero, + kurbo::Affine::IDENTITY, + &kurbo::Rect::new(-1e9, -1e9, 1e9, 1e9), + ); + return; + }; + let rect = kurbo::Rect::new(*x, *y, x + width, y + height); + let r = corner_radii; + let rr = kurbo::RoundedRect::from_rect( + rect, + kurbo::RoundedRectRadii::new(r.top_left, r.top_right, r.bottom_right, r.bottom_left), + ); + scene.push_clip_layer(peniko::Fill::NonZero, transform, &rr); +} + +/// `prims[range]` as a self-contained list: the groups, layers, clips and +/// masks open at `range.start` are reopened before it, and everything still +/// open at its end is closed. +pub(super) fn slice_with_context( + prims: &[PaintPrimitive], + range: Range, +) -> Vec { + enum Open { + One(usize), + Mask { + def: Option>, + apply: usize, + }, + } + let range = range.start.min(prims.len())..range.end.min(prims.len()); + let mut stack: Vec = Vec::new(); + let mut last_def: Option> = None; + let mut i = 0; + while i < range.start { + match &prims[i] { + PaintPrimitive::GroupStart { .. } + | PaintPrimitive::LayerStart { .. } + | PaintPrimitive::ClipStart { .. } => { + stack.push(Open::One(i)); + } + PaintPrimitive::GroupEnd { .. } + | PaintPrimitive::LayerEnd { .. } + | PaintPrimitive::ClipEnd { .. } + | PaintPrimitive::MaskApplyEnd { .. } => { + stack.pop(); + } + PaintPrimitive::MaskDefStart { .. } => { + let mut depth = 0_usize; + let mut end = i; + for (j, p) in prims.iter().enumerate().skip(i) { + match p { + PaintPrimitive::MaskDefStart { .. } => depth += 1, + PaintPrimitive::MaskDefEnd { .. } => { + depth -= 1; + if depth == 0 { + end = j; + break; + } + } + _ => {} + } + } + last_def = Some(i..end + 1); + i = end + 1; + continue; + } + PaintPrimitive::MaskApplyStart { .. } => stack.push(Open::Mask { + def: last_def.take(), + apply: i, + }), + _ => {} + } + i += 1; + } + + let mut out = Vec::new(); + for o in &stack { + match o { + Open::One(i) => out.push(prims[*i].clone()), + Open::Mask { def, apply } => { + if let Some(d) = def { + out.extend_from_slice(&prims[d.clone()]); + } + out.push(prims[*apply].clone()); + } + } + } + out.extend_from_slice(&prims[range]); + + let mut closers = Vec::new(); + let mut in_def = 0_usize; + for p in &out { + match p { + PaintPrimitive::MaskDefStart { .. } => in_def += 1, + PaintPrimitive::MaskDefEnd { .. } => in_def = in_def.saturating_sub(1), + _ if in_def > 0 => {} + PaintPrimitive::GroupStart { node_id, .. } => { + closers.push(PaintPrimitive::GroupEnd { node_id: *node_id }) + } + PaintPrimitive::LayerStart { node_id, .. } => { + closers.push(PaintPrimitive::LayerEnd { node_id: *node_id }) + } + PaintPrimitive::ClipStart { node_id, .. } => { + closers.push(PaintPrimitive::ClipEnd { node_id: *node_id }) + } + PaintPrimitive::MaskApplyStart { node_id, .. } => { + closers.push(PaintPrimitive::MaskApplyEnd { node_id: *node_id }) + } + PaintPrimitive::GroupEnd { .. } + | PaintPrimitive::LayerEnd { .. } + | PaintPrimitive::ClipEnd { .. } + | PaintPrimitive::MaskApplyEnd { .. } => { + closers.pop(); + } + _ => {} + } + } + out.extend(closers.into_iter().rev()); + out +} + +/// Copy all of `src` onto `dst` (same size). +fn copy_whole( + device: &wgpu::Device, + queue: &wgpu::Queue, + src: &wgpu::Texture, + dst: &wgpu::Texture, +) { + let mut enc = encoder(device); + enc.copy_texture_to_texture( + src.as_image_copy(), + dst.as_image_copy(), + wgpu::Extent3d { + width: src.width().min(dst.width()), + height: src.height().min(dst.height()), + depth_or_array_layers: 1, + }, + ); + queue.submit([enc.finish()]); +} + +/// Whether a primitive draws with normal blending (so drawing it into its +/// own texture and laying that over gives the same picture). +fn blends_normally(p: &PaintPrimitive) -> bool { + use crate::render::anyrender_renderer::BlendMix; + match p { + PaintPrimitive::Rect { fills, blend, .. } | PaintPrimitive::Path { fills, blend, .. } => { + *blend == BlendMix::Normal && fills.iter().all(|(_, b)| *b == BlendMix::Normal) + } + PaintPrimitive::Text { blend, .. } => *blend == BlendMix::Normal, + PaintPrimitive::LayerStart { blend, .. } => *blend == BlendMix::Normal, + _ => true, + } +} + +/// Whether a container open across a split may be split there: a layer +/// that neither fades nor blends, a group or a clip. Not a mask. +fn splits_cleanly(p: &PaintPrimitive) -> bool { + use crate::render::anyrender_renderer::BlendMix; + match p { + PaintPrimitive::LayerStart { blend, opacity, .. } => { + *blend == BlendMix::Normal && (*opacity - 1.0).abs() < 1e-9 + } + PaintPrimitive::GroupStart { .. } | PaintPrimitive::ClipStart { .. } => true, + _ => false, + } +} + +/// The containers open at index `at` of `prims` (their start primitives). +fn open_at(prims: &[PaintPrimitive], at: usize) -> impl Iterator { + let mut stack: Vec<&PaintPrimitive> = Vec::new(); + for p in &prims[..at.min(prims.len())] { + match p { + PaintPrimitive::GroupStart { .. } + | PaintPrimitive::LayerStart { .. } + | PaintPrimitive::ClipStart { .. } + | PaintPrimitive::MaskDefStart { .. } + | PaintPrimitive::MaskApplyStart { .. } => stack.push(p), + PaintPrimitive::GroupEnd { .. } + | PaintPrimitive::LayerEnd { .. } + | PaintPrimitive::ClipEnd { .. } + | PaintPrimitive::MaskDefEnd { .. } + | PaintPrimitive::MaskApplyEnd { .. } => { + stack.pop(); + } + _ => {} + } + } + stack.into_iter() +} + +/// Whether `prims[a..b]` is exactly one layer (its start at `a`, its end at +/// `b - 1` or past the slice), a plain one: normal blend. +fn whole_layer(prims: &[PaintPrimitive], a: usize, b: usize) -> Option<()> { + use crate::render::anyrender_renderer::BlendMix; + let PaintPrimitive::LayerStart { + blend: BlendMix::Normal, + .. + } = prims.get(a)? + else { + return None; + }; + let mut depth = 0_i64; + for (i, p) in prims.iter().enumerate().take(b).skip(a) { + depth += match p { + PaintPrimitive::GroupStart { .. } + | PaintPrimitive::LayerStart { .. } + | PaintPrimitive::ClipStart { .. } + | PaintPrimitive::MaskDefStart { .. } + | PaintPrimitive::MaskApplyStart { .. } => 1, + PaintPrimitive::GroupEnd { .. } + | PaintPrimitive::LayerEnd { .. } + | PaintPrimitive::ClipEnd { .. } + | PaintPrimitive::MaskDefEnd { .. } + | PaintPrimitive::MaskApplyEnd { .. } => -1, + _ => 0, + }; + if depth == 0 && i + 1 < b { + return None; + } + } + Some(()) +} diff --git a/crates/frame/src/render/fx/wgsl/crt.wgsl b/crates/frame/src/render/fx/wgsl/crt.wgsl new file mode 100644 index 00000000..2a3caf23 --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/crt.wgsl @@ -0,0 +1,34 @@ +// crt — a phosphor readout: scanlines, slight barrel distortion, phosphor +// bloom and a soft vignette. +// params: strength (0.7), scale (3) — scanline period in node px. + +fn effect(uv: vec2) -> vec4 { + let strength = clamp(param_or(P_STRENGTH, 0.7), 0.0, 1.0); + let period = max(param_or(P_SCALE, 3.0), 1.0); + let d = uv - vec2(0.5); + let r2 = dot(d, d); + let uvb = vec2(0.5) + d * (1.0 + 0.14 * strength * r2); + let base_a = sample_src(uv).a; + if (any(uvb < vec2(0.0)) || any(uvb > vec2(1.0))) { + return vec4(0.0, 0.0, 0.0, base_a); + } + let c = sample_src(uvb); + // Phosphor bloom: the bright parts bleed into a small ring. + let o = 1.8 / max(fx.node_size, vec2(1.0)); + var g = vec3(0.0); + g += sample_src(uvb + vec2(o.x, 0.0)).rgb; + g += sample_src(uvb - vec2(o.x, 0.0)).rgb; + g += sample_src(uvb + vec2(0.0, o.y)).rgb; + g += sample_src(uvb - vec2(0.0, o.y)).rgb; + g += sample_src(uvb + o * 2.0).rgb; + g += sample_src(uvb - o * 2.0).rgb; + g += sample_src(uvb + vec2(o.x, -o.y) * 2.0).rgb; + g += sample_src(uvb + vec2(-o.x, o.y) * 2.0).rgb; + let bloom = max(g / 8.0 - vec3(0.3), vec3(0.0)) * 0.9 * strength; + let y = uvb.y * fx.node_size.y; + let scan = 0.5 + 0.5 * cos(6.2831853 * y / period); + let lines = mix(1.0, 0.5 + 0.5 * scan, strength); + let vignette = 1.0 - r2 * 0.9 * strength; + let rgb = (c.rgb * lines + bloom) * vignette; + return vec4(rgb, max(c.a, base_a)); +} diff --git a/crates/frame/src/render/fx/wgsl/entry.wgsl b/crates/frame/src/render/fx/wgsl/entry.wgsl new file mode 100644 index 00000000..3e258f6d --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/entry.wgsl @@ -0,0 +1,32 @@ + +// ── entry points (appended by the effects stage) ───────────────────────── + +struct FxVsOut { + @builtin(position) pos: vec4, +} + +@vertex +fn fx_vs_main(@builtin(vertex_index) i: u32) -> FxVsOut { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + var o: FxVsOut; + o.pos = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); + return o; +} + +@fragment +fn fx_fs_main(in: FxVsOut) -> @location(0) vec4 { + let px = in.pos.xy; + let uv = px_to_local(px) / max(fx.node_size, vec2(1e-6)); + // Evaluated for every pixel (uniform control flow, so an effect may + // use derivatives), then discarded outside the box. + let c = effect(clamp(uv, vec2(0.0), vec2(1.0))); + if (any(uv < vec2(0.0)) || any(uv > vec2(1.0))) { + // Outside the node's box the subtree draws plainly. + return sample_src_px(px); + } + // Keep the result a valid premultiplied colour, at the node's opacity + // (`to_local_x.w` = 1 − opacity). + let a = clamp(c.a, 0.0, 1.0); + let o = clamp(1.0 - fx.to_local_x.w, 0.0, 1.0); + return vec4(clamp(c.rgb, vec3(0.0), vec3(a)), a) * o; +} diff --git a/crates/frame/src/render/fx/wgsl/flicker.wgsl b/crates/frame/src/render/fx/wgsl/flicker.wgsl new file mode 100644 index 00000000..f2f190b7 --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/flicker.wgsl @@ -0,0 +1,20 @@ +// flicker — tube-lamp brightness jitter over time (the node's emission +// flickers with it; see `flicker_level` on the CPU side, which mirrors this). +// params: strength (0.5) — depth, speed (10) — jitter rate, Hz. + +fn effect(uv: vec2) -> vec4 { + let depth = clamp(param_or(P_STRENGTH, 0.5), 0.0, 1.0); + let speed = max(param_or(P_SPEED, 10.0), 0.0); + let x = fx.time_s * speed; + let i = floor(x); + let f = x - i; + let s = f * f * (3.0 - 2.0 * f); + let noise = mix(hash11(i), hash11(i + 1.0), s); + var dropout = 0.0; + if (hash11(floor(x * 0.37) + 7.0) > 0.9) { + dropout = 0.6; + } + let level = clamp(1.0 - depth * (noise * 0.5 + dropout), 0.0, 1.0); + let c = sample_src(uv); + return vec4(c.rgb * level, c.a); +} diff --git a/crates/frame/src/render/fx/wgsl/glass.wgsl b/crates/frame/src/render/fx/wgsl/glass.wgsl new file mode 100644 index 00000000..4c2c9914 --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/glass.wgsl @@ -0,0 +1,16 @@ +// glass — a shallow convex lens over the subtree plus a specular sheen. +// params: strength (0.6) — refraction + highlight amount. + +fn effect(uv: vec2) -> vec4 { + let strength = param_or(P_STRENGTH, 0.6); + let d = uv - vec2(0.5); + // Refraction: pull samples toward the centre (magnify), less at the rim. + let refr = d * (-0.07 * strength) * (1.0 - dot(d, d) * 1.6); + let c = sample_src(uv + refr); + // Specular: a soft diagonal band from the top-left plus a thin top rim. + let diag = uv.x * 0.45 + uv.y; + let sheen = smoothstep(0.62, 0.2, diag) * smoothstep(-0.02, 0.18, diag) * 0.32; + let rim = (1.0 - smoothstep(0.0, 0.07, uv.y)) * 0.22; + let h = clamp((sheen + rim) * strength, 0.0, 1.0) * c.a; + return vec4(c.rgb * (1.0 - h) + vec3(h), c.a); +} diff --git a/crates/frame/src/render/fx/wgsl/knob3d.wgsl b/crates/frame/src/render/fx/wgsl/knob3d.wgsl new file mode 100644 index 00000000..34476aff --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/knob3d.wgsl @@ -0,0 +1,667 @@ +// knob.3d — a 3D knob rendered per pixel over the node's box: a signed +// distance field of cap, skirt and insert, sphere-traced from a camera a +// little in front of the panel (an oblique view: the panel stays square, +// what stands off it leans up and shows its front wall), turned by the +// control's value under a light that stays put. +// +// The box is the knob's footprint plus room for its shadow: the knob's +// outer radius (skirt, or cap without one) is `radius` × half the box's +// shorter side, centred. The subtree (the vector knob) is not sampled: the +// shader replaces it; renderers without the stage draw it instead. +// +// Params (named slots; see CONTRACT.md "knob.3d"): +// strength specular strength (1) speed anisotropy 0..1 (0) +// amount metalness 0..1 (0) scale top-edge bevel radius (0.07) +// angle sweep, degrees (300) elevation view tilt, degrees (30) +// softness roughness 0..1 (0.38) radius footprint / half box (0.66) +// tint_r/g/b cap colour (sRGB 0..1) tint_a clearcoat 0..1 (0) +// p0 cap profile 0 flat · 1 domed · 2 fluted · 3 knurled · 4 chicken head +// 5 teardrop pointer cap · 6 wing · 7 stepped +// 8 fluted body + teardrop insert +// p1 ribs (flutes or knurl teeth; 0 = the profile's default) +// p2 skirt 0 none · 1 plain · 2 flared · 3 knurled · 4 metal · 5 dished +// p3 pointer 0 none · 1 line · 2 dot · 3 line down the side too · 4 engraved groove +// Inputs (optional `Const` arrays; a missing value takes its default): +// IN_SHAPE [cap radius .72, height .5, dome 0, rib depth .045, +// knurl band 1, step radius .5, step height .14, diamond 0, +// skirt teeth 48] +// IN_SKIRT [r, g, b, metal, roughness, clearcoat, radius 1, height .16] +// IN_INSERT [r, g, b, metal, roughness, anisotropy, size .62, height .09] +// IN_POINTER [r, g, b, width .075, inner .3, outer .92] +// IN_LIGHT [shadow 1, occlusion 1, fill 1, rim 1, key 1] +// IN_VALUE the position 0..1 (bind a param with a map) — else `value` + +const K_PI: f32 = 3.14159265; +const K_TAU: f32 = 6.28318531; + +const ID_CAP: f32 = 0.0; +const ID_SKIRT: f32 = 1.0; +const ID_INSERT: f32 = 2.0; + +// Toward the kit's key light: upper left (azimuth −35°, kit angles), 50° up. +// World axes: x right, y down the panel (toward the viewer), z off it. +const KEY: vec3 = vec3(-0.3687, -0.5267, 0.7660); +const FILL: vec3 = vec3(0.5514, 0.4511, 0.7017); +const RIM: vec3 = vec3(0.1504, -0.8521, 0.5013); + +struct Mat { + base: vec3, + metal: f32, + rough: f32, + coat: f32, + aniso: f32, +} + +struct Knob { + profile: i32, + skirt: i32, + pointer: i32, + ribs: f32, + cap_r: f32, + height: f32, + dome: f32, + bevel: f32, + rib_depth: f32, + band: f32, + step_r: f32, + step_h: f32, + diamond: f32, + skirt_teeth: f32, + skirt_r: f32, + skirt_h: f32, + insert_size: f32, + insert_h: f32, + // Pointer: width, inner and outer radius (cap radii). + ptr_w: f32, + ptr_in: f32, + ptr_out: f32, + // Rotation: cos, sin of the turn. + rc: f32, + rs: f32, + // Oblique view: screen y = y − tilt·z. + tilt: f32, + // World units per target pixel. + pw: f32, + // Rib detail kept (0 = averaged away when ribs are under ~2 px). + rib_lod: f32, + knurl_lod: f32, + top: f32, + // The cap's bounding radius. + bound: f32, + // Anything reads the angle round the shaft (flutes, knurl). + ribbed: bool, +} + +var K: Knob; +// The angle round the shaft at the point `map` is looking at, in turns. +var TN: f32; + +fn kin(slot: u32, k: u32, fallback: f32) -> f32 { + if (input_len(slot) > k) { + return input_raw(slot, k); + } + return fallback; +} + +fn to_linear(c: vec3) -> vec3 { + return pow(max(c, vec3(0.0)), vec3(2.2)); +} + +// ── 2D / profile distance helpers ──────────────────────────────────────── + +fn smin(a: f32, b: f32, k: f32) -> f32 { + let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} + +fn smax(a: f32, b: f32, k: f32) -> f32 { + return -smin(-a, -b, k); +} + +// A 2D shape `d2` stood between z = 0 and `h` with its top edge rounded by +// `r` (the bottom sits on whatever is under it). +fn stand(d2: f32, z: f32, h: f32, r: f32) -> f32 { + let w = vec2(d2 + r, z - h + r); + let top = min(max(w.x, w.y), 0.0) + length(max(w, vec2(0.0))) - r; + return max(top, -z); +} + +// iq's uneven capsule, from the origin (radius r1) up +y to (0, h) (r2). +fn uneven_capsule(q: vec2, r1: f32, r2: f32, h: f32) -> f32 { + let p = vec2(abs(q.x), q.y); + let b = (r1 - r2) / h; + let a = sqrt(max(1.0 - b * b, 0.0)); + let k = dot(p, vec2(-b, a)); + if (k < 0.0) { + return length(p) - r1; + } + if (k > a * h) { + return length(p - vec2(0.0, h)) - r2; + } + return dot(p, vec2(a, b)) - r1; +} + +// iq's trapezoid in (r, z): bottom half-width r1, top r2, half-height he. +fn trapezoid(q: vec2, r1: f32, r2: f32, he: f32) -> f32 { + let k1 = vec2(r2, he); + let k2 = vec2(r2 - r1, 2.0 * he); + let p = vec2(abs(q.x), q.y); + let ca = vec2(p.x - min(p.x, select(r2, r1, p.y < 0.0)), abs(p.y) - he); + let cb = p - k1 + k2 * clamp(dot(k1 - p, k2) / dot(k2, k2), 0.0, 1.0); + let s = select(1.0, -1.0, cb.x < 0.0 && ca.y < 0.0); + return s * sqrt(min(dot(ca, ca), dot(cb, cb))); +} + +fn box2(p: vec2, b: vec2) -> f32 { + let d = abs(p) - b; + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0); +} + +// Knob-local (turning) coordinates of a world point on the panel plane. +fn turn(p: vec2) -> vec2 { + return vec2(K.rc * p.x + K.rs * p.y, -K.rs * p.x + K.rc * p.y); +} + +// The angle round the shaft, 0 at the pointer, clockwise, in turns. +fn turns(l: vec2) -> f32 { + return atan2(l.x, -l.y) / K_TAU; +} + +// How far ribs cut into a radius at local `l`, height `z`: flutes (round +// scallops), straight knurl (V teeth) or diamond knurl. +fn flute_cut(l: vec2, n: f32, depth: f32) -> f32 { + let u = fract(TN * n) - 0.5; + // A round scallop centred on u = 0, its lands flat between. + let w = 0.36; + let s = clamp(abs(u) / w, 0.0, 1.0); + return depth * sqrt(1.0 - s * s) * K.rib_lod; +} + +fn knurl_cut(l: vec2, z: f32, n: f32, depth: f32) -> f32 { + let t = TN * n; + let lod = K.knurl_lod; + var tri = abs(fract(t) - 0.5) * 2.0; + if (K.diamond > 0.5) { + // Two helices crossing: pyramids. + let pitch = z * n / (K_TAU * max(K.cap_r, 0.1)); + let a = abs(fract(t + pitch) - 0.5) * 2.0; + let b = abs(fract(t - pitch) - 0.5) * 2.0; + tri = max(a, b); + } + return depth * mix(0.5, 1.0 - tri, lod); +} + +// ── the knob ───────────────────────────────────────────────────────────── + +fn skirt_d(l: vec2, z: f32) -> f32 { + let r = length(l); + let h = K.skirt_h; + let rs = K.skirt_r; + if (K.skirt == 2 || K.skirt == 5) { + // Flared: wide at the panel, narrowing to the cap. + let top_r = mix(K.cap_r * 1.04, rs, 0.25); + var d = trapezoid(vec2(r, z - h * 0.5), rs, top_r, h * 0.5) - 0.012; + if (K.skirt == 5) { + // A dish: a raised lip round the edge. + d = smin(d, stand(r - rs, z, h * 0.62, 0.03), 0.03); + } + return max(d, -z); + } + var cut = 0.0; + if (K.skirt == 3) { + cut = knurl_cut(l, z, K.skirt_teeth, 0.035); + } + return stand(r + cut - rs, z, h, 0.028); +} + +// The cap: a lathe-turned body (flat, domed, fluted, knurled, stepped) or +// a moulded one (chicken head, wing), and the teardrop's insert. +fn cap_d(l: vec2, z: f32) -> vec2 { + let r = length(l); + let H = K.height; + let b = K.bevel; + let p = K.profile; + var d = 1e3; + var id = ID_CAP; + if (p == 4) { + // Chicken head: a round boss behind the shaft and a long beak, one + // moulding: its top slopes down toward the tip and is gabled along + // the beak, so the ridge catches the light. + let boss_r = K.cap_r * 0.55; + let back = boss_r * 0.4; + let q = vec2(l.x, -(l.y - back)); + let d2 = uneven_capsule(q, boss_r, 0.075, 0.92 + back - 0.075); + let along = clamp(-l.y / 0.92, 0.0, 1.0); + let beak_top = mix(H, H * 0.7, along) - 0.32 * sqrt(l.x * l.x + 0.002) * (0.4 + along); + let w = vec2(d2 + b, z - beak_top + b); + d = max(min(max(w.x, w.y), 0.0) + length(max(w, vec2(0.0))) - b, -z); + } else if (p == 5) { + // A teardrop cap: round behind the shaft, its point the index. + let q = vec2(l.x, -(l.y - K.cap_r * 0.1)); + let d2 = uneven_capsule(q, K.cap_r * 0.8, 0.07, 0.96 + K.cap_r * 0.1 - 0.07); + let top = H + K.dome * (1.0 - clamp(dot(l, l) / (K.cap_r * K.cap_r), 0.0, 1.0)) - 0.12 * max(-l.y - K.cap_r * 0.6, 0.0); + let w = vec2(d2 + b, z - top + b); + d = max(min(max(w.x, w.y), 0.0) + length(max(w, vec2(0.0))) - b, -z); + } else if (p == 6) { + // Wing: a dome crossed by a fin that runs out to the pointer. + let rr = K.cap_r * 0.86; + let dome = stand(r - rr, z, H * 0.62, rr * 0.6); + let y = l.y; + let fin_top = H * (1.0 - 0.55 * pow(clamp((y + 0.1) / 1.0, -1.0, 1.0), 2.0)); + let d2 = box2(vec2(l.x, y + 0.2), vec2(0.1, 0.78)); + let w = vec2(d2 + 0.06, z - fin_top + 0.06); + let fin = max(min(max(w.x, w.y), 0.0) + length(max(w, vec2(0.0))) - 0.06, -z); + d = smin(dome, fin, 0.07); + } else { + var cut = 0.0; + if (p == 2 || p == 8) { + cut = flute_cut(l, K.ribs, K.rib_depth); + } else if (p == 3) { + let band = smoothstep(H * K.band + 0.03, H * K.band - 0.03, z); + cut = knurl_cut(l, z, K.ribs, K.rib_depth) * band; + } + d = stand(r + cut - K.cap_r, z, H, b); + if (K.dome > 0.001) { + // A spherical crown `dome` above the top edge. + let a = K.cap_r - b * 0.5; + let rs = (a * a + K.dome * K.dome) / (2.0 * K.dome); + let zc = H + K.dome - rs; + let sphere = length(vec3(l, z - zc)) - rs; + d = smax(stand(r + cut - K.cap_r, z, H + K.dome, b), sphere, b * 0.8); + } + if (p == 7) { + d = smin(d, stand(r - K.step_r, z, H + K.step_h, b * 0.8), 0.015); + } + if (p == 8) { + // The teardrop insert, standing proud of the body's top. + let s = K.insert_size; + let q = vec2(l.x, -(l.y - s * 0.12)); + let d2 = uneven_capsule(q, s * 0.62, s * 0.12, s * 0.95); + let top = H + K.insert_h - 0.05 * dot(l, l); + let w = vec2(d2 + 0.05, z - top + 0.05); + let ins = max(min(max(w.x, w.y), 0.0) + length(max(w, vec2(0.0))) - 0.05, H * 0.5 - z); + if (ins < d) { + id = ID_INSERT; + } + d = min(d, ins); + } + } + if (K.pointer == 4) { + // An engraved groove along the pointer. + let g = box2(l - vec2(0.0, -(K.ptr_in + K.ptr_out) * 0.5 * K.cap_r), vec2(K.ptr_w * 0.5, (K.ptr_out - K.ptr_in) * 0.5 * K.cap_r)); + d = max(d, -max(g, K.top - 0.035 - z)); + } + return vec2(d, id); +} + +// The scene: (distance, part id). +fn map(p: vec3) -> vec2 { + let l = turn(p.xy); + if (K.ribbed) { + TN = turns(l); + } + // Far from a part, its bounding cylinder is distance enough. + let r = length(l); + let far = 0.08; + var res = vec2(max(max(r - K.bound, p.z - K.top), -p.z), ID_CAP); + if (res.x < far) { + res = cap_d(l, p.z); + } + if (K.skirt > 0) { + var s = max(max(r - K.skirt_r, p.z - K.skirt_h - 0.02), -p.z); + if (s < far) { + s = skirt_d(l, p.z); + } + if (s < res.x) { + res = vec2(s, ID_SKIRT); + } + } + return res; +} + +fn normal_at(p: vec3) -> vec3 { + let e = max(K.pw * 0.5, 0.002); + let k0 = vec3(1.0, -1.0, -1.0); + let k1 = vec3(-1.0, -1.0, 1.0); + let k2 = vec3(-1.0, 1.0, -1.0); + let k3 = vec3(1.0, 1.0, 1.0); + return normalize(k0 * map(p + k0 * e).x + k1 * map(p + k1 * e).x + k2 * map(p + k2 * e).x + k3 * map(p + k3 * e).x); +} + +// ── shading ────────────────────────────────────────────────────────────── + +fn fresnel(f0: vec3, c: f32) -> vec3 { + return f0 + (vec3(1.0) - f0) * pow(1.0 - clamp(c, 0.0, 1.0), 5.0); +} + +// GGX (anisotropic when `ax != ay`), Smith visibility, times N·L. +fn ggx(n: vec3, v: vec3, l: vec3, t: vec3, ax: f32, ay: f32) -> f32 { + let nl = dot(n, l); + if (nl <= 0.0) { + return 0.0; + } + let h = normalize(l + v); + let bt = cross(n, t); + let th = dot(t, h) / ax; + let bh = dot(bt, h) / ay; + let nh = dot(n, h); + let q = th * th + bh * bh + nh * nh; + let dd = 1.0 / (K_PI * ax * ay * q * q); + let nv = max(dot(n, v), 1e-3); + let a = sqrt(ax * ay); + let vis = 0.5 / (nl * (nv * (1.0 - a) + a) + nv * (nl * (1.0 - a) + a)); + return dd * vis * nl; +} + +// The studio the knob reflects: a dark room, a bright softbox where the key +// light is, a strip light behind, the panel below. +fn env(rd: vec3, rough: f32) -> vec3 { + let s = 0.012 + 0.5 * rough * rough; + let gain = 0.07 / (0.07 + s); + // Ceiling brighter toward the key's side, falling to the horizon. + let side = 0.5 + 0.5 * dot(normalize(rd.xy + vec2(1e-4, 0.0)), normalize(KEY.xy)); + let up = smoothstep(-0.15, 0.95, rd.z); + // The floor is the lit panel round the knob. + let room = mix(vec3(0.16, 0.155, 0.15), vec3(0.16, 0.165, 0.175) * (0.35 + side), up); + let soft = exp(-(1.0 - dot(rd, KEY)) / (s + 0.035)) * 2.4 * gain; + let strip = exp(-(1.0 - dot(rd, RIM)) / (s * 1.6 + 0.01)) * 0.9 * gain; + let fill = exp(-(1.0 - dot(rd, FILL)) / (s * 2.5 + 0.1)) * 0.25; + return room + vec3(1.0, 0.98, 0.95) * soft + vec3(0.9, 0.95, 1.0) * (strip + fill); +} + +fn soft_shadow(p: vec3) -> f32 { + var res = 1.0; + var t = 0.04; + for (var i = 0; i < 3; i++) { + let h = map(p + KEY * t).x; + res = min(res, 7.0 * h / t); + t += clamp(h, 0.08, 0.3); + if (res < 0.02 || t > 1.2) { + break; + } + } + return clamp(res, 0.0, 1.0); +} + +fn occlusion(p: vec3, n: vec3) -> f32 { + // One tap: how much of a small step off the surface is inside it. + let a = map(p + n * 0.06).x; + return clamp(0.35 + 0.65 * a / 0.06, 0.0, 1.0); +} + +// The parts' materials, the pointer's colour and the lights, read once per +// pixel up front (the reads overlap; spread through the shading they'd +// each wait on memory). +var MAT_CAP: Mat; +var MAT_SKIRT: Mat; +var MAT_INSERT: Mat; +var PTR_COLOR: vec3; +// shadow, occlusion, fill, rim; key +var LIGHTS: vec4; +var KEY_I: f32; + +fn materials_load() { + MAT_SKIRT.base = to_linear(vec3(kin(IN_SKIRT, 0u, 0.06), kin(IN_SKIRT, 1u, 0.06), kin(IN_SKIRT, 2u, 0.065))); + MAT_SKIRT.metal = kin(IN_SKIRT, 3u, select(0.0, 1.0, K.skirt == 4)); + MAT_SKIRT.rough = clamp(kin(IN_SKIRT, 4u, select(0.4, 0.22, K.skirt == 4)), 0.03, 1.0); + MAT_SKIRT.coat = kin(IN_SKIRT, 5u, 0.0); + MAT_SKIRT.aniso = 0.0; + MAT_INSERT.base = to_linear(vec3(kin(IN_INSERT, 0u, 0.82), kin(IN_INSERT, 1u, 0.83), kin(IN_INSERT, 2u, 0.85))); + MAT_INSERT.metal = kin(IN_INSERT, 3u, 1.0); + MAT_INSERT.rough = clamp(kin(IN_INSERT, 4u, 0.2), 0.03, 1.0); + MAT_INSERT.coat = 0.0; + MAT_INSERT.aniso = kin(IN_INSERT, 5u, 0.0); + MAT_CAP.base = to_linear(vec3(param_or(P_TINT_R, 0.1), param_or(P_TINT_G, 0.1), param_or(P_TINT_B, 0.105))); + MAT_CAP.metal = clamp(param_or(P_AMOUNT, 0.0), 0.0, 1.0); + MAT_CAP.rough = clamp(param_or(P_SOFTNESS, 0.38), 0.03, 1.0); + MAT_CAP.coat = clamp(param_or(P_TINT_A, 0.0), 0.0, 1.0); + MAT_CAP.aniso = clamp(param_or(P_SPEED, 0.0), 0.0, 1.0); + PTR_COLOR = to_linear(vec3(kin(IN_POINTER, 0u, 0.95), kin(IN_POINTER, 1u, 0.94), kin(IN_POINTER, 2u, 0.9))); + LIGHTS = vec4(kin(IN_LIGHT, 0u, 1.0), clamp(kin(IN_LIGHT, 1u, 1.0), 0.0, 1.0), kin(IN_LIGHT, 2u, 1.0), kin(IN_LIGHT, 3u, 1.0)); + KEY_I = kin(IN_LIGHT, 4u, 1.0); +} + +fn material_of(id: f32) -> Mat { + if (id == ID_SKIRT) { + return MAT_SKIRT; + } + if (id == ID_INSERT) { + return MAT_INSERT; + } + return MAT_CAP; +} + +fn shade(p: vec3, n_in: vec3, v: vec3, id: f32) -> vec3 { + var m = material_of(id); + var n = n_in; + let l = turn(p.xy); + let r = length(p.xy); + // Circumferential tangent: spun metal is turned round the shaft. + var t = normalize(cross(vec3(0.0, 0.0, 1.0), n) + vec3(1e-4, 0.0, 0.0)); + if (r > 1e-3) { + let tc = vec3(-p.y / r, p.x / r, 0.0); + t = normalize(tc - n * dot(tc, n)); + } + if (m.aniso > 0.01 && n.z > 0.6) { + // Machining rings: faint grooves in the normal and the albedo. + let ring = sin(r * 260.0) * 0.5 + 0.5; + let fade = clamp(1.6 - K.pw * 260.0 / K_TAU, 0.0, 1.0); + m.base *= 1.0 - 0.1 * ring * fade * m.aniso; + n = normalize(n + vec3(p.xy / max(r, 1e-3), 0.0) * (ring - 0.5) * 0.05 * fade); + } + // The pointer: paint on the cap (or its insert). + var paint = 0.0; + if (K.pointer > 0 && id != ID_SKIRT) { + let len_in = K.ptr_in * K.cap_r; + let len_out = K.ptr_out * K.cap_r; + var d = 1.0; + if (K.pointer == 2) { + d = length(l - vec2(0.0, -(len_in + len_out) * 0.5)) - K.ptr_w * 0.7; + } else { + d = box2(l - vec2(0.0, -(len_in + len_out) * 0.5), vec2(K.ptr_w * 0.5, (len_out - len_in) * 0.5)); + } + let on_top = select(smoothstep(0.1, 0.45, n.z), 1.0, K.pointer == 3 || K.pointer == 4); + paint = clamp(0.5 - d / max(K.pw, 1e-4), 0.0, 1.0) * on_top; + if (K.pointer == 4) { + paint *= step(p.z, K.top - 0.01); + } + } + if (paint > 0.0) { + let pc = PTR_COLOR; + m.base = mix(m.base, pc, paint); + m.metal = mix(m.metal, 0.0, paint); + m.rough = mix(m.rough, 0.45, paint); + m.aniso = mix(m.aniso, 0.0, paint); + } + + let key_i = KEY_I * 1.2; + let fill_i = LIGHTS.z * 0.35; + let rim_i = LIGHTS.w * 0.9; + let spec_k = max(param_or(P_STRENGTH, 1.0), 0.0); + let ao = mix(1.0, occlusion(p, n), LIGHTS.y); + var sh = 1.0; + // Only what sits under the cap is shadowed by it (the skirt, a wing's + // dome under its fin); the cap's own far side is dark by N·L. + if (id == ID_SKIRT || (K.profile == 6 && p.z < K.height * 0.66)) { + sh = soft_shadow(p + n * 0.01); + } + + let nv = max(dot(n, v), 1e-3); + let f0 = mix(vec3(0.04), m.base, m.metal); + let diff = m.base * (1.0 - m.metal); + // Diffuse: key (shadowed), fill, a little sky. + var col = diff * (max(dot(n, KEY), 0.0) * key_i * sh + max(dot(n, FILL), 0.0) * fill_i * vec3(0.9, 0.95, 1.05) + (0.1 + 0.12 * n.z) * ao); + // Specular: the three lights (area-ish: roughness floored) … + let a0 = max(m.rough * m.rough, 0.02); + let ax = a0 * (1.0 - 0.85 * m.aniso); + let ay = min(a0 * (1.0 + 2.5 * m.aniso) + 0.1 * m.aniso, 1.0); + let fk = fresnel(f0, dot(normalize(KEY + v), v)); + var spec = fk * ggx(n, v, KEY, t, ax, ay) * key_i * sh; + spec += fresnel(f0, dot(normalize(FILL + v), v)) * ggx(n, v, FILL, t, ax, ay) * fill_i; + spec += fresnel(f0, dot(normalize(RIM + v), v)) * ggx(n, v, RIM, t, ax, ay) * rim_i * sh; + // … and the room (softbox, strip), faded where the knob hides it. + let rd = reflect(-v, n); + let fe = fresnel(f0, nv); + spec = spec * 0.35 + fe * env(rd, m.rough + m.aniso * 0.25) * mix(ao, 1.0, 0.3) * mix(0.35, 1.0, sh); + col += spec * spec_k; + // Clearcoat: a second, sharp lobe over the base (bakelite, lacquer). + if (m.coat > 0.0) { + let fc = fresnel(vec3(0.04), nv).x * m.coat; + let cs = env(rd, 0.06) * fc + ggx(n, v, KEY, t, 0.02, 0.02) * 0.04 * m.coat * key_i * sh; + col = col * (1.0 - fc) + cs * spec_k; + } + return col; +} + +// Filmic shoulder, then sRGB-ish gamma. +fn display(c: vec3) -> vec3 { + let x = max(c, vec3(0.0)); + let knee = vec3(0.75); + let over = max(x - knee, vec3(0.0)); + let y = min(x, knee) + (vec3(1.0) - knee) * (vec3(1.0) - exp(-over / (vec3(1.0) - knee))); + return pow(y, vec3(1.0 / 2.2)); +} + +// ── the effect ─────────────────────────────────────────────────────────── + +fn knob_load() { + let profile = i32(round(param_or(P_P0, 0.0))); + K.profile = profile; + K.skirt = i32(round(param_or(P_P2, 0.0))); + K.pointer = i32(round(param_or(P_P3, 1.0))); + var ribs = param_or(P_P1, 0.0); + if (ribs < 1.0) { + ribs = select(select(8.0, 60.0, profile == 3), 12.0, profile == 8); + } + K.ribs = ribs; + K.cap_r = kin(IN_SHAPE, 0u, 0.72); + K.height = kin(IN_SHAPE, 1u, 0.5); + K.dome = kin(IN_SHAPE, 2u, select(0.0, 0.16, profile == 1)); + K.rib_depth = kin(IN_SHAPE, 3u, select(0.045, 0.02, profile == 3)); + K.band = kin(IN_SHAPE, 4u, 1.0); + K.step_r = kin(IN_SHAPE, 5u, 0.5); + K.step_h = kin(IN_SHAPE, 6u, 0.14); + K.diamond = kin(IN_SHAPE, 7u, 0.0); + K.skirt_teeth = kin(IN_SHAPE, 8u, 48.0); + K.bevel = clamp(param_or(P_SCALE, 0.07), 0.005, 0.4); + K.skirt_r = kin(IN_SKIRT, 6u, 1.0); + K.skirt_h = kin(IN_SKIRT, 7u, 0.16); + K.insert_size = kin(IN_INSERT, 6u, 0.62); + K.insert_h = kin(IN_INSERT, 7u, 0.09); + K.ptr_w = kin(IN_POINTER, 3u, 0.075); + K.ptr_in = kin(IN_POINTER, 4u, 0.3); + K.ptr_out = kin(IN_POINTER, 5u, 0.92); + var top = K.height + K.dome; + if (profile == 7) { + top = max(top, K.height + K.step_h); + } + if (profile == 8) { + top = max(top, K.height + K.insert_h); + } + K.top = top; + var bound = K.cap_r + 0.01; + if (profile == 4 || profile == 5 || profile == 6) { + bound = 1.0; + } else if (profile == 8) { + bound = max(bound, K.insert_size * 1.02 + 0.02); + } + K.bound = bound; + K.ribbed = profile == 2 || profile == 3 || profile == 8 || K.skirt == 3; + let v = clamp(input_or(IN_VALUE, fx.value), 0.0, 1.0); + let a = (v - 0.5) * radians(param_or(P_ANGLE, 300.0)); + K.rc = cos(a); + K.rs = sin(a); + K.tilt = tan(radians(clamp(param_or(P_ELEVATION, 30.0), 0.0, 50.0))); +} + +fn effect(uv: vec2) -> vec4 { + knob_load(); + // Knob units: the footprint's radius is 1. + let box = fx.node_size; + let half = 0.5 * min(box.x, box.y); + let scale = half * clamp(param_or(P_RADIUS, 0.66), 0.1, 1.0); + let s = (uv - vec2(0.5)) * box / scale; + // Derivatives first, in uniform control flow. + let fw = fwidth(s); + K.pw = max(max(fw.x, fw.y), 1e-4); + // Ribs narrower than ~2.5 px average away (no shimmer when small). + K.rib_lod = clamp((K_TAU * K.cap_r / (K.ribs * K.pw) - 2.5) / 3.0, 0.0, 1.0); + K.knurl_lod = clamp((K_TAU * K.cap_r / (K.ribs * K.pw) - 3.0) / 4.0, 0.0, 1.0); + materials_load(); + let shadow_k = LIGHTS.x; + + // Panel: contact shadow and occlusion, analytic — a disc (the base) + // swept away from the light up the knob's height, blurred with distance. + let foot = select(K.cap_r, max(K.skirt_r, K.cap_r), K.skirt > 0); + let away = -KEY.xy / KEY.z; + let seg = away * K.top * 0.7; + let hproj = clamp(dot(s, seg) / dot(seg, seg), 0.0, 1.0); + let sd = length(s - seg * hproj) - mix(foot, K.cap_r * 0.9, hproj); + let cast_sh = 1.0 - smoothstep(-0.04 - 0.08 * hproj, 0.06 + 0.22 * hproj, sd); + let contact = exp(-max(length(s) - foot, 0.0) * 14.0); + // Faded out before the box edge, which would cut it square. + let edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); + let shadow_a = clamp((cast_sh * 0.45 + contact * 0.45) * shadow_k, 0.0, 0.92) * smoothstep(0.0, 0.14, edge); + + // The ray: from above the knob, down and back into the panel. + let dir = normalize(vec3(0.0, -K.tilt, -1.0)); + let zmax = K.top + 0.05; + // Outside the knob's lean up the screen nothing is hit. + let lean = vec2(0.0, clamp(s.y, -K.tilt * zmax, 0.0)); + if (length(s - lean) > max(K.bound, select(0.0, K.skirt_r, K.skirt > 0)) + 0.04) { + return vec4(0.0, 0.0, 0.0, shadow_a); + } + var ro = vec3(s.x, s.y + K.tilt * zmax, zmax); + var t = 0.0; + var hit = false; + var dmin = 1e3; + var tmin = 0.0; + var tmax = zmax * length(vec2(K.tilt, 1.0)); + var id = ID_CAP; + // Skip to where the ray enters the knob's bounding cylinder. + let reach = max(K.bound, select(0.0, K.skirt_r, K.skirt > 0)) + 0.02; + let yb = sqrt(max(reach * reach - s.x * s.x, 0.0)); + if (K.tilt > 1e-3 && ro.y > yb) { + t = (ro.y - yb) * length(vec2(K.tilt, 1.0)) / K.tilt; + } + let slant = length(vec2(K.tilt, 1.0)); + // … and stop where it leaves it (behind the knob). + if (K.tilt > 1e-3) { + tmax = min(tmax, (ro.y + yb) * slant / K.tilt); + } + for (var i = 0; i < 16; i++) { + let p = ro + dir * t; + let m = map(p); + if (m.x < dmin) { + dmin = m.x; + tmin = t; + id = m.y; + } + if (m.x < K.pw * 0.4) { + hit = true; + break; + } + // The panel is a surface too: a ray reaching it is done (it would + // otherwise creep along the knob's foot). + if (p.z < K.pw * 0.4) { + break; + } + t += max(min(m.x, p.z * slant) * 0.9, K.pw * 0.3); + if (t > tmax) { + break; + } + } + // Coverage from the closest approach (anti-aliased silhouette). + let cover = select(clamp(0.5 - dmin / K.pw, 0.0, 1.0), 1.0, hit); + if (cover <= 0.0) { + return vec4(0.0, 0.0, 0.0, shadow_a); + } + let p = ro + dir * tmin; + let n = normal_at(p); + // Shade as a camera a few radii away sees it (the view changes across + // a flat top, so reflections sweep over it). + let cam = vec3(0.0, 7.0 * K.tilt, 7.0) * inverseSqrt(1.0 + K.tilt * K.tilt); + let rgb = display(shade(p, n, normalize(cam - p), id)); + let a = cover + (1.0 - cover) * shadow_a; + return vec4(rgb * cover, a); +} diff --git a/crates/frame/src/render/fx/wgsl/lighting.wgsl b/crates/frame/src/render/fx/wgsl/lighting.wgsl new file mode 100644 index 00000000..69eda046 --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/lighting.wgsl @@ -0,0 +1,32 @@ +// lighting — a directional light over a height field taken from the +// subtree's luminance, so knob caps and brushed metal catch the light. The +// normals are taken in screen space: a rotating cap keeps its light fixed. +// params: strength (0.8), angle (135° — from the top-left), elevation (45°), +// scale (1) — bump depth. + +fn height_at(px: vec2) -> f32 { + let c = sample_src_px(px); + return luminance(c.rgb); +} + +fn effect(uv: vec2) -> vec4 { + let strength = clamp(param_or(P_STRENGTH, 0.8), 0.0, 2.0); + let angle = radians(param_or(P_ANGLE, 135.0)); + let elevation = radians(clamp(param_or(P_ELEVATION, 45.0), 5.0, 90.0)); + let bump = 4.0 * param_or(P_SCALE, 1.0); + let px = uv_to_px(uv); + let e = max(fx.px_per_unit, 1.0); + let hx = height_at(px + vec2(e, 0.0)) - height_at(px - vec2(e, 0.0)); + let hy = height_at(px + vec2(0.0, e)) - height_at(px - vec2(0.0, e)); + let n = normalize(vec3(-hx * bump, -hy * bump, 1.0)); + let l = normalize(vec3(cos(angle) * cos(elevation), -sin(angle) * cos(elevation), sin(elevation))); + let flat_diffuse = sin(elevation); + let diffuse = max(dot(n, l), 0.0); + let h = normalize(l + vec3(0.0, 0.0, 1.0)); + let flat_spec = pow(h.z, 24.0); + let spec = max(pow(max(dot(n, h), 0.0), 24.0) - flat_spec, 0.0); + let c = sample_src_px(px); + let shade = mix(1.0, diffuse / max(flat_diffuse, 0.2), strength); + let rgb = c.rgb * shade + vec3(spec * 0.6 * strength) * c.a; + return vec4(rgb, c.a); +} diff --git a/crates/frame/src/render/fx/wgsl/prelude.wgsl b/crates/frame/src/render/fx/wgsl/prelude.wgsl new file mode 100644 index 00000000..31071bbc --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/prelude.wgsl @@ -0,0 +1,148 @@ +// ── frame effects contract (see CONTRACT.md) ───────────────────────────── +// Prepended to every effect (built-in or `asset:`). An effect defines +// fn effect(uv: vec2) -> vec4 +// returning the premultiplied colour at node-local `uv` (0..1 across the +// node's box). + +struct FxUniforms { + time_s: f32, + value: f32, + px_per_unit: f32, + param_mask: u32, + resolution: vec2, + node_size: vec2, + to_local_x: vec4, + to_local_y: vec4, + to_px_x: vec4, + to_px_y: vec4, + params: array, 4>, + // Input slot i: (offset, len) into `fx_data` at `inputs[i / 2]` .xy + // (even i) or .zw (odd i). + inputs: array, 8>, +} + +@group(0) @binding(0) var fx: FxUniforms; +@group(0) @binding(1) var src_tex: texture_2d; +@group(0) @binding(2) var src_samp: sampler; +// Every input's values, back to back. +@group(0) @binding(4) var fx_data: array; + +const P_STRENGTH: u32 = 0u; +const P_SPEED: u32 = 1u; +const P_AMOUNT: u32 = 2u; +const P_SCALE: u32 = 3u; +const P_ANGLE: u32 = 4u; +const P_ELEVATION: u32 = 5u; +const P_SOFTNESS: u32 = 6u; +const P_RADIUS: u32 = 7u; +const P_TINT_R: u32 = 8u; +const P_TINT_G: u32 = 9u; +const P_TINT_B: u32 = 10u; +const P_TINT_A: u32 = 11u; +const P_P0: u32 = 12u; +const P_P1: u32 = 13u; +const P_P2: u32 = 14u; +const P_P3: u32 = 15u; + +fn param(i: u32) -> f32 { + return fx.params[i / 4u][i % 4u]; +} + +fn param_set(i: u32) -> bool { + return ((fx.param_mask >> i) & 1u) != 0u; +} + +fn param_or(i: u32, fallback: f32) -> f32 { + if (param_set(i)) { + return param(i); + } + return fallback; +} + +// ── inputs (`IN_` constants are generated from the names this +// effect mentions) ── + +fn fx_input_slot(i: u32) -> vec2 { + let row = fx.inputs[min(i, 15u) / 2u]; + if ((i % 2u) == 0u) { + return row.xy; + } + return row.zw; +} + +// How many values input `i` carries this frame (0 = no data). +fn input_len(i: u32) -> u32 { + if (i >= 16u) { + return 0u; + } + return fx_input_slot(i).y; +} + +// Value `k` of input `i` (clamped to its length; 0 with no data). +fn input_raw(i: u32, k: u32) -> f32 { + let s = fx_input_slot(i); + if (i >= 16u || s.y == 0u) { + return 0.0; + } + return fx_data[s.x + min(k, s.y - 1u)]; +} + +// Input `i`'s first value, or `fallback` with no data (a param, a reading). +fn input_or(i: u32, fallback: f32) -> f32 { + if (input_len(i) == 0u) { + return fallback; + } + return input_raw(i, 0u); +} + +// Input `i` read across `x` 0..1 (first value at 0, last at 1), linearly +// interpolated; 0 with no data. +fn input_at(i: u32, x: f32) -> f32 { + let n = input_len(i); + if (n == 0u) { + return 0.0; + } + let pos = clamp(x, 0.0, 1.0) * f32(n - 1u); + let k = u32(floor(pos)); + return mix(input_raw(i, k), input_raw(i, k + 1u), fract(pos)); +} + +// The node's box in target pixels. +fn node_px() -> vec2 { + return fx.node_size * fx.px_per_unit; +} + +fn px_to_local(px: vec2) -> vec2 { + let p = vec3(px, 1.0); + return vec2(dot(fx.to_local_x.xyz, p), dot(fx.to_local_y.xyz, p)); +} + +fn local_to_px(l: vec2) -> vec2 { + let p = vec3(l, 1.0); + return vec2(dot(fx.to_px_x.xyz, p), dot(fx.to_px_y.xyz, p)); +} + +fn uv_to_px(uv: vec2) -> vec2 { + return local_to_px(uv * fx.node_size); +} + +// The subtree's colour at target pixel `px` (premultiplied, bilinear). +fn sample_src_px(px: vec2) -> vec4 { + let c = textureSampleLevel(src_tex, src_samp, px / fx.resolution, 0.0); + return vec4(c.rgb * c.a, c.a); +} + +// The subtree's colour at node-local `uv` (premultiplied). +fn sample_src(uv: vec2) -> vec4 { + return sample_src_px(uv_to_px(uv)); +} + +fn luminance(rgb: vec3) -> f32 { + return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); +} + +fn hash11(x: f32) -> f32 { + return fract(sin(x * 12.9898) * 43758.5453); +} + +// ── effect ─────────────────────────────────────────────────────────────── diff --git a/crates/frame/src/render/fx/wgsl/util.wgsl b/crates/frame/src/render/fx/wgsl/util.wgsl new file mode 100644 index 00000000..55af612c --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/util.wgsl @@ -0,0 +1,99 @@ +// The effects stage's own passes: blit-over, premultiply/downsample, +// separable Gaussian blur, additive bloom. One fullscreen triangle each. + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) i: u32) -> VsOut { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + var o: VsOut; + o.pos = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); + o.uv = uv; + return o; +} + +struct Pass { + dir: vec2, + sigma: f32, + taps: i32, + step: u32, + premul: u32, + gain: f32, + // Bloom kept inside the emitters themselves (0..1): they're already lit, + // so most of the light shows as a halo around them. + inside: f32, +} + +@group(0) @binding(0) var u: Pass; +@group(0) @binding(1) var src: texture_2d; +@group(0) @binding(2) var samp: sampler; +// The emitters' own coverage (bloom only). +@group(0) @binding(3) var mask: texture_2d; + +fn load(p: vec2) -> vec4 { + let size = vec2(textureDimensions(src)); + if (any(p < vec2(0)) || any(p >= size)) { + return vec4(0.0); + } + return textureLoad(src, p, 0); +} + +fn premultiply(c: vec4) -> vec4 { + return vec4(c.rgb * c.a, c.a); +} + +// A straight-alpha layer (vello's output), premultiplied, for blending +// "over" (One, OneMinusSrcAlpha). +@fragment +fn fs_blit(in: VsOut) -> @location(0) vec4 { + let c = premultiply(load(vec2(in.pos.xy))); + // `premul` = 1: faded by `gain` (a layer's opacity, applied here + // rather than rendered in). + return select(c, c * u.gain, u.premul == 1u); +} + +// Box-average `step`×`step` source texels into one; premultiply first when +// the source is straight alpha. +@fragment +fn fs_down(in: VsOut) -> @location(0) vec4 { + let base = vec2(in.pos.xy) * i32(u.step); + var acc = vec4(0.0); + for (var y = 0; y < i32(u.step); y++) { + for (var x = 0; x < i32(u.step); x++) { + var c = load(base + vec2(x, y)); + if (u.premul != 0u) { + c = premultiply(c); + } + acc += c; + } + } + return acc / f32(u.step * u.step); +} + +// One axis of a Gaussian (sigma in texels); outside the texture is empty. +@fragment +fn fs_blur(in: VsOut) -> @location(0) vec4 { + let p = vec2(in.pos.xy); + let d = vec2(u.dir); + let k = 1.0 / (2.0 * u.sigma * u.sigma); + var acc = vec4(0.0); + var wsum = 0.0; + for (var i = -u.taps; i <= u.taps; i++) { + let w = exp(-f32(i * i) * k); + acc += load(p + d * i) * w; + wsum += w; + } + return acc / wsum; +} + +// Add blurred light over the frame (blend One + One on colour), mostly +// outside the emitters. +@fragment +fn fs_bloom(in: VsOut) -> @location(0) vec4 { + let c = textureSampleLevel(src, samp, in.uv, 0.0); + let covered = textureSampleLevel(mask, samp, in.uv, 0.0).a; + return vec4(c.rgb * u.gain * mix(1.0, u.inside, covered), 0.0); +} diff --git a/crates/frame/src/render/fx/wgsl/viz/common.wgsl b/crates/frame/src/render/fx/wgsl/viz/common.wgsl new file mode 100644 index 00000000..c16601af --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/viz/common.wgsl @@ -0,0 +1,62 @@ +// ── shared by the visualizer built-ins (`viz.*`, `eq.glow`) ───────────── +// Ported from processor's `fts_audio_ui::shader::PRELUDE`: the colour rules +// every panel shader uses. The visualizers draw their own picture from their +// inputs and ignore the source (their subtree is the vector fallback). + +// A brighter version of a colour that is MORE of that colour, not less: the +// hue is kept and deepened while the value rises. `k` above 1 overdrives; +// the tone map picks up the rolloff. +fn hotter(c: vec3, k: f32) -> vec3 { + let peak = max(max(c.r, c.g), c.b); + if (peak <= 0.0001) { return c; } + let hue = c / peak; + let deep = mix(hue, hue * hue, clamp(k, 0.0, 1.0) * 0.55); + return deep * peak * (1.0 + k * 1.35); +} + +// Reinhard on luminance rather than per channel, so a very bright blue +// stays blue. +fn tonemap(c: vec3) -> vec3 { + let l = dot(c, vec3(0.2126, 0.7152, 0.0722)); + if (l <= 0.0001) { return c; } + return c * ((l / (1.0 + l)) / l); +} + +// A panel shader's result (premultiplied `rgb`, `a`), as it lands on screen +// in signal's rig. processor's `ShaderSurface` renders it with +// `ALPHA_BLENDING` into a cleared target (texel `rgb·a`, `a`), and vello +// composites that texture as STRAIGHT alpha (`register_texture` declares +// `ImageAlphaType::Alpha`), multiplying by `a` once more: what reaches the +// screen is `rgb·a²` over the ground at `1 − a`. Every partly covered pixel +// — a glow's falloff, a haze, a fill — is that much darker than the shader's +// own maths says; this is the picture the rig shows, so it is the one drawn. +fn viz_out(c: vec4) -> vec4 { + let a = clamp(c.a, 0.0, 1.0); + return vec4(c.rgb * a * a, a); +} + +// processor's `lane::beats` — the beat grid its time lanes draw as 1 px +// geometry over the shader — over `c` (premultiplied): a line per beat +// across `window` s at panel x `px_x` of `width`, every fourth (a bar) +// brighter and 1.5 px; none past 64 lines. +fn viz_beats_over(c: vec4, px_x: f32, width: f32, window: f32, beat: f32, lit: bool) -> vec4 { + if (beat <= 0.0 || window <= 0.0) { return c; } + let count = ceil(window / beat); + if (count > 64.0) { return c; } + let k = max(round(px_x / width * window / beat), 1.0); + if (k > count || k * beat > window) { return c; } + let x = k * beat / window * width; + let bar = (u32(k) % 4u) == 0u; + let lw = select(1.0, 1.5, bar); + let cover = clamp(lw * 0.5 + 0.5 - abs(px_x - x), 0.0, 1.0); + var a = 0.07; + if (lit) { a = select(0.22, 0.46, bar); } + let ga = cover * a; + let g = vec3(148.0, 163.0, 184.0) / 255.0 * ga; + return vec4(g + c.rgb * (1.0 - ga), ga + c.a * (1.0 - ga)); +} + +// The lane colour: params `tint_r/g/b` (0..1), else `fallback`. +fn viz_tint(fallback: vec3) -> vec3 { + return vec3(param_or(P_TINT_R, fallback.r), param_or(P_TINT_G, fallback.g), param_or(P_TINT_B, fallback.b)); +} diff --git a/crates/frame/src/render/fx/wgsl/viz/comp.wgsl b/crates/frame/src/render/fx/wgsl/viz/comp.wgsl new file mode 100644 index 00000000..8abaef09 --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/viz/comp.wgsl @@ -0,0 +1,160 @@ +// `viz.comp` — the compressor, as light. Ported from processor's +// `comp-ui/src/viz.wgsl`: same maths, same colours; the uniform block is +// filled from frame's inputs instead of a hand-packed buffer. +// +// Inputs: IN_THRESHOLD (dB), IN_RATIO, IN_KNEE (dB), IN_IN_DB (input level, +// dBFS — the ball), IN_GR_DB (gain reduction, dB positive), IN_INPUT and +// IN_GR (traces 0..1, oldest → newest), IN_ON (0/1, default 1), +// IN_GRABBABLE (0/1, default 0). Params: `tint_r/g/b` (the lane colour, +// default processor's input grey), `p0` = the dB range (60). + +const TAU: f32 = 6.28318530718; + +struct Comp { + // width, height, seconds, on + frame: vec4, + // threshold_db, ratio, knee_db, range_db + curve: vec4, + // in_db, gr_db, trace_len, grabbable + meter: vec4, + // the lane's colour; w unused + color: vec4, +}; + +var u: Comp; + +fn comp_load() { + let size = node_px(); + u.frame = vec4(size, fx.time_s, input_or(IN_ON, 1.0)); + u.curve = vec4(input_or(IN_THRESHOLD, -18.0), max(input_or(IN_RATIO, 4.0), 1.0), input_or(IN_KNEE, 6.0), param_or(P_P0, 60.0)); + u.meter = vec4(input_or(IN_IN_DB, -120.0), input_or(IN_GR_DB, 0.0), f32(input_len(IN_INPUT)), input_or(IN_GRABBABLE, 0.0)); + u.color = vec4(viz_tint(vec3(228.0, 228.0, 231.0) / 255.0), 1.0); +} + +// One trace, sampled across the panel with its neighbours blended +// (smoothstep between samples, so it reads as a body, not a polyline). +fn trace_at(slot: u32, x: f32) -> f32 { + let count = input_len(slot); + if (count < 2u) { return 0.0; } + let pos = clamp(x, 0.0, 1.0) * f32(count - 1u); + let i = u32(floor(pos)); + let j = min(i + 1u, count - 1u); + return mix(input_raw(slot, i), input_raw(slot, j), smoothstep(0.0, 1.0, fract(pos))); +} + +fn input_level_at(x: f32) -> f32 { + return trace_at(IN_INPUT, x); +} + +fn gr_at(x: f32) -> f32 { + return trace_at(IN_GR, x); +} + +// The soft-knee transfer function — the same maths as `comp.transfer`. +fn transfer(input_db: f32) -> f32 { + let threshold = u.curve.x; + let ratio = max(u.curve.y, 1.0); + let knee = u.curve.z; + let slope = 1.0 - 1.0 / ratio; + let half_knee = knee * 0.5; + if (knee > 0.001 && abs(input_db - threshold) < half_knee) { + let x = input_db - threshold + half_knee; + return input_db - slope * x * x / (2.0 * knee); + } + if (input_db <= threshold) { return input_db; } + return input_db - slope * (input_db - threshold); +} + +// dB (0 at the top … −range at the bottom) → 0..=1 down the panel. +fn db_to_v(db: f32) -> f32 { + return clamp(-db / max(u.curve.w, 1.0), 0.0, 1.0); +} + +fn effect(uv: vec2) -> vec4 { + comp_load(); + let t = u.frame.z; + let lit = u.frame.w > 0.5; + + var tint = u.color.rgb; + if (!lit) { tint = vec3(0.30, 0.30, 0.34); } + let hot = hotter(tint, 0.5); + var cut = vec3(0.94, 0.35, 0.35); + if (!lit) { cut = vec3(0.34, 0.28, 0.30); } + + var rgb = vec3(0.0); + var alpha = 0.0; + + // ── The input, rising from the floor ──────────────────────────────── + let lvl = input_level_at(uv.x); + let top = 1.0 - lvl; + let d_in = uv.y - top; + let aa_in = max(fwidth(d_in), 0.0008); + let below = smoothstep(-aa_in, aa_in, d_in); + let depth_below = max(uv.y - top, 0.0); + let falloff = exp(-depth_below * 3.4); + let body = below * (0.16 + 0.62 * lvl) * (0.22 + 0.78 * falloff); + let edge = exp(-abs(uv.y - top) * 150.0) * (0.35 + 0.65 * lvl); + let spill = exp(-max(top - uv.y, 0.0) * (30.0 - 14.0 * lvl)) * 0.26 * lvl; + rgb += tint * (body + edge * 1.35 + spill); + alpha += body * 0.80 + edge * 0.85 + spill * 0.6; + + // ── The gain reduction, hanging from the ceiling ──────────────────── + let red = gr_at(uv.x); + let d_gr = uv.y - red; + let aa_gr = max(fwidth(d_gr), 0.0008); + if (red > 0.001) { + let inside = 1.0 - smoothstep(-aa_gr, aa_gr, d_gr); + let gr_falloff = exp(-max(red - uv.y, 0.0) * 6.0); + let gr_body = inside * (0.22 + 0.80 * red) * (0.25 + 0.75 * gr_falloff); + let gr_edge = exp(-abs(uv.y - red) * 110.0) * (0.5 + 0.8 * red); + rgb += cut * (gr_body * 1.15 + gr_edge * 1.5); + alpha += gr_body * 0.75 + gr_edge * 0.85; + } + + // ── The transfer curve ────────────────────────────────────────────── + let range = max(u.curve.w, 1.0); + let in_db = (uv.x - 1.0) * range; + let out_v = db_to_v(transfer(in_db)); + let d_curve = uv.y - out_v; + let slope = max(fwidth(out_v), 1e-5); + let dist = abs(d_curve) / sqrt(1.0 + slope * slope * u.frame.x * u.frame.x + / max(u.frame.y * u.frame.y, 1.0)) * u.frame.y; + let line = exp(-(dist * dist) / 3.0); + let bloom = exp(-dist / 9.0) * 0.34; + rgb += hot * (line * 0.95 + bloom); + alpha += line * 0.9 + bloom * 0.45; + + let knee_top = db_to_v(transfer(u.curve.x)); + if (uv.y < knee_top) { + let work = (knee_top - uv.y) / max(knee_top, 1e-3); + rgb += cut * work * 0.10; + alpha += work * 0.09; + } + + // ── The threshold, when it can be taken ───────────────────────────── + if (u.meter.w > 0.5) { + let ty = db_to_v(u.curve.x); + let near = exp(-abs(uv.y - ty) * 26.0); + let pulse = 0.78 + 0.22 * sin(t * TAU * 0.9); + let handle = near * 0.30 * pulse; + rgb += hotter(cut, 0.35) * handle; + alpha += handle * 0.7; + } + + // ── The ball ──────────────────────────────────────────────────────── + if (lit) { + let bx = clamp(u.meter.x / range + 1.0, 0.0, 1.0); + let by = db_to_v(transfer(u.meter.x)); + let d = length((vec2(bx, by) - uv) * vec2(u.frame.x, u.frame.y)); + let work = clamp(u.meter.y / 12.0, 0.0, 1.0); + let r = 3.0 + 2.0 * work * (1.0 + 0.12 * sin(t * TAU * 0.8)); + let core = exp(-(d * d) / (r * r * 0.6)); + let halo = exp(-d / (r * 4.0)) * 0.5; + rgb += mix(hot, cut, work) * (core + halo); + alpha += core * 0.95 + halo * 0.4; + } + + rgb = tonemap(rgb * 2.2); + alpha = clamp(alpha, 0.0, 0.94); + return viz_out(vec4(rgb * alpha, alpha)); +} diff --git a/crates/frame/src/render/fx/wgsl/viz/delay.wgsl b/crates/frame/src/render/fx/wgsl/viz/delay.wgsl new file mode 100644 index 00000000..ed70eabf --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/viz/delay.wgsl @@ -0,0 +1,529 @@ +// `viz.delay` — the delay's taps, as light. Ported from processor's +// `delay-ui/src/viz.wgsl` (the body below is processor's, unchanged; only +// where the numbers come from differs). +// +// Inputs: IN_LEFT, IN_RIGHT (taps as `at_s, level` pairs, the left run +// drawn up and the right down — pan ∓0.8), IN_LANE (`window_s, beat_s`), +// IN_FAMILY (0 Digital, 1 Tape, 2 Analog, 3 Pitch, 4 Rhythmic, 5 Special), +// IN_ON (default 1). Params: `tint_r/g/b` (the lane colour, default +// processor's delay blue). The beat grid processor rules over the shader +// is drawn here too (`viz_beats_over`). + +// The delay's taps, as light. +// +// The vector pass draws the same taps as geometry and is a perfectly good +// picture; what a shader adds is the part that is about energy rather than +// position — the bloom as the playhead crosses a repeat, the haze the tail +// leaves behind it, the way a loud tap spills past its own edge. Those are +// per-pixel effects, and drawing them as geometry means one shape per glow +// and a scene that grows with the tail. +// +// Layout of `u` mirrors `DelayUniforms` in viz.rs. `taps` is fixed-length +// because a uniform buffer is; `frame.w` carries how many are real. +// +// The beat grid is NOT here. A 1 px ruled line is the one thing the vector +// pass does better, and the grid is what the taps are read against. + +const MAX_TAPS: u32 = 32u; +const TAU: f32 = 6.28318530718; + +// The families, in `family_index`'s order. The two must agree. +const DIGITAL: f32 = 0.0; +const TAPE: f32 = 1.0; +const ANALOG: f32 = 2.0; +const PITCH: f32 = 3.0; +const RHYTHMIC: f32 = 4.0; +const SPECIAL: f32 = 5.0; + +struct Delay { + // width, height, seconds, tap_count + frame: vec4, + // window_s, mix, on, beat_s + params: vec4, + // the lane's colour; w unused + color: vec4, + // family, unused, unused, unused + style: vec4, +}; + +var u: Delay; + +fn is_family(f: f32) -> bool { + return abs(u.style.x - f) < 0.5; +} + +fn hash1(p: f32) -> f32 { + return fract(sin(p * 127.1) * 43758.5453); +} + +// How much the machine smears a repeat by the time it is `age` repeats old. +// +// This is the single number that separates the families most: a digital +// delay's tenth repeat is the first one again, a tape's has been through the +// heads ten times, and a bucket-brigade's has been resampled ten times by a +// clock that was never clean. Drawn as the bar getting wider and losing its +// cap, because that is what the ear hears as the repeat going soft. +fn smear(age: f32) -> f32 { + if (is_family(TAPE)) { return age * 1.6; } + if (is_family(ANALOG)) { return age * 3.2; } + if (is_family(SPECIAL)) { return age * 1.4; } + return 0.0; +} + +// Wow and flutter: the horizontal wobble a mechanical transport puts on a +// repeat. Zero for everything that is not a transport — a digital delay that +// wobbled would be lying about the one thing it is for. +fn wobble(age: f32, t: f32) -> f32 { + // In PIXELS of the panel, so the wobble is as visible on a rack lane as + // it is on a plugin window. A constant here is invisible on one and + // absurd on the other. + let unit = max(u.frame.x * 0.012, 4.0); + if (is_family(TAPE)) { + // Wow is slow and deep, flutter fast and shallow; both grow with how + // many passes the repeat has had. This is the single cue that a + // transport is involved, so it has to be plainly visible — a repeat + // that has been round the reels five times is not where the + // arithmetic says it should be, and that is the whole point. + return (sin(t * 0.7 + age * 4.0) * 1.0 + sin(t * 6.1 + age * 9.0) * 0.30) + * age * unit; + } + if (is_family(ANALOG)) { + return sin(t * 1.1 + age * 6.0) * 0.55 * age * unit; + } + return 0.0; +} + +// The playhead's position across the window, 0..=1, or −1 when the lane is +// bypassed. A bypassed delay keeps its shape and loses its motion: "off" and +// "not configured" must not look the same. +fn playhead() -> f32 { + let window = max(u.params.x, 1e-3); + let beat = max(u.params.w, 1e-3); + if (u.params.z < 0.5) { return -1.0; } + let cycle = max(beat * ceil(window / beat), beat); + return fract(u.frame.z / cycle); +} + +// How recently the playhead passed `at` (seconds), 0..=1, dying within a beat +// so two taps a beat apart never glow at once and the eye follows a single +// moving highlight. +fn recency(at: f32, head: f32) -> f32 { + if (head < 0.0) { return 0.0; } + let window = max(u.params.x, 1e-3); + let beat = max(u.params.w, 1e-3); + let cycle = max(beat * ceil(window / beat), beat); + let pos = at / window; + let since = fract(head - pos + 1.0); + let over = beat / cycle; + if (since >= over) { return 0.0; } + let k = 1.0 - since / over; + return k * k * k; +} + +fn effect(uv: vec2) -> vec4 { + delay_load(); + let px = uv * vec2(u.frame.x, u.frame.y); + let t = u.frame.z; + let lit = u.params.z > 0.5; + + let window = max(u.params.x, 1e-3); + let mid = u.frame.y * 0.5; + let head = playhead(); + + // Bypassed keeps the picture and drops the colour, so the shape still + // reads as a configured delay that happens to be off. + var tint = u.color.rgb; + if (!lit) { tint = vec3(0.32, 0.32, 0.36); } + let hot = hotter(tint, 0.55); + + var rgb = vec3(0.0); + var alpha = 0.0; + + // ── The lane itself ───────────────────────────────────────────────── + // + // The panel is a DEEP BLUE field, not a black box with blue marks on it. + // A near-black ground made every time-effect lane look like every other + // one, and left the rig's two rows of them — delay and reverb — telling + // you apart only by the colour of the few lit pixels. The ground is + // where a panel says what it is, before anything is drawn on it. + // + // Deep and desaturated: this sits UNDER the taps, and a ground bright + // enough to compete with them would cost the picture its contrast. It is + // painted with its own alpha rather than added, so what shows through is + // the lane's colour and not the rack's black. + let base = mix(vec3(0.010, 0.013, 0.030), tint, 0.10); + // Brighter at the head of the lane, where the dry hit is and the first + // repeats land; the far end is where the tail has given up. + // The far end keeps its colour rather than going black: a lane that + // fades to the rack's own ground stops saying which effect it is + // exactly where the tail is hardest to read. + let along = mix(1.25, 0.72, uv.x); + // And brighter along the channel rule, so the lane has a spine. + let across = mix(1.15, 0.72, clamp(abs(uv.y - 0.5) * 2.0, 0.0, 1.0)); + let ground_rgb = base * along * across; + // A vignette, so the lane reads as a panel rather than as a rectangle + // of colour butted against its neighbours. + let edge_y = 1.0 - smoothstep(0.78, 1.0, abs(uv.y - 0.5) * 2.0) * 0.45; + let ground_a = 0.95 * edge_y; + + // ── What the machine puts in the lane itself ──────────────────────── + // + // Two families are not characterised by what they do to a repeat but by + // where the repeats are allowed to be, so their mark is on the lane + // rather than on the bars. + if (is_family(RHYTHMIC) && lit) { + // The grid IS the effect, so it is drawn as one: sixteenth SLOTS, + // every one of them, lit whether or not a repeat landed in it. A + // rhythmic delay places its repeats on a pattern rather than at + // multiples of one time, and a pattern is only readable against the + // slots it could have used — an uneven row of sticks with nothing + // behind it is just an uneven row of sticks. + let beat = max(u.params.w, 1e-3); + let slots = max(floor(window / (beat * 0.25) + 0.5), 1.0); + let slot = uv.x * slots; + let which = floor(slot); + let inside = fract(slot); + + // Every slot gets a step pad, the downbeats brighter, so the bar + // line is countable without a caption. Bright enough to be furniture + // you read the pattern ON, which is the whole job — at a tenth of + // this it was a smudge and the family was indistinguishable from + // Digital. + let is_beat = abs(which - floor(which / 4.0) * 4.0) < 0.5; + let pad_h = select(0.055, 0.085, is_beat); + let pad = (1.0 - smoothstep(pad_h * 0.55, pad_h, abs(uv.y - 0.5))) + * (1.0 - smoothstep(0.62, 0.90, abs(inside - 0.5) * 2.0)); + let lit_pad = pad * select(0.30, 0.55, is_beat); + rgb += mix(tint, hot, 0.25) * lit_pad; + alpha += lit_pad * 0.8; + + // A full-height rail on the downbeats: the bar lines the pattern is + // counted against. + if (is_beat) { + let rail = smoothstep(0.86, 1.0, abs(inside - 0.5) * 2.0) * 0.16; + rgb += tint * rail; + alpha += rail * 0.7; + } + } + if (is_family(DIGITAL) && lit) { + // A quantisation lattice: fine, exact, evenly spaced, and utterly + // regular. A digital delay's character is that it does the + // arithmetic and nothing else, which is a hard thing to DRAW — + // "absence of artefact" has no shape. The grid is the positive + // version of it: the machine's own ruler, visible behind the + // repeats that land exactly on it. + let ticks = 64.0; + let cell = fract(uv.x * ticks); + let tick = smoothstep(0.90, 1.0, abs(cell - 0.5) * 2.0); + let near_rule = exp(-abs(uv.y - 0.5) * 7.0); + let lattice = tick * near_rule * 0.09; + rgb += tint * lattice; + alpha += lattice * 0.6; + } + if (is_family(TAPE) && lit) { + // The transport itself: the tape path running the length of the + // lane, wowing as it goes. The repeats sit ON it, which is why they + // are not where the arithmetic says — the picture shows the thing + // that moved them rather than only the result. + let wow = sin(uv.x * 4.0 + t * 0.7) * 0.10 + sin(uv.x * 9.0 - t * 2.3) * 0.025; + let path = exp(-abs((uv.y - 0.5) - wow) * 26.0); + let ribbon = path * (0.10 + 0.05 * sin(uv.x * 30.0 - t * 4.0)); + rgb += mix(tint, vec3(1.0, 0.72, 0.42), 0.55) * ribbon; + alpha += ribbon * 0.55; + } + if (is_family(ANALOG) && lit) { + // A bucket brigade's noise floor, rising along the lane. The chip + // hisses and the clock leaks, and by the end of the tail there is + // more of that than there is repeat — which is the reason nobody + // runs one at high feedback and is invisible in a clean drawing. + let grain = hash1(floor(px.x * 0.9) * 2.3 + floor(px.y * 0.9) * 7.1 + floor(t * 12.0)); + let floor_h = exp(-abs(uv.y - 0.5) * 4.5); + let hiss = grain * floor_h * (0.05 + 0.30 * uv.x) * 0.22; + rgb += mix(tint, vec3(0.42, 0.36, 0.50), 0.6) * hiss; + alpha += hiss * 0.7; + } + if (is_family(SPECIAL) && lit) { + // A repeat that is no longer one: reversed, filtered, dissolved. + // The lane itself is unstable — a drifting veil that says the + // repeats are being taken apart rather than merely fading. + // Grain that thickens along the lane: the repeats are coming apart, + // and by the end of the tail there is more cloud than repeat. A flat + // wash would say "this panel has a haze on it" rather than "this + // machine is dissolving what you put into it". + let drift = vec2(uv.x * 26.0 - t * 0.5, (uv.y - 0.5) * 14.0 + t * 0.3); + let cell = floor(drift); + let grit = hash1(cell.x * 3.7 + cell.y * 11.3); + let puff = exp(-length(fract(drift) - 0.5) * 4.0) * step(0.55, grit); + let dissolve = puff * (0.12 + 0.85 * uv.x) * 0.30; + rgb += mix(tint, hot, 0.55) * dissolve; + alpha += dissolve * 0.7; + } + + // ── The channel rule ──────────────────────────────────────────────── + // + // The lane is split: UP IS LEFT, DOWN IS RIGHT. That is the read every + // good delay display uses (FabFilter's Timeless puts the channels on the + // vertical axis for the same reason), and it is what makes a ping-pong + // legible — the repeats alternate across the rule instead of nudging a + // few pixels off a shared centre line, which is what a pan-as-offset + // draws and which nobody can see. + let rule = exp(-abs(px.y - mid) / max(u.frame.y * 0.004, 0.7)) * 0.20; + rgb += tint * rule; + alpha += rule * 0.55; + + // ── The dry hit ───────────────────────────────────────────────────── + // + // At zero, full height, across both channels: everything to the right of + // it is a repeat OF this, which is the one relationship the picture has + // to establish. + let dry_w = max(u.frame.x * 0.0018, 1.2); + let dry = exp(-(px.x * px.x) / (dry_w * dry_w * 4.0)); + rgb += hot * dry * 0.9; + alpha += dry * 0.8; + + // ── The taps ──────────────────────────────────────────────────────── + let count = u32(max(u.frame.w, 0.0)); + for (var i = 0u; i < MAX_TAPS; i = i + 1u) { + if (i >= count) { break; } + let tap = delay_tap(i); + let at = tap.x; + let level = clamp(tap.y, 0.0, 1.0); + let pan = clamp(tap.z, -1.0, 1.0); + if (at > window) { continue; } + + let hit = recency(at, head); + // How far down the tail this repeat is, 0..1 — measured in TIME, not + // in array position. + // + // The index is not the age. A stereo delay reports its taps as two + // runs concatenated: every left repeat, then every right repeat. By + // index, the right channel's FIRST repeat sits halfway through the + // array and came out drawn as a half-aged one — on a tape delay that + // meant the right channel was orange and smeared from its very first + // repeat while the left was still clean blue, which reads as a fault + // in the picture because it is one. + // + // Time is side-independent and is what the machine has actually had + // to work on: two repeats that land together have had the same done + // to them, whichever channel they are in. + let age = clamp(at / window, 0.0, 1.0); + let sm = smear(age); + let x = at / window * u.frame.x + wobble(age, t); + + // Equal-power pan, so a tap's two bars carry its level between them + // the way the pan law does. A centred tap is half height on both + // sides rather than full height on neither. + let ang = (pan * 0.5 + 0.5) * 1.5707963; + let gain_l = cos(ang); + let gain_r = sin(ang); + // Which side of the rule this pixel is on, and how far into it. + let up = px.y < mid; + let gain = select(gain_r, gain_l, up); + // Room for the bar, measured from the rule out to the lane's edge. + let half = u.frame.y * 0.5; + let from_rule = abs(px.y - mid); + let bar = level * gain * half * 0.92 * (1.0 + 0.10 * hit); + + let dx = px.x - x; + + // ── What this machine does to a repeat's colour ───────────────── + // + // Pitch delays move the repeat, so the repeat moves through the + // spectrum: each one is further up or down from the one before, and + // a shimmer climbing an octave a repeat should LOOK like it climbs. + // Everything else keeps the lane's hue and only loses brightness. + var voice = tint; + if (is_family(PITCH)) { + // Each repeat is a different NOTE, so each repeat is a different + // colour — the full sweep, not a tint. A shimmer climbing an + // octave a repeat should look like it climbs. + let up = vec3(0.35, 1.00, 0.75); + let down = vec3(1.00, 0.45, 0.30); + // Alternating sides climb and fall independently, which is what + // a dual-tap pitch delay actually does. + let rising = pan < 0.0; + voice = mix(tint, select(down, up, rising), min(age * 1.5, 1.0)); + } else if (is_family(TAPE)) { + // Oxide: the repeats go warm as they go soft. Pushed far enough + // to read at a glance — the last repeat of a tape delay is a + // different colour from the first, and that IS what it sounds + // like. + voice = mix(tint, vec3(1.0, 0.58, 0.22), min(age * 1.25, 0.95)); + } else if (is_family(ANALOG)) { + // A bucket brigade loses the top first and ends up muddy. + voice = mix(tint, vec3(0.42, 0.36, 0.50), min(age * 1.35, 0.9)); + } + + // The bar: bright along its own column, growing from the rule + // outward and stopping at its own height. + // + // Width is in PIXELS and must not be a constant: a falloff tuned on a + // 600 px panel is a hairline on a 2560 px one, which is how these + // ended up all but invisible on the rig's own screen. Scaled off the + // panel, with a floor so a narrow lane still draws something. + let stem_w = max(u.frame.x * 0.0022, 1.4) * (1.0 + 0.5 * hit) * (1.0 + sm * 3.5); + // A smeared repeat loses its edge as well as its width: the bar + // stops ending anywhere in particular. + let edge_lo = mix(0.90, 0.35, clamp(sm, 0.0, 1.0)); + let within = 1.0 - smoothstep(bar * edge_lo, bar * (1.06 + sm), from_rule); + let stem = exp(-(dx * dx) / (stem_w * stem_w)) * within; + // Tape and analog darken as they smear; a digital repeat does not. + let dull = 1.0 / (1.0 + sm * 1.6); + rgb += voice * stem * (0.55 + 0.85 * level + 0.7 * hit) * dull; + alpha += stem * (0.55 + 0.45 * level + 0.35 * hit); + + // The cap: a bright point at the bar's far end, which is where its + // level is actually read. + let r = max(u.frame.x * 0.0035, 2.2) + 2.5 * level + 3.0 * hit; + let cap_y = mid + select(bar, -bar, up); + let d = length(vec2(dx, px.y - cap_y)); + // The cap goes as the repeat smears — a soft repeat has no edge to + // read a level off, which is exactly the point. + let cap = exp(-(d * d) / (r * r * 0.8)) * step(0.02, bar) / (1.0 + sm * 2.4); + // The cap carries the repeat's OWN colour, lifted toward white + // rather than replaced by it. Always-hot caps meant a pitch delay's + // repeats — whose whole character is that each one is a different + // note — differed from a digital delay's only along a two-pixel + // stem, which is to say not at all. + let cap_rgb = hotter(voice, 0.45); + rgb += cap_rgb * cap * (0.5 + 0.5 * level + 0.5 * hit); + alpha += cap * (0.5 + 0.4 * level + 0.4 * hit); + + // A pitch delay's rung: a short horizontal tick beside each repeat, + // stepped further from the centre with every one. The colour says a + // note changed; the ladder says BY HOW MUCH, and in which direction + // — an interval is a distance and a distance wants to be drawn as + // one. + if (is_family(PITCH)) { + let rising = pan < 0.0; + let step_n = age * 5.0; + let rung_y = mid + select(1.0, -1.0, rising) * u.frame.y * 0.055 * step_n; + let rung_w = max(u.frame.x * 0.010, 5.0); + let rx = 1.0 - smoothstep(rung_w * 0.55, rung_w, abs(dx)); + let ry = exp(-abs(px.y - rung_y) / max(u.frame.y * 0.008, 1.0)); + let rung = rx * ry * 0.55; + rgb += hotter(voice, 0.3) * rung; + alpha += rung * 0.65; + } + + // A pitch delay's repeats get a standing halo in their own colour, + // not only a lit one. The note each repeat lands on is the whole + // character of the family, and a colour that only exists on a stem + // is a colour nobody sees. + if (is_family(PITCH)) { + let halo = exp(-d / (r * 5.0)) * level * 0.45; + rgb += voice * halo; + alpha += halo * 0.45; + } + + // A reversed repeat swells INTO its hit instead of starting at it. + // That is the whole character of the family, and it is the one thing + // a row of decaying sticks cannot say. + if (is_family(SPECIAL)) { + let ramp_len = max(u.frame.x * 0.045, 6.0); + let before = clamp((x - px.x) / ramp_len, 0.0, 1.0); + let swell = (1.0 - before) * step(px.x, x) * within * level * 0.5; + rgb += voice * swell; + alpha += swell * 0.5; + } + + // The bloom, only while lit by the sweep. This is the part that is + // worth a GPU: one soft falloff per tap, per pixel, for free. + if (hit > 0.01) { + let br = 4.0 + 18.0 * hit * (0.4 + level); + let bloom = exp(-d / br) * hit; + rgb += voice * bloom * 0.55; + alpha += bloom * 0.30; + } + } + + // ── The envelope ──────────────────────────────────────────────────── + // + // The shape the tail decays along, as a haze rather than an outline: it + // is context for the taps, and an edge would compete with them. + if (count > 1u) { + // Nearest-tap level at this x, so the haze follows the actual taps + // rather than an idealised exponential the delay may not be doing. + // Per SIDE, so the haze follows each channel's own tail. A shared + // envelope would draw a ping-pong as one symmetrical shape, which is + // the opposite of what it sounds like. + var env_l = 0.0; + var env_r = 0.0; + for (var i = 0u; i < MAX_TAPS; i = i + 1u) { + if (i >= count) { break; } + let tap = delay_tap(i); + let tx = tap.x / window; + let w = exp(-abs(uv.x - tx) * 12.0); + let a = (clamp(tap.z, -1.0, 1.0) * 0.5 + 0.5) * 1.5707963; + env_l = max(env_l, tap.y * cos(a) * w); + env_r = max(env_r, tap.y * sin(a) * w); + } + let env = select(env_r, env_l, uv.y < 0.5); + let span = env * 0.46; + let inside = 1.0 - smoothstep(span * 0.75, span * 1.15, abs(uv.y - 0.5)); + let haze = inside * env * 0.40 * (1.0 - uv.x * 0.55); + rgb += mix(tint, hot, 0.5) * haze; + alpha += haze * 0.55; + } + + // ── The sweep ─────────────────────────────────────────────────────── + // + // A thin bright edge with a trail behind it. Not decoration: the taps are + // static geometry saying *where* the repeats land, and the sweep is what + // turns that into a rhythm readable at a glance. + if (head >= 0.0) { + let hx = head * u.frame.x; + let behind = max(hx - px.x, 0.0); + let trail = exp(-behind / (u.frame.x * 0.08)) * 0.16; + let edge = exp(-abs(px.x - hx) / max(u.frame.x * 0.0012, 1.0)) * 0.55; + rgb += tint * trail + hot * edge; + alpha += trail * 0.7 + edge * 0.6; + // A faint shimmer riding the edge, so the sweep has a leading face. + let shimmer = edge * 0.25 * (0.5 + 0.5 * sin(px.y * 0.35 + t * 6.0)); + rgb += hot * shimmer; + alpha += shimmer * 0.4; + } + + // Tone-map rather than clamp: two overlapping blooms otherwise clip to + // white and lose the colour in both, exactly where it matters most. + // Driven before the tone map. Reinhard on raw contributions crushes + // everything below 1.0, and 1.0 is brighter than anything this panel + // produces — so without the drive the whole picture sits in the bottom + // quarter of the curve and reads as switched off. + rgb = tonemap(rgb * 2.2); + alpha = clamp(alpha, 0.0, 0.92); + + // Marks over ground, composited properly rather than summed, and handed + // back premultiplied because that is what the compositor expects. + // + // The ground stays OUT of the curve above: it is not light the panel is + // emitting, it is the panel, and running it through the same tone map + // lifted it until it competed with what was drawn on it — a lane so + // bright the marks had no contrast left to stand out against. + let out_a = alpha + ground_a * (1.0 - alpha); + let out_rgb = rgb * alpha + ground_rgb * ground_a * (1.0 - alpha); + return viz_beats_over(viz_out(vec4(out_rgb, out_a)), uv.x * u.frame.x, u.frame.x, u.params.x, u.params.w, u.params.z > 0.5); +} + +fn delay_load() { + let nl = input_len(IN_LEFT) / 2u; + let nr = input_len(IN_RIGHT) / 2u; + let count = min(nl + nr, MAX_TAPS); + u.frame = vec4(node_px(), fx.time_s, f32(count)); + u.params = vec4(max(input_raw(IN_LANE, 0u), 0.05), 1.0, input_or(IN_ON, 1.0), max(input_raw(IN_LANE, 1u), 1e-3)); + if (input_len(IN_LANE) < 2u) { + u.params = vec4(2.0, 1.0, input_or(IN_ON, 1.0), 0.5); + } + u.color = vec4(viz_tint(vec3(59.0, 130.0, 246.0) / 255.0), 1.0); + u.style = vec4(input_or(IN_FAMILY, 0.0), 0.0, 0.0, 0.0); +} + +// Tap `i`: the left run, then the right. +fn delay_tap(i: u32) -> vec4 { + let nl = input_len(IN_LEFT) / 2u; + if (i < nl) { + return vec4(input_raw(IN_LEFT, i * 2u), input_raw(IN_LEFT, i * 2u + 1u), -0.8, 0.0); + } + let k = i - nl; + return vec4(input_raw(IN_RIGHT, k * 2u), input_raw(IN_RIGHT, k * 2u + 1u), 0.8, 0.0); +} diff --git a/crates/frame/src/render/fx/wgsl/viz/eq_glow.wgsl b/crates/frame/src/render/fx/wgsl/viz/eq_glow.wgsl new file mode 100644 index 00000000..04dbd3b1 --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/viz/eq_glow.wgsl @@ -0,0 +1,250 @@ +// `eq.glow` — the EQ's light: the analyser as a field, a bloom under every +// band. Ported from processor's `eq-ui/src/eq_glow.wgsl` (the body below is +// processor's; bins and band halos come from frame's inputs, laid out the +// way processor's `EqGraphWidget::paint_glow` lays them out). +// +// Inputs: IN_SPECTRUM (bins in dB, log-spaced 20 Hz–20 kHz; resample to +// 128), IN_BANDS (`freq_hz, gain_db` per enabled band — `eq.bands`), +// IN_DB_RANGE (the graph's ± dB span, default 24) or IN_RANGE + IN_RANGES +// (a zoom param's option index and the spans it picks from), IN_FLOOR (the analyser's +// floor dB, default −90). Params: `strength` = node glow (1), `amount` = +// spectrum glow (1), `softness` = hue spread (1), `radius` = bloom (1); +// `p0`/`p1` = the frequency axis (20, 20000 Hz). + +// The EQ's light: the spectrum as a field, and a bloom under every band node. +// +// Painted UNDER the vector graph, not instead of it. The curves, the grid and +// the node rings stay crisp geometry — they are read precisely, and a shader +// is the wrong tool for a 1 px line. What a shader is the right tool for is +// everything that should look like energy rather than like a drawing: the +// analyser's body, the haze where a band is lifting, the halo that says which +// node has focus. +// +// Layout of `u` mirrors `Glow` in eq_glow.rs. The arrays are fixed-length +// because a uniform buffer is: `bins` and `nodes` carry their own counts. + +const MAX_BINS: u32 = 128u; +const MAX_NODES: u32 = 24u; +const TAU: f32 = 6.28318530718; + +struct Glow { + // width, height, seconds, bin_count + frame: vec4, + // node_count, db_range, spectrum_floor_db, unused + cfg: vec4, + // node_glow, spectrum_glow, hue_spread, bloom — the look, mirroring + // `GlowStyle` in eq_glow.rs. Everything else here is measurement; this + // row is the only taste. + style: vec4, +}; + +var u: Glow; + +// `VsOut` and `vs_main` come from the shared prelude — every panel draws the +// same full-screen triangle, and a second copy here is a redefinition the +// device rejects. + +// One bin, by index, out of the packed rows. +fn bin_at(i: u32) -> f32 { + return input_raw(IN_SPECTRUM, i); +} + +// The analyser's height at this x, 0..=1, smoothed across neighbours so the +// field reads as a body rather than as a bar chart. +fn spectrum_height(x: f32) -> f32 { + let count = u32(max(u.frame.w, 1.0)); + if (count < 2u) { return 0.0; } + let floor_db = u.cfg.z; + let pos = clamp(x, 0.0, 1.0) * f32(count - 1u); + let i = u32(floor(pos)); + let f = fract(pos); + let j = min(i + 1u, count - 1u); + let a = bin_at(i); + let b = bin_at(j); + let db = mix(a, b, f); + return clamp((db - floor_db) / (0.0 - floor_db), 0.0, 1.0); +} + +// The analyser's hue at this point on the frequency axis. +// +// The graph already colours a band by where it sits — a low shelf is not the +// same colour as an air band — and the analyser using the same sweep means the +// light under a node and the node itself agree. It also does the work a single +// colour cannot: with one hue the only thing carrying frequency is horizontal +// position, and on a log axis that is exactly where the eye is worst at it. +// +// `hue_spread` at 0 collapses the sweep to one hue, for a panel that wants the +// analyser to read as one object. +fn spectrum_hue(x: f32, energy: f32) -> vec3 { + let low = vec3(1.00, 0.36, 0.42); + let mid = vec3(0.45, 0.85, 0.55); + let high = vec3(0.40, 0.72, 1.00); + let air = vec3(0.78, 0.60, 1.00); + + let k = clamp(x, 0.0, 1.0) * 3.0; + var swept = mix(low, mid, clamp(k, 0.0, 1.0)); + swept = mix(swept, high, clamp(k - 1.0, 0.0, 1.0)); + swept = mix(swept, air, clamp(k - 2.0, 0.0, 1.0)); + + // The neutral the sweep collapses towards: the panel's own colour when + // it sets one (`tint_r/g/b` — an EQ drawn in one hue), and then the + // whole analyser is that colour. + if (tinted()) { + return hotter(viz_tint(vec3(0.52, 0.80, 1.0)), 0.55 * energy * energy); + } + let flat = vec3(0.52, 0.80, 1.0); + let hue = mix(flat, swept, clamp(u.style.z, 0.0, 1.0)); + // Loud runs HOTTER, not whiter: a peak should look like more of its own + // frequency band, and a white peak has thrown away the one thing the + // hue sweep was drawn to say. + return hotter(hue, 0.55 * energy * energy); +} + +fn effect(uv: vec2) -> vec4 { + glow_load(); + let px = uv * vec2(u.frame.x, u.frame.y); + let t = u.frame.z; + + let node_glow = max(u.style.x, 0.0); + let spectrum_glow = max(u.style.y, 0.0); + let bloom = max(u.style.w, 0.0); + + var rgb = vec3(0.0); + var alpha = 0.0; + + // ── The analyser ──────────────────────────────────────────────────── + // + // A filled body with a lit rim, plus a soft bloom above it. The bloom is + // what makes a loud band look loud rather than just tall: energy spilling + // past its own edge is how light behaves, and the eye reads it before it + // reads a height. + // + // This is the ONLY analyser on the panel. The vector pass is told to skip + // its own (`GraphPaint::spectrum`), because the same data drawn twice with + // two different smoothings reads as the analyser being wrong rather than + // as a second layer. + let h = spectrum_height(uv.x); + let top = 1.0 - h; + let below = smoothstep(0.0, 0.012, uv.y - top); + let body = below * (0.10 + 0.45 * h); + + // The rim, tight to the edge. + let rim = exp(-abs(uv.y - top) * 190.0) * (0.35 + 0.65 * h); + // The spill above it, wider where there is more energy. + let spill = exp(-max(top - uv.y, 0.0) * (26.0 - 12.0 * h)) * 0.30 * h * bloom; + // A vertical shimmer riding the rim, so a held note is alive rather than a + // frozen outline. Tied to the energy, so silence is genuinely still. + let shimmer = exp(-abs(uv.y - top) * 70.0) + * 0.18 * h * (0.5 + 0.5 * sin(uv.x * 46.0 + t * 2.1)); + + let spectrum_rgb = spectrum_hue(uv.x, h); + + rgb += spectrum_rgb * ((body + rim * 1.5 + spill + shimmer) * spectrum_glow); + alpha += (body * 0.55 + rim * 0.85 + spill * 0.7 + shimmer * 0.5) * spectrum_glow; + + // ── The bands ─────────────────────────────────────────────────────── + // + // A halo under each node, in the node's own colour, sized by its radius + // and breathing very slightly so a static graph is never quite still. + let count = u32(max(u.cfg.x, 0.0)); + for (var i = 0u; i < MAX_NODES; i = i + 1u) { + if (i >= count) { break; } + let n = glow_node(i); + let strength = n.w; + if (strength <= 0.001) { continue; } + + let d = distance(px, n.xy); + let r = max(n.z, 1.0); + // Breath is per-node and out of phase, so a row of bands shimmers + // rather than pulsing in unison like a warning light. + let breath = 1.0 + 0.06 * sin(t * TAU * 0.35 + f32(i) * 1.7); + let core = exp(-(d * d) / (r * r * 0.55 * breath)); + let halo = exp(-d / (r * 3.2 * breath)) * 0.45; + // A tight bright centre on top of the two soft falloffs, so the node + // reads as a source of the light rather than as a patch of it. + let spark = exp(-(d * d) / (r * r * 0.05)) * 0.9; + let lit = (core + halo + spark * bloom) * strength * node_glow; + + // The band's own colour, undiluted at the centre and kept saturated in + // the falloff. Each node glowing its OWN hue is the whole point: it is + // what ties a halo to the ring drawn over it and to its place on the + // frequency sweep. + // The band's own colour, intensified at the centre rather than + // bleached. Two bands whose halos overlap should pile up into a + // deeper mix of their colours, not a white patch. + let tint = hotter(glow_color(i).rgb, 0.45 * spark); + rgb += tint * lit; + alpha += lit * 0.60; + } + + // Tone-map rather than clamp: without this two overlapping halos clip to + // white and the colour information in both is lost exactly where they + // matter most. + rgb = tonemap(rgb); + alpha = clamp(alpha, 0.0, 0.88); + + // Premultiplied, because that is what the compositor expects of the + // texture it is handed. + return viz_out(vec4(rgb * alpha, alpha)); +} + +// Whether the panel sets its own colour (`tint_r`), in place of the sweep. +fn tinted() -> bool { + return param_or(P_TINT_R, -1.0) >= 0.0; +} + +fn glow_load() { + let n = min(input_len(IN_BANDS) / 2u, MAX_NODES); + u.frame = vec4(node_px(), fx.time_s, f32(min(input_len(IN_SPECTRUM), MAX_BINS))); + // The graph's span: fixed (IN_DB_RANGE), or a zoom param's pick + // (IN_RANGE, an index) from its table (IN_RANGES). + var span = input_or(IN_DB_RANGE, 24.0); + if (input_len(IN_RANGES) > 0u) { + span = input_raw(IN_RANGES, u32(max(round(input_or(IN_RANGE, 0.0)), 0.0))); + } + u.cfg = vec4(f32(n), span, input_or(IN_FLOOR, -90.0), 0.0); + u.style = vec4(param_or(P_STRENGTH, 1.0), param_or(P_AMOUNT, 1.0), param_or(P_SOFTNESS, 1.0), param_or(P_RADIUS, 1.0)); +} + +// Band `i`'s halo, as processor's graph widget pushes it: at the band's +// point on the graph, sized and lit by how far it lifts. +fn glow_node(i: u32) -> vec4 { + let freq = input_raw(IN_BANDS, i * 2u); + let gain = input_raw(IN_BANDS, i * 2u + 1u); + let lo = param_or(P_P0, 20.0); + let hi = param_or(P_P1, 20000.0); + let x = log(clamp(freq, lo, hi) / lo) / log(hi / lo) * u.frame.x; + let range = max(u.cfg.y, 1.0); + let y = (0.5 - clamp(gain, -range, range) / (2.0 * range)) * u.frame.y; + let lift = clamp(abs(gain) / range, 0.0, 1.0); + return vec4(x, y, 30.0 + 34.0 * lift, 0.80 + 0.45 * lift); +} + +// processor's `freq_to_color`: a band's colour by where it sits — hue red +// (0°) → violet (290°) across 20 Hz–20 kHz, HSL s 0.68, l 0.58. +fn glow_color(i: u32) -> vec4 { + // A panel in one colour (`tint_r/g/b`): every band blooms in it. + if (tinted()) { + return vec4(viz_tint(vec3(1.0)), 1.0); + } + let hz = max(input_raw(IN_BANDS, i * 2u), 1.0); + let lo = log(20.0); + let t = clamp((log(hz) - lo) / (log(20000.0) - lo), 0.0, 1.0); + let h = t * 290.0; + let c = (1.0 - abs(2.0 * 0.58 - 1.0)) * 0.68; + let hp = h / 60.0; + let x = c * (1.0 - abs(hp - 2.0 * floor(hp / 2.0) - 1.0)); + var rgb = vec3(c, 0.0, x); + if (hp < 1.0) { + rgb = vec3(c, x, 0.0); + } else if (hp < 2.0) { + rgb = vec3(x, c, 0.0); + } else if (hp < 3.0) { + rgb = vec3(0.0, c, x); + } else if (hp < 4.0) { + rgb = vec3(0.0, x, c); + } else if (hp < 5.0) { + rgb = vec3(x, 0.0, c); + } + return vec4(rgb + (0.58 - c / 2.0), 1.0); +} diff --git a/crates/frame/src/render/fx/wgsl/viz/modulation.wgsl b/crates/frame/src/render/fx/wgsl/viz/modulation.wgsl new file mode 100644 index 00000000..21f1a514 --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/viz/modulation.wgsl @@ -0,0 +1,367 @@ +// `viz.modulation` — a picture per modulation engine. Ported from +// processor's `modulation-ui/src/viz.wgsl` (the body below is processor's; +// the default uniform block it used is filled from frame's inputs). +// +// Inputs: IN_RATE (Hz), IN_DEPTH, IN_MIX (0..1), IN_ENGINE (0 Chorus, +// 1 Flanger, 2 Phaser, 3 Tremolo, 4 Vibrato, 5 Rotary), IN_ON (default 1). +// Params: `tint_r/g/b` (the group colour, default processor's modulation +// cyan). + +struct ModUniforms { + // width, height, seconds, engine + frame: vec4, + // rate_hz, depth, mix, engaged + params: vec4, + color: vec4, +}; + +var u: ModUniforms; + +// The modulation panels, on the GPU. +// +// Every engine here works by interference — copies of a signal against +// itself, displaced in time or pitch, reinforcing and cancelling. So the +// picture is interference: fields beating against each other at the engine's +// own rate and depth, which is both what the effect does and the reason it +// looks the way it does. +// +// `u.frame` is [width, height, seconds, engine]; `u.params` is +// [rate_hz, depth, mix, engaged]; `u.color` is the group's colour. + +const CHORUS: f32 = 0.0; +const FLANGER: f32 = 1.0; +const PHASER: f32 = 2.0; +const TREMOLO: f32 = 3.0; +const VIBRATO: f32 = 4.0; +const ROTARY: f32 = 5.0; + +const TAU: f32 = 6.28318530718; + +// A cheap smooth noise, for the grain that keeps a flat field from looking +// like a gradient. +fn hash(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn noise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); +} + +// A frequency response, drawn as a curve with a body under it. +// +// `gain` is 0..1 at this column — 1 is untouched, 0 is a full notch. The +// filter engines are drawn this way rather than as a field of brightness +// because a field cannot say HOW MANY notches there are or how deep they go, +// and that is the entire difference between the two of them: a flanger is a +// dense harmonic comb, a phaser is four or six dips wandering through the +// band. Rendered as brightness they were both "vertical banding" and the +// pair was indistinguishable. +fn response_curve(uv: vec2, gain: f32) -> f32 { + // 1 near the top of the lane, 0 near the bottom, with a margin so a full + // notch still has somewhere to be. + let y = 0.14 + (1.0 - clamp(gain, 0.0, 1.0)) * 0.72; + let d = uv.y - y; + + // A tight core inside a wider glow, rather than one falloff doing both + // jobs: the core is what makes the curve readable to a pixel, the glow + // is what makes it look like light. + let core = exp(-(d * d) / 0.00035); + let glow = exp(-abs(d) * 11.0) * 0.34; + // A body under it, so the curve reads as a filled response and not as a + // wire — falling away beneath its own edge rather than filling flat. A + // constant fill is a coloured rectangle with a line on top: it says the + // response reached here and nothing about the shape, and on a short lane + // it is most of the lane. + let below = max(d, 0.0); + let under = smoothstep(0.0, 0.014, d) * (0.09 + 0.24 * gain) * exp(-below * 2.6); + // And a haze above it, so a peak looks like one. + let halo = exp(-max(-d, 0.0) * 9.0) * 0.18 * gain; + return core * 1.25 + glow + under + halo; +} + +fn effect(uv: vec2) -> vec4 { + mod_load(); + let t = u.frame.z; + let engine = u.frame.w; + let rate = max(u.params.x, 0.01); + let depth = clamp(u.params.y, 0.0, 1.0); + // `wet` rather than `mix`: `mix` is a WGSL builtin and shadowing it costs + // every interpolation in the file. + let wet_mix = clamp(u.params.z, 0.0, 1.0); + let engaged = u.params.w; + + // The LFO every engine is driven by, so they share a heartbeat even when + // they draw differently. + let phase = t * rate; + let lfo = sin(phase * TAU); + + // Distance from the centre line, which is where most of these live. + let mid = abs(uv.y - 0.5) * 2.0; + + var field = 0.0; + + if (engine < CHORUS + 0.5) { + // A braid. + // + // A chorus is copies of one signal, each on its own slowly-moving + // delay, and what you hear is them pulling apart and closing again — + // so what it should look like is strands weaving through one + // another. Drawn as three separate lines it was legible but inert; + // drawn as a braid the voices actually cross, and every crossing is + // the thickening you hear, in the place you hear it. + // + // Every parameter has something to move: + // rate — how many turns the braid makes across the lane + // depth — how far the strands swing apart + // mix — how bright the wet strands are against the dry one + // + // One helix in three phases, a third of a turn apart, so the strands + // are genuinely the same path and genuinely interleave. + let turns = 0.9 + 2.4 * clamp(rate / 4.0, 0.0, 1.0); + let swing = 0.055 + depth * 0.32; + let wet = 0.45 + 0.75 * wet_mix; + + var ys = array(0.0, 0.0, 0.0); + var strands = 0.0; + for (var i = 0; i < 3; i = i + 1) { + let a = uv.x * turns * TAU - phase * TAU + f32(i) * (TAU / 3.0); + let y = 0.5 + sin(a) * swing; + ys[i] = y; + // Thinner than the old lines, because now there is light between + // them doing the work the thickness used to do. + let d = (uv.y - y) / 0.030; + strands = strands + (exp(-d * d) + exp(-abs(d) * 0.75) * 0.30) * wet; + } + + // The dry signal, steady down the middle. Without it there is + // nothing for the wet voices to be diverging FROM, and a braid with + // no axis is just a knot. + let dd = (uv.y - 0.5) / 0.016; + let dry = exp(-dd * dd) * 0.55; + + // Where two strands converge the light piles up. This is the whole + // point: a crossing is where two copies agree, which is exactly the + // moment the sound thickens. + var knots = 0.0; + for (var i = 0; i < 3; i = i + 1) { + for (var j = i + 1; j < 3; j = j + 1) { + let gap = abs(ys[i] - ys[j]); + let meeting = exp(-gap * 22.0); + let centre = (ys[i] + ys[j]) * 0.5; + let k = (uv.y - centre) / 0.075; + knots = knots + meeting * exp(-k * k) * 0.85; + } + } + + // The body the braid encloses, shimmering: the chorused signal has + // width, and the width is the effect. + var lo = min(ys[0], min(ys[1], ys[2])); + var hi = max(ys[0], max(ys[1], ys[2])); + let inside = smoothstep(lo - 0.02, lo + 0.02, uv.y) + * (1.0 - smoothstep(hi - 0.02, hi + 0.02, uv.y)); + let shimmer = inside * (0.55 + 0.45 * sin(uv.x * 44.0 + t * 2.6)) * 0.16 * wet_mix; + + field = strands + dry + knots + shimmer; + } else if (engine < FLANGER + 0.5) { + // A comb, and the teeth slide. + // + // HARMONIC: the notches are integer multiples of one delay, evenly + // spaced all the way up, and there are a lot of them. That is what a + // flanger is and what separates it from the phaser beside it — the + // count and the regularity, both of which a response curve states + // and a brightness field cannot. + let sweep = (lfo * 0.5 + 0.5) * depth; + let teeth = 5.0 + 20.0 * sweep; + // |cos| is 1 at the peaks and 0 at the notches — the comb itself. + let comb = abs(cos(uv.x * teeth * 3.14159265)); + // How DEEP the notches go is the wet/dry blend, not the sweep: a + // comb filter nulls completely at 50/50 and not at all when the wet + // path is muted. Depth slides the teeth; mix decides whether there + // are teeth at all. + let gain = mix(1.0, comb, 0.08 + 0.92 * wet_mix); + field = response_curve(uv, gain); + } else if (engine < PHASER + 0.5) { + // Allpass notches travelling through the band. + // + // FEW, and NOT harmonically spaced — that is the whole difference + // from the comb next door. Six of them, drifting at their own rates, + // wide enough to see individually. + let travel = lfo * 0.5 + 0.5; + var gain = 1.0; + for (var i = 0; i < 6; i = i + 1) { + let fi = f32(i); + // Spacing that widens as it climbs, so no two gaps match and the + // eye cannot read it as a comb. + let home = 0.08 + fi * 0.13 + fi * fi * 0.012; + let centre = fract(home + travel * 0.22); + let width = 0.030 + 0.022 * depth; + let d = (uv.x - centre) / width; + // Same division as the flanger: depth moves the notches, mix + // decides how deep they cut. + gain = gain - (0.10 + 0.85 * wet_mix) * exp(-d * d); + } + field = response_curve(uv, clamp(gain, 0.0, 1.0)); + } else if (engine < TREMOLO + 0.5) { + // Amplitude, pumping: the carrier's envelope is the LFO. + // + // The envelope floors at a fraction of full height rather than at + // zero, so the quiet part of the cycle is still a waveform being + // held down rather than a hole in the panel. A tremolo at full depth + // is silent for part of its cycle, but silence in a picture is + // indistinguishable from nothing loaded. + let swing = sin((uv.x - phase) * TAU) * 0.5 + 0.5; + // Depth is how far the level CAN swing; mix is how much of the + // swung signal you are hearing. With the wet path muted there is no + // tremolo however deep it is set, and the panel has to say so. + let reach = depth * 0.80 * (0.12 + 0.88 * wet_mix); + let env = mix(1.0 - reach, 1.0, swing); + let carrier = sin(uv.x * 40.0 * TAU); + field = carrier * env; + field = field * step(mid, env); + } else if (engine < VIBRATO + 0.5) { + // Pitch, bending — drawn as a WAVEFORM whose wavelength stretches and + // compresses, not as a field of bars. + // + // As bars it was a comb with uneven spacing, which is a flanger with + // uneven spacing: the two engines came out looking like the same + // picture, and they are not remotely the same effect. A single line + // that visibly bunches and spreads says "the pitch is moving" in a + // way a bar field cannot, because a bar field has no continuity for + // the eye to follow along. + let bend = depth * 1.3 * (0.15 + 0.85 * wet_mix) + * sin((uv.x * 1.6 - phase) * TAU); + let wave = sin((uv.x * 16.0 + bend * 6.0) * TAU); + let y = 0.5 + wave * 0.30; + let d = abs(uv.y - y); + // The trace, plus a wider glow so it has body at a rack lane's + // height. + field = exp(-(d * d) / 0.0016) + exp(-d * 9.0) * 0.40; + // Where the cycles bunch, the line is doing more — brighten it, so + // the modulation peak is readable without counting wavelengths. + let bunching = abs(cos((uv.x * 1.6 - phase) * TAU)) * depth; + field = field * (0.75 + 0.55 * bunching); + } else { + // A horn going round, seen from above: the orbit it travels, the + // horn on it, and the doppler it throws into the room. + // + // Measured in ASPECT-CORRECTED space. `distance(uv, c)` on a lane + // that is three times wider than it is tall squashes the orbit into + // a sliver and puts almost all of the falloff in x — which is why + // this drew a single blob near the bottom and nothing else. + let aspect = max(u.frame.x, 1.0) / max(u.frame.y, 1.0); + let p = vec2((uv.x - 0.5) * aspect, uv.y - 0.5); + + let a = phase * TAU; + let radius = 0.42 * (0.62 + 0.38 * depth); + // Wider than tall, because a rotor seen from the front is an ellipse + // and the lane is wide. + let c = vec2(cos(a) * radius * 1.9, sin(a) * radius); + let d = distance(p, c); + + // The orbit itself, faint: the path is context for the horn on it. + let on_path = abs(length(vec2(p.x / 1.9, p.y)) - radius); + // Bright enough to be the picture rather than a hint of one: the + // orbit is what says "this thing goes round", and the horn alone is + // a dot moving in the dark. + let path = exp(-on_path * 13.0) * 0.55; + + // The horn. Coming toward you it is bright and tight; going away it + // is dim and smeared — which IS the doppler, drawn rather than + // described. + let toward = sin(a) * 0.5 + 0.5; + let tight = mix(45.0, 170.0, toward); + let horn = exp(-d * d * tight) * (0.85 + 1.05 * toward); + + // The wake it drags behind itself around the orbit. + var wake = 0.0; + for (var i = 1u; i < 5u; i = i + 1u) { + let lag = f32(i) * 0.16; + let ca = a - lag; + let cp = vec2(cos(ca) * radius * 1.9, sin(ca) * radius); + let dd = distance(p, cp); + wake = wake + exp(-dd * dd * 70.0) * (0.55 / f32(i)); + } + + // The room is the wet path: the orbit and the wake are what the + // cabinet throws, and with the wet signal down you are left with the + // horn itself. + let room = 0.18 + 0.82 * wet_mix; + field = path * room + horn + wake * room; + } + + // Grain, so a flat region reads as material rather than as a fill. + // + // Sampled on SQUARE cells. Scaling uv by the panel's pixel size gave + // cells as wide as the panel and as tall — 35 across and 12 down on a + // rig lane — so the grain came out as vertical streaks, and on a quiet + // engine those streaks were the brightest thing on the panel. Square + // cells read as material; rectangular ones read as a pattern the effect + // is not making. + let cell = max(u.frame.x, u.frame.y) * 0.05; + let grain = (noise(uv * cell + t * 0.3) - 0.5) * 0.10; + var lit = clamp(abs(field) + grain, 0.0, 1.5); + + // A bypassed engine keeps its shape and loses its light — "off" and + // "not configured" must not look the same. + lit = lit * mix(0.16, 1.0, engaged); + + // Colour: the group's hue, lifted toward white where the field is + // strongest, so the bright parts read as energy rather than as a + // different colour arriving. + // The brightest parts are MORE of the group's colour, not less. Mixing + // toward white meant a chorus knot or a dense comb turned into a white + // highlight exactly where the picture had the most to say — and the + // colour is what tells you whether you are reading MOD or MOTION. + let base = u.color.rgb; + let heat = clamp(lit - 0.45, 0.0, 1.0); + let rgb = tonemap(hotter(base, heat * 1.15) * clamp(lit, 0.0, 1.4)); + let alpha = clamp(lit * 0.82, 0.0, 0.92); + + // ── The lane itself ───────────────────────────────────────────────── + // + // The panel is a tinted FIELD, not a black box with coloured marks on + // it. Modulation is cyan-led and motion pink-led, and that grouping is + // how you know which of the two rows you are reading without going to + // the label — but it only worked where the engine happened to be + // drawing. On a quiet engine, or in the wide empty margins a narrow + // shape leaves, the panel was black and said nothing at all. + // + // Deep and desaturated: this is UNDER the field, and a ground bright + // enough to compete with it would cost the picture its contrast. It also + // keeps its colour into the corners rather than fading out, which is + // exactly where the panel stops identifying itself. + let ground_base = mix(vec3(0.012, 0.018, 0.024), base, 0.13); + // A little brighter through the middle, where the engines live. + let across = mix(1.20, 0.72, clamp(mid, 0.0, 1.0)); + let along = mix(1.06, 0.88, abs(uv.x - 0.5) * 2.0); + // Bypassed keeps a ground, dimmed: an engine that is off should look + // switched off, not unloaded. + let ground_rgb = ground_base * across * along * mix(0.45, 1.0, engaged); + let edge = 1.0 - smoothstep(0.80, 1.0, mid) * 0.40; + // An inner shadow along the top, and a hairline of light along the + // bottom. Costs almost nothing and is the difference between a panel + // that sits IN the rack and a rectangle of colour laid on top of it. + let inset = 1.0 - exp(-uv.y * 34.0) * 0.55; + let sill = exp(-(1.0 - uv.y) * 46.0) * 0.14; + let ground_a = 0.94 * edge; + let ground_lit = ground_rgb * inset + base * sill; + + // Field over ground, composited rather than summed. The ground is not + // light the panel is emitting, it is the panel. + let out_a = alpha + ground_a * (1.0 - alpha); + let out_rgb = rgb * alpha + ground_lit * ground_a * (1.0 - alpha); + return viz_out(vec4(out_rgb, out_a)); +} + +fn mod_load() { + u.frame = vec4(node_px(), fx.time_s, input_or(IN_ENGINE, 0.0)); + u.params = vec4(input_or(IN_RATE, 1.0), input_or(IN_DEPTH, 0.5), input_or(IN_MIX, 0.5), input_or(IN_ON, 1.0)); + u.color = vec4(viz_tint(vec3(34.0, 211.0, 238.0) / 255.0), 1.0); +} diff --git a/crates/frame/src/render/fx/wgsl/viz/reverb.wgsl b/crates/frame/src/render/fx/wgsl/viz/reverb.wgsl new file mode 100644 index 00000000..c395c87f --- /dev/null +++ b/crates/frame/src/render/fx/wgsl/viz/reverb.wgsl @@ -0,0 +1,504 @@ +// `viz.reverb` — the reverb's decay, as light. Ported from processor's +// `reverb-ui/src/viz.wgsl` (the body below is processor's; its `hash11` is +// `rv_hash11` here, frame's prelude has one of its own). +// +// Inputs: IN_DECAY (RT60, s), IN_DENSITY (0..1), IN_PREDELAY (ms), IN_MIX, +// IN_DAMP (0..1), IN_BPM, IN_FAMILY (0 Room, 1 Hall, 2 Plate, 3 Spring, +// 4 Ambient, 5 Random, 6 Special, 7 Convolution), IN_ON (default 1). +// Params: `tint_r/g/b` (default processor's reverb violet). The beat grid +// processor rules over the shader is drawn here too (`viz_beats_over`). + +// The reverb's decay, as light. +// +// The vector pass draws the same decay as geometry; what a shader adds is the +// part that is about a field of energy rather than an outline — the density of +// early reflections as actual grain, the tail as a haze that thins rather than +// a curve that descends, the bloom where a reflection lands. Those are +// per-pixel, and as geometry each one costs a shape. +// +// Layout of `u` mirrors `ReverbUniforms` in viz.rs. +// +// The beat grid is NOT here, for the same reason as the delay's: a 1 px ruled +// line is the one thing the vector pass does better, and the grid is what the +// tail's length is read against. + +const TAU: f32 = 6.28318530718; + +// The families, in `family_index`'s order. The two must agree. +const ROOM: f32 = 0.0; +const HALL: f32 = 1.0; +const PLATE: f32 = 2.0; +const SPRING: f32 = 3.0; +const AMBIENT: f32 = 4.0; +const RANDOM: f32 = 5.0; +const SPECIAL: f32 = 6.0; +const CONVOLUTION: f32 = 7.0; + +struct Reverb { + // width, height, seconds, on + frame: vec4, + // decay_s, density, predelay_s, mix + params: vec4, + // beat_s, window_s, damp, unused + time: vec4, + // the lane's colour; w unused + color: vec4, + // family, unused, unused, unused + style: vec4, +}; + +var u: Reverb; + +fn is_family(f: f32) -> bool { + return abs(u.style.x - f) < 0.5; +} + +// How many discrete reflections the front of the tail shows before it stops +// being countable. +// +// This is the first thing that tells the machines apart. A room is a handful +// of close walls and you hear every one; a plate has no walls at all and is +// dense from the first millisecond; a spring is a few big bounces. Drawing +// them all with the same early field made every algorithm look alike, which +// is the one thing a per-algorithm picture must not do. +fn early_count(density: f32) -> f32 { + if (is_family(ROOM)) { return 6.0 + 8.0 * density; } + if (is_family(HALL)) { return 8.0 + 20.0 * density; } + // A plate has no walls and a spring's early field IS its chirp; an + // ambient's is velvet's grains. None of them has discrete arrivals to + // draw, and drawing some anyway is what made every family look alike. + if (is_family(PLATE)) { return 0.0; } + if (is_family(SPRING)) { return 0.0; } + if (is_family(AMBIENT)) { return 0.0; } + if (is_family(SPECIAL)) { return 16.0 + 16.0 * density; } + if (is_family(CONVOLUTION)) { return 20.0 + 28.0 * density; } + return 6.0 + 26.0 * density; +} + +/// The most arrivals the early field draws. +const MAX_EARLY: u32 = 48u; + +// WHERE the early reflections land, which is the family's fingerprint. +// +// Every family used one hash for placement and differed only in how many +// arrivals it drew — so a room, a hall, a gated reverb and a convolution all +// showed the same scatter at different densities, and the eye reads the same +// scatter as the same machine. The pattern has to differ, because the pattern +// is the thing: a room's returns come off parallel walls in pairs, a hall's +// thicken as the space diffuses, a measured space arrives in clusters with +// real gaps between them, and a random space's do not stay where they were. +// +// `k` is 0..1 across the early span, `py` is -1..1 across the lane. +fn early_field(k: f32, py: f32, density: f32, t: f32) -> f32 { + let total = early_count(density); + if (total < 0.5) { return 0.0; } + let n = u32(min(total, f32(MAX_EARLY))); + + var acc = 0.0; + for (var i = 0u; i < MAX_EARLY; i = i + 1u) { + if (i >= n) { break; } + let fi = f32(i); + let fn_ = f32(n); + let s1 = rv_hash11(fi + 1.0); + let s2 = rv_hash11(fi + 17.0); + + var kx = (fi + 0.5) / fn_; + var y = mix(-0.8, 0.8, s2); + var amp = 0.45 + 0.55 * s1; + var w = 0.030; + + if (is_family(ROOM)) { + // Off parallel walls, in pairs, evenly: a box returns the same + // slap from two sides and it is regular enough to count. + kx = (fi + 0.5) / fn_; + y = select(0.62, -0.62, (i % 2u) == 0u) * (0.65 + 0.35 * s1); + amp = (1.0 - kx * 0.45) * (0.7 + 0.3 * s1); + w = 0.022; + } else if (is_family(HALL)) { + // Sparse at first and thickening: a big space takes time to + // diffuse, and watching it fill in IS the size. + kx = pow((fi + 0.5) / fn_, 0.62); + amp = (0.35 + 0.65 * s1) * (0.55 + 0.45 * kx); + w = 0.034; + } else if (is_family(RANDOM)) { + // They do not stay where they were. Position AND height drift, + // which is the one family whose character is that it is never + // the same twice. + kx = clamp((fi + 0.5) / fn_ + sin(t * 0.55 + fi * 1.7) * 0.045, 0.0, 1.0); + y = sin(t * 0.7 + fi * 2.3) * 0.82; + amp = 0.45 + 0.55 * s1; + w = 0.032; + } else if (is_family(SPECIAL)) { + // A lattice. Gated reverbs are dense and machine-made, and the + // regularity is why the cliff at the end reads as a decision + // rather than as an ending. + kx = (fi + 0.5) / fn_; + y = (f32(i % 5u) / 4.0 - 0.5) * 1.7; + amp = 0.85; + w = 0.018; + } else if (is_family(CONVOLUTION)) { + // Clusters with real gaps between them. A measured space is not + // evenly spaced — that is exactly how a recording differs from + // a synthesis, and it is the only honest way to draw one. + let group = floor(fi / 5.0); + let within = fi - group * 5.0; + kx = clamp(group * 0.235 + within * 0.028 + s1 * 0.018, 0.0, 1.0); + y = mix(-0.92, 0.92, s2); + amp = (0.3 + 0.7 * s1) * (1.0 - kx * 0.35); + w = 0.016; + } + + let dk = (k - kx) / w; + let dy = (py - y) / 0.26; + acc = acc + exp(-(dk * dk + dy * dy)) * amp; + } + return acc; +} + +// How far into the window the early field reaches, as a fraction. +fn early_reach() -> f32 { + if (is_family(ROOM)) { return 0.16; } + if (is_family(SPRING)) { return 0.42; } + if (is_family(CONVOLUTION)) { return 0.55; } + return 0.28; +} + +// The envelope's shape, on the dB axis, 0..=1 at `k` of the way through the +// decay. Straight is the default; the families that are not straight are the +// ones whose whole character is the shape. +fn shape(k: f32) -> f32 { + let lin = clamp(1.0 - k, 0.0, 1.0); + if (is_family(AMBIENT)) { + // No walls: it SWELLS before it goes. A bloom that started at full + // level and fell would be indistinguishable from a hall. + let rise = smoothstep(0.0, 0.22, k); + return clamp(rise * lin * 1.6, 0.0, 1.0); + } + if (is_family(SPECIAL)) { + // Gated and non-linear: it holds, then stops. The cliff is the + // effect — a gate that faded out would not be a gate. + return select(0.0, 1.0, k < 0.78) * clamp(1.0 - smoothstep(0.74, 0.80, k), 0.0, 1.0); + } + if (is_family(PLATE)) { + // Steel is bright and quick at the end. + return pow(lin, 1.35); + } + return lin; +} + +// A cheap stable hash, for reflection placement. Deterministic in x, so the +// reflections stand still instead of crawling: where they ARE is the density, +// and a field that shimmered position would be saying something untrue. +fn rv_hash11(p: f32) -> f32 { + return fract(sin(p * 127.1) * 43758.5453); +} + +// The tail's height at `t` seconds, 0..=1 — on the dB axis, not the linear +// one. +// +// RT60 is the definition: 60 dB down at `decay` seconds. Plotting the LINEAR +// amplitude of that is a curve which has visually vanished by a fifth of the +// way across (−14 dB is already 0.2 of full scale), so nine tenths of the +// panel draws a tail that is still audibly there as nothing at all. Every +// other meter in this rack reads in dB for the same reason. +// +// On the dB axis the same envelope is a straight fall from full height to the +// floor at exactly `decay` — so the SLOPE is the RT60, which is the number +// the picture exists to show, and two reverbs set the same still look the +// same. +fn envelope_at(t: f32, decay: f32) -> f32 { + let predelay = max(u.params.z, 0.0); + if (t < predelay) { return 0.0; } + let since = t - predelay; + return shape(since / max(decay, 1e-3)); +} + +// The whole tail: the low end, which damping does not touch. +fn envelope(t: f32) -> f32 { + return envelope_at(t, max(u.params.x, 1e-3)); +} + +// The BRIGHT part of the tail — the top of the spectrum, which damping eats. +// +// A room and a hall set to the same RT60 draw the identical wedge, and the +// thing that actually tells them apart is how fast the highs go. Two nested +// envelopes say it in one picture: a bright core that dies early inside a +// dimmer body that lasts, and the gap between them IS the damping. At zero +// damping they coincide and the whole tail is bright, which is what an +// undamped reverb sounds like. +fn envelope_high(t: f32) -> f32 { + // A gate defines its own end. Damping the highs early would cut the + // bright core before the cliff and put TWO edges on the panel, which + // reads as a reverb that stops twice — the opposite of what a gate is. + if (is_family(SPECIAL)) { return envelope(t); } + let damp = clamp(u.time.z, 0.0, 1.0); + let decay = max(u.params.x, 1e-3) * (1.0 - 0.78 * damp); + return envelope_at(t, decay); +} + +fn effect(uv: vec2) -> vec4 { + reverb_load(); + let px = uv * vec2(u.frame.x, u.frame.y); + let t = u.frame.z; + let lit = u.frame.w > 0.5; + + let window = max(u.time.y, 1e-3); + let predelay = max(u.params.z, 0.0); + let density = clamp(u.params.y, 0.0, 1.0); + let mid = 0.5; + + var tint = u.color.rgb; + if (!lit) { tint = vec3(0.32, 0.32, 0.36); } + // Only a little toward white. At 0.55 the tail came out grey — the + // body covers most of the panel, so whatever colour it is IS the colour + // of the effect, and a purple reverb whose tail is white is not one. + let hot = hotter(tint, 0.34); + + var rgb = vec3(0.0); + var alpha = 0.0; + + // Seconds at this column. + let at = uv.x * window; + let env = envelope(at); + let env_hi = envelope_high(at); + // Distance from the centre line as a fraction of the half-height. + let off = abs(uv.y - mid) * 2.0; + + // ── Before anything arrives ───────────────────────────────────────── + // + // Pre-delay is silence, and silence should look like silence: the gap + // before the first reflection is the single clearest thing separating a + // room from a hall, so it is drawn as an actually empty lane. + let gap = 1.0 - step(predelay, at); + + // ── The lane itself ───────────────────────────────────────────────── + // + // The panel is a PURPLE field, not a black box with purple marks on it. + // See the delay's note: a near-black ground made every time-effect lane + // look the same, and the ground is where a panel says what it is before + // anything is drawn on it. + // + // Pre-delay stays darker than the rest, because silence before the first + // reflection is the clearest thing separating a room from a hall and it + // should look like silence — but it is not black, or the gap reads as a + // panel that has not loaded. + // Violet-led rather than near-neutral: the ground is most of the panel, + // so where it sits between black and the lane's colour is what the eye + // actually calls the panel's colour. + let base = mix(vec3(0.036, 0.016, 0.068), tint, 0.16); + let along = mix(1.30, 0.78, uv.x); + let across = mix(1.12, 0.70, clamp(off, 0.0, 1.0)); + let ground_rgb = base * along * across * (1.0 - gap * 0.45); + let edge_y = 1.0 - smoothstep(0.80, 1.0, off) * 0.45; + let ground_a = 0.95 * edge_y; + + // A random space's delay lines walk, so its envelope is never quite the + // same twice — the tail breathes rather than falling cleanly. This is + // the family's entire character and it is invisible in a still frame, + // which is exactly why it has to move. + var env_w = env; + if (is_family(RANDOM) && lit) { + let walk = sin(uv.x * 5.0 - t * 0.9) * sin(uv.x * 2.3 + t * 0.5); + env_w = clamp(env * (1.0 + walk * 0.22), 0.0, 1.0); + } + + // ── The tail ──────────────────────────────────────────────────────── + // + // A body that narrows as it decays, hazed rather than outlined. The eye + // reads the RATE it closes, which is the RT60 made visible. + let span = env_w; + let inside = 1.0 - smoothstep(span * 0.72, span * 1.1, off); + let body = inside * env_w * 0.34; + rgb += tint * body; + alpha += body * 0.75; + + // ── Steel ─────────────────────────────────────────────────────────── + // + // A plate is a sheet of metal under tension, and what it sounds like is + // bright and ringing rather than roomy. It has no early reflections to + // set it apart — that IS its character, density from the first + // millisecond — so without something else it is the dullest panel here + // and reads as a hall with the interesting part missing. The shimmer is + // the modal ringing the sheet actually has. + if (is_family(PLATE) && lit && at >= predelay) { + let modes = sin(uv.y * 38.0 + t * 1.3) * sin(uv.x * 19.0 - t * 2.1); + let ring = max(modes, 0.0) * env * inside * 0.30; + rgb += hotter(hot, 0.45) * ring; + alpha += ring * 0.55; + } + + // The lit rim where the body ends — the decay curve itself, as an edge + // made of light rather than a stroked path. + let rim = exp(-abs(off - span) * 17.0) * env_w * 0.8; + rgb += hot * rim; + alpha += rim * 0.85; + + // ── The bright core ───────────────────────────────────────────────── + // + // The high end of the tail, inside the body: where it stops is where the + // room goes dull. With damping at zero it reaches the same place as the + // rim and the tail is bright all the way; wound up, it closes early and + // leaves a long dim body behind it. + let span_hi = env_hi; + let inside_hi = 1.0 - smoothstep(span_hi * 0.66, span_hi * 1.05, off); + let core = inside_hi * env_hi * 0.26; + let core_rim = exp(-abs(off - span_hi) * 20.0) * env_hi * 0.65; + rgb += hotter(hot, 0.22) * (core + core_rim); + alpha += core * 0.6 + core_rim * 0.7; + + // ── The early reflections ─────────────────────────────────────────── + // + // Discrete arrivals near the front, packing tighter as density rises + // until they stop being countable and become the wash. That transition + // IS density, and it is why this is grain rather than a number. + let early_span = predelay + max(u.params.x, 1e-3) * early_reach(); + if (at < early_span && at >= predelay) { + let k = (at - predelay) / max(early_span - predelay, 1e-4); + // Early reflections thin out towards the wash rather than stopping at + // a hard line — the transition from countable to uncountable IS the + // density, and an edge would draw a boundary the ear does not hear. + let into_wash = 1.0 - smoothstep(0.58, 1.0, k); + let field = early_field(k, (uv.y - mid) * 2.0, density, t); + let spark = field * env * into_wash; + rgb += hot * spark * 1.25; + alpha += spark * 0.80; + } + + // ── The wash ──────────────────────────────────────────────────────── + // + // Past the early field the reflections are too dense to count, so they + // are drawn as a moving grain instead. This is the only part that moves, + // and it moves because a tail is not a still object. + if (lit && at >= predelay) { + // Two drifting fields rather than a product of two pixel-rate sines. + // The sines striped the panel vertically at any real resolution, which + // reads as static sitting on top of the reverb rather than as the + // reverb itself — and static does not decay, so it fought the one + // thing the picture is saying. + // + // Slow, wide, and crossing at an angle: close to still where the tail + // is quiet, alive where it is not. + let q = vec2(uv.x * 7.0, (uv.y - mid) * 5.0); + let a = sin(q.x * 1.7 - t * 0.55 + q.y * 0.9); + let b = sin(q.x * 1.1 + t * 0.37 - q.y * 1.6); + let field = (a * b) * 0.5 + 0.5; + // Density is how BROKEN the wash is: a sparse reverb still has + // separable reflections this far out, a dense one is smooth. + let broken = mix(smoothstep(0.35, 0.95, field), field, density); + let wash = broken * env * inside * 0.30 * (0.4 + 0.6 * density) + * (0.45 + 0.55 * env_hi); + rgb += mix(tint, hot, 0.35) * wash; + alpha += wash * 0.55; + } + + // ── Flutter ───────────────────────────────────────────────────────── + // + // A small room has parallel walls close together, and what that sounds + // like is a flutter: the same slap coming back at a fixed short spacing, + // over and over, long after the reflections should have smeared into a + // tail. It is the single artefact that says "this is a box" rather than + // "this is a big space", and without it a room drew the same picture as + // a hall with fewer early reflections — which the eye reads as the same + // picture. + // + // Evenly spaced, unlike everything else in the early field, because + // being evenly spaced is the whole complaint. + if (is_family(ROOM) && lit && at >= predelay) { + let decay = max(u.params.x, 1e-3); + // Tighter walls as density rises. + let spacing = mix(0.085, 0.040, density); + let k = (at - predelay) / decay; + let slap = fract(k / spacing); + let hit = exp(-slap * 14.0) + exp(-(1.0 - slap) * 14.0); + // Flutter outlives the early field but not the tail. + let live = exp(-k * 2.2); + let flutter = hit * live * env * inside * 0.55; + rgb += hotter(hot, 0.30) * flutter; + alpha += flutter * 0.65; + } + + // ── The spring ────────────────────────────────────────────────────── + // + // A spring disperses: high frequencies travel the coil faster than low + // ones, so a single hit arrives as a descending chirp — the "boing" that + // no other reverb makes. Drawn as chirp streaks sweeping down the panel, + // which is the one picture that could only be a spring. + if (is_family(SPRING) && lit && at >= predelay) { + let since = at - predelay; + var chirp = 0.0; + for (var i = 0u; i < 3u; i = i + 1u) { + let born = f32(i) * 0.14; + let age = since - born; + if (age <= 0.0) { continue; } + // Frequency falls as the bounce travels: y is where the energy + // has got to, and it slides toward the bottom of the lane. + let fall = 1.0 - exp(-age * 5.5); + let y = mix(0.12, 0.88, fall); + let d = abs(uv.y - y); + let w = 0.05 + 0.10 * fall; + chirp = chirp + exp(-(d * d) / (w * w)) * env * exp(-age * 1.6); + } + rgb += hotter(hot, 0.25) * chirp * 0.75; + alpha += chirp * 0.6; + } + + // ── Velvet ────────────────────────────────────────────────────────── + // + // The ambient family's sparse random grains: a cloud made of individual + // impulses far enough apart to be heard as texture rather than as a + // wash. Density thins them rather than thickening them, which is the + // opposite of every other family here. + if (is_family(AMBIENT) && lit && at >= predelay) { + let cellx = floor(uv.x * 90.0); + let celly = floor((uv.y - mid) * 24.0); + let seed = rv_hash11(cellx * 7.3 + celly * 13.1); + let alive = step(0.82 - 0.25 * density, seed); + let grain = alive * exp(-length(vec2(fract(uv.x * 90.0) - 0.5, + fract((uv.y - mid) * 24.0) - 0.5)) * 6.0); + let twinkle = 0.5 + 0.5 * sin(t * 2.2 + seed * TAU); + let velvet = grain * env * twinkle * 0.55; + rgb += mix(tint, hot, 0.6) * velvet; + alpha += velvet * 0.6; + } + + // ── The arrival ───────────────────────────────────────────────────── + // + // A bright mark where the first reflection lands. With the empty gap + // beside it, this is the pre-delay stated as a picture. + let arrive = exp(-abs(at - predelay) * 240.0 / window) * (1.0 - off * 0.5); + rgb += hot * arrive * 0.7; + alpha += arrive * 0.6; + + // Driven before the tone map. Reinhard on raw contributions crushes + // everything below 1.0, and 1.0 is brighter than anything this panel + // produces — so without the drive the whole picture sits in the bottom + // quarter of the curve and reads as switched off. + // A gentler drive than the delay's: a delay's marks are thin sticks on + // an empty lane and need lifting, where a reverb's tail covers most of + // the panel — driven as hard it saturates into one solid mass and loses + // the decay shape that is the entire message. + rgb = tonemap(rgb * 1.5); + alpha = clamp(alpha, 0.0, 0.92); + + // Marks over ground, composited properly rather than summed, and handed + // back premultiplied because that is what the compositor expects. + // + // The ground stays OUT of the curve above: it is not light the panel is + // emitting, it is the panel, and running it through the same tone map + // lifted it until it competed with what was drawn on it — a lane so + // bright the marks had no contrast left to stand out against. + let out_a = alpha + ground_a * (1.0 - alpha); + let out_rgb = rgb * alpha + ground_rgb * ground_a * (1.0 - alpha); + return viz_beats_over(viz_out(vec4(out_rgb, out_a)), uv.x * u.frame.x, u.frame.x, u.time.y, u.time.x, u.frame.w > 0.5); +} + +fn reverb_load() { + let decay = input_or(IN_DECAY, 2.0); + let predelay = input_or(IN_PREDELAY, 20.0) / 1000.0; + u.frame = vec4(node_px(), fx.time_s, input_or(IN_ON, 1.0)); + u.params = vec4(decay, input_or(IN_DENSITY, 0.5), predelay, input_or(IN_MIX, 0.3)); + // The window: the tail plus its pre-delay (processor's `window_of`). + u.time = vec4(max(60.0 / max(input_or(IN_BPM, 120.0), 1.0), 1e-3), max(predelay + decay, 0.05), clamp(input_or(IN_DAMP, 0.0), 0.0, 1.0), 0.0); + u.color = vec4(viz_tint(vec3(167.0, 139.0, 250.0) / 255.0), 1.0); + u.style = vec4(input_or(IN_FAMILY, 1.0), 0.0, 0.0, 0.0); +} diff --git a/crates/frame/src/render/mod.rs b/crates/frame/src/render/mod.rs new file mode 100644 index 00000000..7a511626 --- /dev/null +++ b/crates/frame/src/render/mod.rs @@ -0,0 +1,34 @@ +//! Frame renderer. +//! +//! One source, two backends: +//! - paint primitives (`anyrender_renderer`) painted into a vello scene +//! (`anyrender` feature) +//! - SVG export over the same primitives (`svg_export` feature) + +pub mod anyrender_renderer; +#[cfg(feature = "svg_export")] +pub mod svg; + +pub use anyrender_renderer::{ + CollectOptions, EffectsPlan, LayoutBox, PaintPrimitive, RenderDiagnostics, Rgba, + build_scene_layout_boxes, build_scene_primitives, build_scene_primitives_and_effects, + build_scene_primitives_with, collect_render_diagnostics, +}; +#[cfg(feature = "anyrender")] +pub use anyrender_renderer::{GlyphRun, PaintTarget}; +#[cfg(feature = "anyrender")] +pub use anyrender_renderer::{paint_primitives_into_scene_with, paint_scene_into}; + +pub mod fonts; +pub use fonts::{FontBook, FontSpec, book_for_scene, text_advance}; + +#[cfg(feature = "anyrender")] +pub mod fx; +#[cfg(feature = "anyrender")] +pub mod offscreen; + +#[cfg(feature = "render")] +pub mod font_book; + +#[cfg(feature = "render")] +pub mod canvas; diff --git a/crates/frame/src/render/offscreen.rs b/crates/frame/src/render/offscreen.rs new file mode 100644 index 00000000..618e9f54 --- /dev/null +++ b/crates/frame/src/render/offscreen.rs @@ -0,0 +1,546 @@ +//! Headless GPU rendering: a vello scene → RGBA pixels. +//! +//! One [`Offscreen`] holds the device and renderer, so rendering many frames +//! (parity runs, surface snapshots, previews) pays GPU setup once. + +use vello::wgpu; +use vello::{AaConfig, RenderParams, Renderer, RendererOptions}; + +pub struct Offscreen { + device: wgpu::Device, + queue: wgpu::Queue, + renderer: Renderer, + /// The effects stage, created on first use. + fx: Option, + /// Bands the last [`Offscreen::render`] took. + last_bands: u32, +} + +/// What a render target is cleared to before vello draws: vello writes +/// every pixel of a frame it renders, and none when a buffer overflows, so +/// a row of nothing but this is an overflow. (No render leaves a whole row +/// this colour: almost transparent, but not black.) +pub const SENTINEL: [u8; 4] = [3, 1, 4, 1]; + +/// Rows per band multiple: vello's tile height, so bands split on tiles. +const BAND_ALIGN: u32 = 16; + +/// The usages a [`render_checked`] target needs. +pub const CHECKED_TARGET_USAGES: wgpu::TextureUsages = wgpu::TextureUsages::RENDER_ATTACHMENT + .union(wgpu::TextureUsages::COPY_SRC) + .union(wgpu::TextureUsages::COPY_DST) + .union(wgpu::TextureUsages::STORAGE_BINDING) + .union(wgpu::TextureUsages::TEXTURE_BINDING); + +/// Whether `rgba` is entirely [`SENTINEL`] (vello drew nothing). +#[must_use] +pub fn is_untouched(rgba: &[u8]) -> bool { + rgba.as_chunks::<4>().0.iter().all(|p| *p == SENTINEL) +} + +/// Render `scene` into `target` (an `Rgba8Unorm` texture with +/// [`CHECKED_TARGET_USAGES`]) like `Renderer::render_to_texture`, but never +/// leave it blank. +/// +/// vello's GPU buffers (per-tile commands, tiles, segments, blend spill) +/// have fixed sizes that vello 0.10 cannot grow, and a frame that overflows +/// any of them renders *nothing*: coarse rasterisation flags the failure +/// and the fine stage skips every tile, with no error returned. A 6000 px +/// page overflows the per-tile command buffer on its own. So the target is +/// cleared to [`SENTINEL`] first; if its top row is still all sentinel +/// after the render, the frame is rendered again in horizontal bands +/// (halved until each fits — the viewport-sized buffers shrink with the +/// band) copied into place. +/// +/// Returns the number of bands drawn (1 when the frame fit whole). +/// +/// # Errors +/// On GPU failures, and when the scene overflows even the thinnest band +/// (its path data alone is too large). +pub fn render_checked( + renderer: &mut vello::Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &vello::Scene, + target: &wgpu::Texture, + base: peniko::Color, + antialiasing: AaConfig, +) -> Result { + let (width, height) = (target.width(), target.height()); + if render_band( + renderer, + device, + queue, + scene, + target, + 0, + base, + antialiasing, + )? { + return Ok(1); + } + let mut bands = 0; + // Bands still to draw, (top, height), top first; halved on overflow. + let half = |h: u32| (h / 2).div_ceil(BAND_ALIGN) * BAND_ALIGN; + let mut todo = vec![(half(height), height - half(height)), (0, half(height))]; + while let Some((top, h)) = todo.pop() { + let band = device.create_texture(&wgpu::TextureDescriptor { + label: Some("frame-band"), + size: wgpu::Extent3d { + width, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: target.format(), + usage: CHECKED_TARGET_USAGES, + view_formats: &[], + }); + if render_band( + renderer, + device, + queue, + scene, + &band, + top, + base, + antialiasing, + )? { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame-band-copy"), + }); + encoder.copy_texture_to_texture( + band.as_image_copy(), + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d { x: 0, y: top, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width, + height: h, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + bands += 1; + continue; + } + let split = half(h); + if split == 0 || split >= h { + return Err(format!( + "the scene overflows vello's GPU buffers even in a {width}×{h} band; simplify it or render it smaller" + )); + } + todo.push((top + split, h - split)); + todo.push((top, split)); + } + Ok(bands) +} + +/// Render rows `top..` of `scene` into `target`; `false` when vello's +/// buffers overflowed (its first row came back untouched). +#[allow(clippy::too_many_arguments)] +fn render_band( + renderer: &mut vello::Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &vello::Scene, + target: &wgpu::Texture, + top: u32, + base: peniko::Color, + antialiasing: AaConfig, +) -> Result { + let shifted; + let scene = if top == 0 { + scene + } else { + let mut s = vello::Scene::new(); + s.append( + scene, + Some(kurbo::Affine::translate((0.0, -f64::from(top)))), + ); + shifted = s; + &shifted + }; + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + clear_to_sentinel(device, queue, &view); + renderer + .render_to_texture( + device, + queue, + scene, + &view, + &RenderParams { + base_color: base, + width: target.width(), + height: target.height(), + antialiasing_method: antialiasing, + }, + ) + .map_err(|e| format!("render_to_texture failed: {e}"))?; + Ok(!is_untouched(&read_rows(device, queue, target, 0, 1)?)) +} + +/// Fill `view` with [`SENTINEL`]. +fn clear_to_sentinel(device: &wgpu::Device, queue: &wgpu::Queue, view: &wgpu::TextureView) { + let c = |v: u8| f64::from(v) / 255.0; + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame-sentinel"), + }); + drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("frame-sentinel"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: c(SENTINEL[0]), + g: c(SENTINEL[1]), + b: c(SENTINEL[2]), + a: c(SENTINEL[3]), + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + })); + queue.submit([encoder.finish()]); +} + +/// Render `scene` into `target` as [`render_checked`] renders a frame +/// that fits, without waiting for the check: the target's top row is read +/// back behind the render, and [`PendingCheck::poll`] says later whether +/// vello overflowed — `render_checked` again then, banded. +/// +/// For a host drawing every frame, where waiting on the GPU for one row +/// cost a surface's first frame 3–28 ms (the wait is for everything +/// queued ahead of it, not for the row). +/// +/// # Errors +/// On GPU failures. +pub fn render_deferred( + renderer: &mut vello::Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &vello::Scene, + target: &wgpu::Texture, + base: peniko::Color, + antialiasing: AaConfig, +) -> Result { + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + clear_to_sentinel(device, queue, &view); + renderer + .render_to_texture( + device, + queue, + scene, + &view, + &RenderParams { + base_color: base, + width: target.width(), + height: target.height(), + antialiasing_method: antialiasing, + }, + ) + .map_err(|e| format!("render_to_texture failed: {e}"))?; + Ok(PendingCheck(Readback::start(device, queue, target, 0, 1))) +} + +/// A render's overflow check, read back behind it: see [`render_deferred`]. +pub struct PendingCheck(Readback); + +impl PendingCheck { + /// `None` while the GPU is still at it; then whether the render + /// overflowed (drew nothing). Never waits. + /// + /// # Errors + /// When the readback failed. + pub fn poll(&self, device: &wgpu::Device) -> Option> { + let _ = device.poll(wgpu::PollType::Poll); + self.0 + .try_take() + .map(|rows| rows.map(|rgba| is_untouched(&rgba))) + } +} + +/// Rows of an `Rgba8Unorm` texture on their way back to the CPU. +struct Readback { + buffer: wgpu::Buffer, + mapped: std::sync::mpsc::Receiver>, + unpadded: u32, + padded: u32, + rows: u32, +} + +impl Readback { + /// Copy rows `top..top + rows` of `texture` out and ask for them mapped. + fn start( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + top: u32, + rows: u32, + ) -> Self { + let width = texture.width(); + let unpadded = width * 4; + let padded = unpadded.div_ceil(256) * 256; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("frame-offscreen-readback"), + size: u64::from(padded) * u64::from(rows), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame-offscreen-copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d { x: 0, y: top, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(rows), + }, + }, + wgpu::Extent3d { + width, + height: rows, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + let (tx, mapped) = std::sync::mpsc::channel(); + buffer.slice(..).map_async(wgpu::MapMode::Read, move |v| { + let _ = tx.send(v); + }); + Self { + buffer, + mapped, + unpadded, + padded, + rows, + } + } + + /// The rows (straight RGBA8, no padding) once mapped; `None` before. + fn try_take(&self) -> Option, String>> { + match self.mapped.try_recv() { + Ok(Ok(())) => {} + Ok(Err(e)) => return Some(Err(format!("map_async failed: {e}"))), + Err(std::sync::mpsc::TryRecvError::Empty) => return None, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + return Some(Err("map_async channel disconnected".into())); + } + } + let mapped = self.buffer.slice(..).get_mapped_range(); + let (unpadded, padded) = (self.unpadded as usize, self.padded as usize); + let mut rgba = vec![0_u8; unpadded * self.rows as usize]; + for row in 0..self.rows as usize { + rgba[row * unpadded..(row + 1) * unpadded] + .copy_from_slice(&mapped[row * padded..row * padded + unpadded]); + } + drop(mapped); + self.buffer.unmap(); + Some(Ok(rgba)) + } +} + +/// Copy rows `top..top + rows` of an `Rgba8Unorm` texture back to the CPU +/// (straight RGBA8, no padding), waiting for them. +fn read_rows( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + top: u32, + rows: u32, +) -> Result, String> { + let readback = Readback::start(device, queue, texture, top, rows); + loop { + let _ = device.poll(wgpu::PollType::Poll); + match readback.try_take() { + Some(rows) => return rows, + None => std::thread::sleep(std::time::Duration::from_millis(1)), + } + } +} + +/// Straight RGBA8 pixels, row-major, no padding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pixels { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl Offscreen { + /// # Errors + /// When no GPU adapter/device is available. + pub fn new() -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .map_err(|e| format!("request_adapter failed: {e}"))?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("frame-offscreen"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + experimental_features: wgpu::ExperimentalFeatures::disabled(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::default(), + })) + .map_err(|e| format!("request_device failed: {e}"))?; + let renderer = Renderer::new( + &device, + RendererOptions { + use_cpu: false, + antialiasing_support: [AaConfig::Area, AaConfig::Msaa8].into_iter().collect(), + num_init_threads: None, + pipeline_cache: None, + }, + ) + .map_err(|e| format!("failed to create renderer: {e}"))?; + Ok(Self { + device, + queue, + renderer, + fx: None, + last_bands: 0, + }) + } + + /// Render `scene` at `width × height` over `base`, splitting it into + /// bands when it overflows vello's buffers (see [`render_checked`]). + /// [`Self::last_bands`] tells how many bands the frame took. + /// + /// # Errors + /// On GPU failures, and when the scene overflows vello's buffers even + /// in the thinnest band (never a blank frame). + pub fn render( + &mut self, + scene: &vello::Scene, + width: u32, + height: u32, + base: peniko::Color, + ) -> Result { + let (width, height) = (width.clamp(1, 8192), height.clamp(1, 8192)); + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("frame-offscreen-target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: CHECKED_TARGET_USAGES, + view_formats: &[], + }); + self.last_bands = render_checked( + &mut self.renderer, + &self.device, + &self.queue, + scene, + &texture, + base, + AaConfig::Msaa8, + )?; + self.read_back(&texture) + } + + /// Render `scene` with a deferred check ([`render_deferred`]) and + /// poll it until the GPU answers: whether it overflowed. + #[cfg(test)] + // Test helper whose tests were cut in Studio (FastTrackStudios/studio#203). + #[allow(dead_code)] + pub(crate) fn deferred_overflow( + &mut self, + scene: &vello::Scene, + width: u32, + height: u32, + ) -> bool { + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("frame-offscreen-target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: CHECKED_TARGET_USAGES, + view_formats: &[], + }); + let base = peniko::Color::from_rgba8(255, 255, 255, 255); + let check = render_deferred( + &mut self.renderer, + &self.device, + &self.queue, + scene, + &texture, + base, + AaConfig::Area, + ) + .expect("render"); + loop { + if let Some(answer) = check.poll(&self.device) { + return answer.expect("readback"); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + + /// How many bands the last [`Self::render`] drew (1 when the frame fit + /// vello's buffers whole). + #[must_use] + pub const fn last_bands(&self) -> u32 { + self.last_bands + } + + /// Render a document through the effects stage ([`crate::render::fx`]), in + /// `frame.mode`. Antialiasing is MSAA 8×, like [`Self::render`]. + /// + /// # Errors + /// On GPU failures. + pub fn render_with_effects( + &mut self, + frame: &crate::render::fx::Frame<'_>, + ) -> Result { + let frame = crate::render::fx::Frame { + antialiasing: AaConfig::Msaa8, + ..*frame + }; + let stage = self + .fx + .get_or_insert_with(|| crate::render::fx::Stage::new(&self.device)); + let texture = stage.render(&self.device, &self.queue, &mut self.renderer, &frame)?; + self.read_back(&texture) + } + + /// Copy an `Rgba8Unorm` texture back to the CPU. + fn read_back(&self, texture: &wgpu::Texture) -> Result { + let rgba = read_rows(&self.device, &self.queue, texture, 0, texture.height())?; + Ok(Pixels { + width: texture.width(), + height: texture.height(), + rgba, + }) + } +} diff --git a/crates/frame/src/render/svg/cli.rs b/crates/frame/src/render/svg/cli.rs new file mode 100644 index 00000000..10b50d30 --- /dev/null +++ b/crates/frame/src/render/svg/cli.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "svg_export")] +pub use super::render::run_cli; diff --git a/crates/frame-ui/src/svg/context.rs b/crates/frame/src/render/svg/context.rs similarity index 100% rename from crates/frame-ui/src/svg/context.rs rename to crates/frame/src/render/svg/context.rs diff --git a/crates/frame-ui/src/svg/effects.rs b/crates/frame/src/render/svg/effects.rs similarity index 94% rename from crates/frame-ui/src/svg/effects.rs rename to crates/frame/src/render/svg/effects.rs index 4ce9ad22..8c525f3a 100644 --- a/crates/frame-ui/src/svg/effects.rs +++ b/crates/frame/src/render/svg/effects.rs @@ -1,6 +1,6 @@ use super::render::{ - apply_corner_radii_rect, fmt_f64, push_node, svg_color_paint_values, svg_linecap, - svg_linejoin, svg_mix_blend_mode, to_u8, + apply_corner_radii_rect, fmt_f64, push_node, svg_color_paint_values, svg_linecap, svg_linejoin, + svg_mix_blend_mode, to_u8, }; /// Format a shadow color component (0.0–1.0) with Figma-compatible precision. @@ -21,19 +21,19 @@ fn fmt_shadow_component(v: f64) -> String { fn build_effect_filter( root_id_suffix: &str, effect_count: &mut u64, - effects: &[crate::anyrender_renderer::NodeEffect], + effects: &[crate::render::anyrender_renderer::NodeEffect], x: f64, y: f64, width: f64, height: f64, ) -> Option<(svg::node::element::Filter, String)> { - use crate::anyrender_renderer::EffectKind; + use crate::render::anyrender_renderer::EffectKind; - let drop_shadows: Vec<&crate::anyrender_renderer::NodeEffect> = effects + let drop_shadows: Vec<&crate::render::anyrender_renderer::NodeEffect> = effects .iter() - .filter(|effect| matches!(effect.kind, EffectKind::DropShadow)) + .filter(|effect| matches!(effect.kind, EffectKind::DropShadow | EffectKind::Glow)) .collect(); - let inner_shadows: Vec<&crate::anyrender_renderer::NodeEffect> = effects + let inner_shadows: Vec<&crate::render::anyrender_renderer::NodeEffect> = effects .iter() .filter(|effect| matches!(effect.kind, EffectKind::InnerShadow)) .collect(); @@ -59,14 +59,22 @@ fn build_effect_filter( if effect_suffix.is_empty() { effect_suffix.push('f'); } - let filter_id = format!("filter{}_{}_{}", *effect_count, effect_suffix, root_id_suffix); + let filter_id = format!( + "filter{}_{}_{}", + *effect_count, effect_suffix, root_id_suffix + ); *effect_count += 1; // Compute filter bounds. let (mut min_x, mut min_y, mut max_x, mut max_y) = if drop_shadows.is_empty() { (x, y, x + width, y + height) } else { - (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY) + ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ) }; for shadow in &drop_shadows { let radius = shadow.radius.max(0.0); @@ -252,7 +260,11 @@ fn build_effect_filter( svg::Node::assign( &mut fe_morphology, "operator", - if shadow.spread > 0.0 { "erode" } else { "dilate" }, + if shadow.spread > 0.0 { + "erode" + } else { + "dilate" + }, ); svg::Node::assign(&mut fe_morphology, "in", "SourceAlpha"); svg::Node::assign(&mut fe_morphology, "result", this_result.clone()); @@ -338,16 +350,16 @@ pub(super) fn push_rect_with_effects( effect_count: &mut u64, bg_blur_count: &mut u64, rect: svg::node::element::Rectangle, - stroke: Option<&crate::anyrender_renderer::StrokeStyle>, - effects: &[crate::anyrender_renderer::NodeEffect], + stroke: Option<&crate::render::anyrender_renderer::StrokeStyle>, + effects: &[crate::render::anyrender_renderer::NodeEffect], x: f64, y: f64, width: f64, height: f64, - corner_radii: &frame_proto::CornerRadii, + corner_radii: &crate::document::proto::CornerRadii, extra_rects: &[svg::node::element::Rectangle], ) { - use crate::anyrender_renderer::EffectKind; + use crate::render::anyrender_renderer::EffectKind; if let Some((filter, filter_id)) = build_effect_filter(root_id_suffix, effect_count, effects, x, y, width, height) @@ -355,9 +367,9 @@ pub(super) fn push_rect_with_effects( *defs = defs.clone().add(filter); *has_defs = true; - let drop_shadows: Vec<&crate::anyrender_renderer::NodeEffect> = effects + let drop_shadows: Vec<&crate::render::anyrender_renderer::NodeEffect> = effects .iter() - .filter(|effect| matches!(effect.kind, EffectKind::DropShadow)) + .filter(|effect| matches!(effect.kind, EffectKind::DropShadow | EffectKind::Glow)) .collect(); let mut filtered = @@ -414,7 +426,7 @@ pub(super) fn push_rect_with_effects( if let Some(opacity) = stroke_opacity { stroke_rect = stroke_rect.set("stroke-opacity", opacity); } - let inset_radii = frame_proto::CornerRadii { + let inset_radii = crate::document::proto::CornerRadii { top_left: (corner_radii.top_left - inset).max(0.0), top_right: (corner_radii.top_right - inset).max(0.0), bottom_right: (corner_radii.bottom_right - inset).max(0.0), @@ -514,8 +526,8 @@ pub(super) fn push_group_with_blend_and_effects( root_id_suffix: &str, effect_count: &mut u64, mut group: svg::node::element::Group, - blend: &crate::anyrender_renderer::BlendMix, - effects: &[crate::anyrender_renderer::NodeEffect], + blend: &crate::render::anyrender_renderer::BlendMix, + effects: &[crate::render::anyrender_renderer::NodeEffect], x: f64, y: f64, width: f64, diff --git a/crates/frame-ui/src/svg/mod.rs b/crates/frame/src/render/svg/mod.rs similarity index 71% rename from crates/frame-ui/src/svg/mod.rs rename to crates/frame/src/render/svg/mod.rs index 68244e56..eea7cb8d 100644 --- a/crates/frame-ui/src/svg/mod.rs +++ b/crates/frame/src/render/svg/mod.rs @@ -9,7 +9,11 @@ mod normalize; #[cfg(feature = "svg_export")] mod render; #[cfg(feature = "svg_export")] -mod source_bridge; +mod scene_source; +#[cfg(feature = "svg_export")] +mod text; #[cfg(feature = "svg_export")] pub use cli::run_cli; +#[cfg(feature = "svg_export")] +pub use text::text_element; diff --git a/crates/frame-ui/src/svg/normalize.rs b/crates/frame/src/render/svg/normalize.rs similarity index 86% rename from crates/frame-ui/src/svg/normalize.rs rename to crates/frame/src/render/svg/normalize.rs index 93a54686..1eaaff0c 100644 --- a/crates/frame-ui/src/svg/normalize.rs +++ b/crates/frame/src/render/svg/normalize.rs @@ -6,11 +6,18 @@ pub(crate) struct NormalizeOptions { #[cfg(feature = "svg_export")] impl Default for NormalizeOptions { fn default() -> Self { - Self { compact_paths: true } + Self { + compact_paths: true, + } } } -pub(crate) fn normalize_svg_for_figma_parity(svg: &str, width: u32, height: u32, opts: &NormalizeOptions) -> String { +pub(crate) fn normalize_svg_for_figma_parity( + svg: &str, + width: u32, + height: u32, + opts: &NormalizeOptions, +) -> String { let mut out = Vec::new(); let mut body = Vec::new(); let root = if svg.contains("xlink:") { @@ -72,7 +79,9 @@ pub(crate) fn normalize_svg_for_figma_parity(svg: &str, width: u32, height: u32, out.extend(body); if !saw_root { // fallback: maintain well-formed output even if parser changes - out.push(format!(r##""##)); + out.push(format!( + r##""## + )); } out.push("".to_string()); out.join("\n") + "\n" @@ -238,21 +247,24 @@ fn normalize_rect_line(line: &str) -> Option { attrs.push(format!(r#"stroke-width="{val}""#)); } } - if let Some(linecap) = linecap { - if linecap != "butt" { - attrs.push(format!(r#"stroke-linecap="{linecap}""#)); - } + if let Some(linecap) = linecap + && linecap != "butt" + { + attrs.push(format!(r#"stroke-linecap="{linecap}""#)); } - if let Some(linejoin) = linejoin { - if linejoin != "miter" { - attrs.push(format!(r#"stroke-linejoin="{linejoin}""#)); - } + if let Some(linejoin) = linejoin + && linejoin != "miter" + { + attrs.push(format!(r#"stroke-linejoin="{linejoin}""#)); } if let Some(payload) = figma_gradient_fill { attrs.push(format!(r#"data-figma-gradient-fill="{payload}""#)); } if let Some(radius) = figma_bg_blur_radius { - attrs.push(format!(r#"data-figma-bg-blur-radius="{}""#, trim_decimal(&radius))); + attrs.push(format!( + r#"data-figma-bg-blur-radius="{}""#, + trim_decimal(&radius) + )); } Some(format!("", attrs.join(" "))) } @@ -398,7 +410,10 @@ fn normalize_line_element(line: &str) -> Option { attrs.push(format!(r#"stroke-dasharray="{stroke_dasharray}""#)); } if let Some(stroke_dashoffset) = extract_xml_attr(line, "stroke-dashoffset") { - attrs.push(format!(r#"stroke-dashoffset="{}""#, trim_decimal(&stroke_dashoffset))); + attrs.push(format!( + r#"stroke-dashoffset="{}""#, + trim_decimal(&stroke_dashoffset) + )); } Some(format!("", attrs.join(" "))) } @@ -438,7 +453,9 @@ fn normalize_group_line(line: &str) -> Option { } #[cfg(feature = "svg_export")] -fn parse_translate_decimal(transform: &str) -> Option<(rust_decimal::Decimal, rust_decimal::Decimal)> { +fn parse_translate_decimal( + transform: &str, +) -> Option<(rust_decimal::Decimal, rust_decimal::Decimal)> { let raw = transform.strip_prefix("translate(")?.strip_suffix(')')?; let mut parts = raw.split(','); let x = parse_decimal(parts.next()?.trim())?; @@ -648,11 +665,11 @@ fn translate_and_compact_path_data( match cmd { 'M' => { - if nums.len() < 2 || nums.len() % 2 != 0 { + if nums.len() < 2 || !nums.len().is_multiple_of(2) { return None; } let mut vals = Vec::with_capacity(nums.len()); - for (i, pair) in nums.chunks_exact(2).enumerate() { + for (i, pair) in nums.as_chunks::<2>().0.iter().enumerate() { let x = pair[0] + tx; let y = pair[1] + ty; vals.push(x); @@ -667,10 +684,10 @@ fn translate_and_compact_path_data( emit('M', &vals, &mut out, &mut first); } 'L' => { - if nums.len() % 2 != 0 { + if !nums.len().is_multiple_of(2) { return None; } - for pair in nums.chunks_exact(2) { + for pair in nums.as_chunks::<2>().0 { let x = pair[0] + tx; let y = pair[1] + ty; if compact_paths && y == curr_y { @@ -685,11 +702,11 @@ fn translate_and_compact_path_data( } } 'C' => { - if nums.len() % 6 != 0 { + if !nums.len().is_multiple_of(6) { return None; } let mut vals = Vec::with_capacity(nums.len()); - for seg in nums.chunks_exact(6) { + for seg in nums.as_chunks::<6>().0 { vals.push(seg[0] + tx); vals.push(seg[1] + ty); vals.push(seg[2] + tx); @@ -704,11 +721,11 @@ fn translate_and_compact_path_data( emit('C', &vals, &mut out, &mut first); } 'Q' | 'S' => { - if nums.len() % 4 != 0 { + if !nums.len().is_multiple_of(4) { return None; } let mut vals = Vec::with_capacity(nums.len()); - for seg in nums.chunks_exact(4) { + for seg in nums.as_chunks::<4>().0 { vals.push(seg[0] + tx); vals.push(seg[1] + ty); let x = seg[2] + tx; @@ -721,11 +738,11 @@ fn translate_and_compact_path_data( emit(cmd, &vals, &mut out, &mut first); } 'T' => { - if nums.len() % 2 != 0 { + if !nums.len().is_multiple_of(2) { return None; } let mut vals = Vec::with_capacity(nums.len()); - for seg in nums.chunks_exact(2) { + for seg in nums.as_chunks::<2>().0 { let x = seg[0] + tx; let y = seg[1] + ty; vals.push(x); @@ -736,11 +753,11 @@ fn translate_and_compact_path_data( emit('T', &vals, &mut out, &mut first); } 'A' => { - if nums.len() % 7 != 0 { + if !nums.len().is_multiple_of(7) { return None; } let mut vals = Vec::with_capacity(nums.len()); - for seg in nums.chunks_exact(7) { + for seg in nums.as_chunks::<7>().0 { vals.push(seg[0]); vals.push(seg[1]); vals.push(seg[2]); @@ -819,7 +836,8 @@ mod svg_normalize_tests { #[test] fn translate_uses_decimal_math() { - let line = r##""##; + let line = + r##""##; let out = normalize_translated_path_line(line, true).expect("normalized"); assert!(out.contains(r#"d="M29.8955 32.1489""#), "{out}"); } @@ -841,90 +859,3 @@ mod svg_normalize_tests { assert!(!out.contains("V"), "{out}"); } } - -#[cfg(feature = "svg_export")] -pub(crate) fn extract_export_bbox( - bytes: &[u8], - root_figma_id: Option<&str>, -) -> Result, String> { - let json: serde_json::Value = match serde_json::from_slice(bytes) { - Ok(v) => v, - Err(_) => return Ok(None), - }; - let nodes = match json.get("nodes").and_then(|n| n.as_array()) { - Some(nodes) if !nodes.is_empty() => nodes, - _ => return Ok(None), - }; - let target = if let Some(root_id) = root_figma_id { - nodes.iter().find(|node| node.get("id").and_then(|v| v.as_str()) == Some(root_id)) - } else { - nodes.first() - } - .unwrap_or(&nodes[0]); - let bbox = match target.get("absoluteBoundingBox") { - Some(v) => v, - None => return Ok(None), - }; - let x = bbox.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); - let y = bbox.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); - let w = bbox.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0); - let h = bbox.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0); - if w <= 0.0 || h <= 0.0 { - return Ok(None); - } - Ok(Some((x, y, w, h))) -} - -#[cfg(feature = "svg_export")] -pub(crate) fn extract_export_base_color( - bytes: &[u8], - root_figma_id: Option<&str>, -) -> Result, String> { - fn figma_f64_to_u8(v: f64) -> u8 { - (v * 255.0).round().clamp(0.0, 255.0) as u8 - } - - let json: serde_json::Value = match serde_json::from_slice(bytes) { - Ok(v) => v, - Err(_) => return Ok(None), - }; - - let nodes = match json.get("nodes").and_then(|n| n.as_array()) { - Some(nodes) if !nodes.is_empty() => nodes, - _ => return Ok(None), - }; - - let target = if let Some(root_id) = root_figma_id { - nodes.iter().find(|node| node.get("id").and_then(|v| v.as_str()) == Some(root_id)) - } else { - nodes.first() - } - .unwrap_or(&nodes[0]); - - let fills = match target.get("fills").and_then(|f| f.as_array()) { - Some(fills) if !fills.is_empty() => fills, - _ => return Ok(None), - }; - - for fill in fills { - if fill.get("visible").and_then(|v| v.as_bool()) == Some(false) { - continue; - } - if fill.get("type").and_then(|v| v.as_str()) != Some("SOLID") { - continue; - } - - let color = match fill.get("color") { - Some(c) => c, - None => continue, - }; - - let r = figma_f64_to_u8(color.get("r").and_then(|v| v.as_f64()).unwrap_or(1.0)); - let g = figma_f64_to_u8(color.get("g").and_then(|v| v.as_f64()).unwrap_or(1.0)); - let b = figma_f64_to_u8(color.get("b").and_then(|v| v.as_f64()).unwrap_or(1.0)); - let a = figma_f64_to_u8(fill.get("opacity").and_then(|v| v.as_f64()).unwrap_or(1.0)); - return Ok(Some((r, g, b, a))); - } - - Ok(None) -} diff --git a/crates/frame-ui/src/svg/render.rs b/crates/frame/src/render/svg/render.rs similarity index 64% rename from crates/frame-ui/src/svg/render.rs rename to crates/frame/src/render/svg/render.rs index dd6f3cc8..63ab90f0 100644 --- a/crates/frame-ui/src/svg/render.rs +++ b/crates/frame/src/render/svg/render.rs @@ -1,11 +1,8 @@ -use super::normalize::{ - extract_export_base_color, extract_export_bbox, normalize_svg_for_figma_parity, - NormalizeOptions, -}; use super::context::RenderCtx; -use super::source_bridge::{ - collect_source_absolute_bboxes, collect_source_stroke_data, extract_export_root_id, - extract_export_root_name, raw_stroke_paints, +use super::normalize::{NormalizeOptions, normalize_svg_for_figma_parity}; +use super::scene_source::{ + export_base_color, export_bbox, export_root, set_group_depth, source_bounds, source_id, + stroke_paints, }; #[cfg(feature = "svg_export")] use kurbo::Shape; @@ -32,15 +29,19 @@ impl ElementExt for svg::node::element::Element { } } +/// The `render_svg` command line: ` --out [--root ID] [--width PX] +/// [--height PX] [--no-compact-paths]`. `load` turns the input file's bytes +/// into a scene (the caller owns import). #[cfg(feature = "svg_export")] -pub fn run_cli() -> Result<(), String> { - use crate::{build_layout_boxes, build_paint_primitives, PaintPrimitive}; - use frame_proto::FrameDocument; +pub fn run_cli( + load: impl FnOnce(&[u8]) -> Result, +) -> Result<(), String> { + use crate::document::proto::NodeKind; + use crate::render::{PaintPrimitive, build_scene_layout_boxes, build_scene_primitives}; + use svg::Document; use svg::node::element::{ - ClipPath, Element, ForeignObject, Group, Image, Line, Mask, - Path as SvgPath, Pattern, Rectangle, Use, + ClipPath, Element, ForeignObject, Group, Image, Line, Mask, Path as SvgPath, Rectangle, }; - use svg::Document; let mut args = std::env::args().skip(1); let input = args @@ -80,37 +81,39 @@ pub fn run_cli() -> Result<(), String> { let out_path = out_path.ok_or_else(|| "--out is required".to_string())?; let bytes = std::fs::read(&input).map_err(|e| format!("failed reading {input}: {e}"))?; - let export_bbox = extract_export_bbox(&bytes, root_figma_id.as_deref())?; - let base_color = extract_export_base_color(&bytes, root_figma_id.as_deref())? + let scene = + load(&bytes).map_err(|e| format!("failed importing source document from {input}: {e}"))?; + let export_node = export_root(&scene, root_figma_id.as_deref()); + let export_bbox = export_node.and_then(export_bbox); + let base_color = export_node + .and_then(export_base_color) .unwrap_or((255, 255, 255, 255)); - let source_bboxes = collect_source_absolute_bboxes(&bytes)?; - let source_stroke_data = collect_source_stroke_data(&bytes)?; - let root_name = extract_export_root_name(&bytes, root_figma_id.as_deref())?; - let export_root_id = extract_export_root_id(&bytes, root_figma_id.as_deref())?; // Text parity fixtures in this corpus validate text outline serialization only. // Keep non-text geometry out of SVG export for parity/text/* scenarios. - let text_only_mode = root_name - .as_deref() - .map(|name| name.starts_with("parity/text/") && !name.contains("alignment-truncation")) + let text_only_mode = export_node + .map(|n| n.name.starts_with("parity/text/") && !n.name.contains("alignment-truncation")) .unwrap_or(false); - let doc = FrameDocument::from_source_bytes(&bytes) - .map_err(|e| format!("failed importing source document from {input}: {e}"))?; let root = match root_figma_id.as_deref() { - Some(figma_id) => doc - .get_by_figma_id(figma_id) + Some(figma_id) => scene + .by_source_id(figma_id) .map(|n| n.id) .ok_or_else(|| format!("root figma id not found: {figma_id}"))?, - None => doc.pages.first().copied().unwrap_or(doc.root), + None => scene.pages().first().copied().unwrap_or(scene.root), }; let root_id_suffix = root_figma_id .clone() - .or(export_root_id) - .or_else(|| doc.get_node(root).map(|n| n.figma_id.clone())) + .or_else(|| export_node.and_then(source_id).map(ToString::to_string)) + .or_else(|| { + scene + .node(root) + .and_then(source_id) + .map(ToString::to_string) + }) .unwrap_or_else(|| "root".to_string()) .replace(':', "_"); - let layout_boxes = build_layout_boxes(&doc, root); - let subtree = doc.walk_subtree(root); + let layout_boxes = build_scene_layout_boxes(&scene, root); + let subtree = scene.walk(root); let mut min_x = f64::INFINITY; let mut min_y = f64::INFINITY; @@ -160,7 +163,7 @@ pub fn run_cli() -> Result<(), String> { height.max(bbox_height) }; - let primitives = build_paint_primitives(&doc, root); + let primitives = build_scene_primitives(&scene, root); let mut svg_doc = Document::new() .set("width", width) @@ -172,8 +175,12 @@ pub fn run_cli() -> Result<(), String> { let ty = -min_y; let canvas_w = width as f64; let canvas_h = height as f64; - let (bg_hex, bg_opacity) = - svg_color_paint_values(base_color.0, base_color.1, base_color.2, f64::from(base_color.3) / 255.0); + let (bg_hex, bg_opacity) = svg_color_paint_values( + base_color.0, + base_color.1, + base_color.2, + f64::from(base_color.3) / 255.0, + ); // Export parity corpus expects an explicit root background only when the // source background is fully opaque. if bg_opacity.is_none() { @@ -206,12 +213,15 @@ pub fn run_cli() -> Result<(), String> { let mut clip_depth: usize = 0; // (depth_at_deferral, stroke_rect) — drained when the matching ClipEnd fires. let mut pending_post_clip_stroke: Vec<(usize, Rectangle)> = Vec::new(); + set_group_depth(None); for primitive in primitives { if text_only_mode { match &primitive { PaintPrimitive::Path { text_outline, .. } if *text_outline => {} - PaintPrimitive::Rect { .. } + PaintPrimitive::GroupStart { .. } + | PaintPrimitive::GroupEnd { .. } + | PaintPrimitive::Rect { .. } | PaintPrimitive::LayerStart { .. } | PaintPrimitive::LayerEnd { .. } | PaintPrimitive::ClipStart { .. } @@ -224,6 +234,30 @@ pub fn run_cli() -> Result<(), String> { } } match primitive { + PaintPrimitive::GroupStart { transform: g, .. } => { + // `g` maps flattened scene space; conjugate it by the export + // offset so it maps SVG user space: T(t) · g · T(-t). + let e = g.tx + tx - (g.a * tx + g.b * ty); + let f = g.ty + ty - (g.c * tx + g.d * ty); + let group = Group::new().set( + "transform", + format!( + "matrix({} {} {} {} {} {})", + fmt_f64(g.a), + fmt_f64(g.c), + fmt_f64(g.b), + fmt_f64(g.d), + fmt_f64(e), + fmt_f64(f) + ), + ); + open_scoped_group(&mut group_stack, group); + set_group_depth(Some(1)); + } + PaintPrimitive::GroupEnd { .. } => { + close_scoped_group(&mut svg_doc, &mut group_stack); + set_group_depth(Some(-1)); + } PaintPrimitive::LayerStart { opacity, blend, .. } => { let mut group = Group::new().set("opacity", fmt_f64(opacity.clamp(0.0, 1.0))); if let Some(mode) = svg_mix_blend_mode(&blend) { @@ -353,9 +387,9 @@ pub fn run_cli() -> Result<(), String> { let rw = width.max(0.0); let rh = height.max(0.0); accumulate_bounds_if_active(&mut active_mask_bounds, rx, ry, rx + rw, ry + rh); - let node = doc.get_node(node_id); + let node = scene.node(node_id); let frame_rect_uses_transform = node - .map(|n| n.figma_type == "FRAME") + .map(|n| matches!(n.kind, NodeKind::Frame)) .unwrap_or(false); // The document background rectangle is emitted above. Skip an identical // full-canvas top-level rect to avoid duplicate backgrounds. @@ -368,96 +402,27 @@ pub fn run_cli() -> Result<(), String> { && stroke.is_none() && effects.is_empty() && corner_radii.is_zero() - { - if let Some((crate::anyrender_renderer::FillPaint::Solid(color), _)) = + && let Some((crate::render::anyrender_renderer::FillPaint::Solid(color), _)) = fills.first() - { - let (hex, opacity) = svg_color_paint_values( - to_u8(color.r), - to_u8(color.g), - to_u8(color.b), - color.a, - ); - if hex == bg_hex && opacity.is_none() { - continue; - } + { + let (hex, opacity) = svg_color_paint_values( + to_u8(color.r), + to_u8(color.g), + to_u8(color.b), + color.a, + ); + if hex == bg_hex && opacity.is_none() { + continue; } } - let effective_corner_radii = if !corner_radii.is_zero() { - corner_radii - } else { - node.and_then(|n| { - n.raw - .get("rectangleCornerRadii") - .and_then(|v| v.as_array()) - .and_then(|arr| { - if arr.len() >= 4 { - Some(frame_proto::CornerRadii { - top_left: arr[0].as_f64().unwrap_or(0.0).max(0.0), - top_right: arr[1].as_f64().unwrap_or(0.0).max(0.0), - bottom_right: arr[2].as_f64().unwrap_or(0.0).max(0.0), - bottom_left: arr[3].as_f64().unwrap_or(0.0).max(0.0), - }) - } else { - None - } - }) - .or_else(|| { - let uniform = n - .raw - .get("cornerRadius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0); - if uniform > 0.0 { - Some(frame_proto::CornerRadii::uniform(uniform)) - } else { - None - } - }) - .or_else(|| { - let tl = n - .raw - .get("topLeftRadius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0); - let tr = n - .raw - .get("topRightRadius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0); - let br = n - .raw - .get("bottomRightRadius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0); - let bl = n - .raw - .get("bottomLeftRadius") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - .max(0.0); - if tl > 0.0 || tr > 0.0 || br > 0.0 || bl > 0.0 { - Some(frame_proto::CornerRadii { - top_left: tl, - top_right: tr, - bottom_right: br, - bottom_left: bl, - }) - } else { - None - } - }) - }).unwrap_or(corner_radii) - }; + // The importer resolves Figma's per-corner / uniform radius + // spellings, so the primitive's radii are already the node's. + let effective_corner_radii = corner_radii; if fills.len() > 1 && !effects.is_empty() { let mut aggregate_group = Group::new(); for (fill, fill_blend) in &fills { match fill { - crate::anyrender_renderer::FillPaint::GradientLinear { + crate::render::anyrender_renderer::FillPaint::GradientLinear { transform, stops, } => { @@ -483,14 +448,15 @@ pub fn run_cli() -> Result<(), String> { .set("fill", format!("url(#{gradient_id})")); rect = apply_corner_radii_rect(rect, &effective_corner_radii); if let Some(mode) = svg_mix_blend_mode(fill_blend) { - aggregate_group = aggregate_group - .add(rect.set("style", format!("mix-blend-mode:{mode}"))); + aggregate_group = aggregate_group.add( + rect.set("style", format!("mix-blend-mode:{mode}")), + ); } else { aggregate_group = aggregate_group.add(rect); } } } - crate::anyrender_renderer::FillPaint::GradientRadial { + crate::render::anyrender_renderer::FillPaint::GradientRadial { transform, stops, } => { @@ -516,14 +482,15 @@ pub fn run_cli() -> Result<(), String> { .set("fill", format!("url(#{gradient_id})")); rect = apply_corner_radii_rect(rect, &effective_corner_radii); if let Some(mode) = svg_mix_blend_mode(fill_blend) { - aggregate_group = aggregate_group - .add(rect.set("style", format!("mix-blend-mode:{mode}"))); + aggregate_group = aggregate_group.add( + rect.set("style", format!("mix-blend-mode:{mode}")), + ); } else { aggregate_group = aggregate_group.add(rect); } } } - crate::anyrender_renderer::FillPaint::Image { + crate::render::anyrender_renderer::FillPaint::Image { image_hash, data_base64, alpha, @@ -553,32 +520,15 @@ pub fn run_cli() -> Result<(), String> { deferred_images.push(image); new_id }; - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") - .set("width", "1") - .set("height", "1"); - let mut image_use = Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = svg_image_fill_transform( + let pattern = svg_image_pattern( + &pattern_id, + &image_id, *scale_mode, image_transform.as_ref(), rw, rh, - ) { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), - ); - } - pattern = pattern.add(image_use); + data_base64, + ); defs = defs.add(pattern); has_defs = true; has_xlink_namespace = true; @@ -610,8 +560,9 @@ pub fn run_cli() -> Result<(), String> { rect = apply_corner_radii_rect(rect, &effective_corner_radii); rect = apply_fill_style_rect(rect, &style); if let Some(mode) = svg_mix_blend_mode(fill_blend) { - aggregate_group = aggregate_group - .add(rect.set("style", format!("mix-blend-mode:{mode}"))); + aggregate_group = aggregate_group.add( + rect.set("style", format!("mix-blend-mode:{mode}")), + ); } else { aggregate_group = aggregate_group.add(rect); } @@ -620,11 +571,13 @@ pub fn run_cli() -> Result<(), String> { } } if let Some(stroke) = stroke.as_ref() { - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, + let (hex, opacity) = svg_stroke_paint( + stroke, + (rx, ry, rw, rh), + &mut gradient_count, + &root_id_suffix, + &mut deferred_gradients, + &mut has_defs, ); let mut stroke_rect = Rectangle::new() .set("x", fmt_f64(rx)) @@ -661,269 +614,94 @@ pub fn run_cli() -> Result<(), String> { // Compute frame stroke overlay data before the single-fill shortcut so // FRAME nodes with gradient strokes emit their stroke overlay rects even // when there is only one gradient fill. - let raw_frame_strokes_early = node - .and_then(|n| source_stroke_data.get(&n.figma_id).map(|(p, _)| p.clone())) - .filter(|p| !p.is_empty()) - .or_else(|| node.map(|n| raw_stroke_paints(&n.raw))) - .unwrap_or_default(); + let raw_frame_strokes_early = + node.map(|n| stroke_paints(n, &scene)).unwrap_or_default(); let has_complex_frame_strokes_early = raw_frame_strokes_early.len() > 1 || raw_frame_strokes_early.iter().any(|paint| { - !matches!(paint, crate::anyrender_renderer::FillPaint::Solid(_)) + !matches!( + paint, + crate::render::anyrender_renderer::FillPaint::Solid(_) + ) }); let frame_stroke_width_early = stroke .as_ref() .map(|s| s.width.max(0.0)) - .or_else(|| { - node.and_then(|n| source_stroke_data.get(&n.figma_id)) - .and_then(|(_, w)| *w) - .map(|w| w.max(0.0)) - }) - .or_else(|| { - node.and_then(|n| n.raw.get("strokeWeight").and_then(|v| v.as_f64())) - .map(|w| w.max(0.0)) - }) + .or_else(|| node.map(|n| n.stroke.weight.max(0.0))) .unwrap_or(0.0); - if fills.len() == 1 { - if let Some((fill, blend_mode)) = fills.first() { - match fill { - crate::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { - let gradient_id = - format!("paint{gradient_count}_linear_{root_id_suffix}"); - gradient_count += 1; - if let Some(gradient) = svg_linear_gradient_def( - &gradient_id, - rx, - ry, - rw, - rh, - transform.as_ref(), - stops.as_slice(), - ) { - deferred_gradients.push(Box::new(gradient)); - has_defs = true; - let mut rect = Rectangle::new() - .set("x", fmt_f64(rx)) - .set("y", fmt_f64(ry)) - .set("width", fmt_f64(rw)) - .set("height", fmt_f64(rh)) - .set("fill", format!("url(#{gradient_id})")); - rect = apply_corner_radii_rect(rect, &effective_corner_radii); - if frame_rect_uses_transform - && has_complex_frame_strokes_early - && frame_stroke_width_early > 0.0 - { - // FRAME node with complex strokes: emit fill-only rect - // then push all stroke overlays (solid + gradient) separately. - push_node_with_optional_blend( - &mut svg_doc, - &mut group_stack, - rect, - Some(blend_mode), - ); - push_complex_frame_stroke_overlays( - &mut svg_doc, - &mut group_stack, - &mut gradient_count, - &mut deferred_gradients, - &mut has_defs, - &root_id_suffix, - rx, - ry, - rw, - rh, - &effective_corner_radii, - frame_stroke_width_early, - &raw_frame_strokes_early, - blend_mode, - ); - } else { - if let Some(stroke) = stroke.as_ref() { - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, - ); - rect = rect - .set("stroke", hex) - .set("stroke-width", fmt_f64(stroke.width.max(0.0))) - .set("stroke-linecap", svg_linecap(stroke.cap)) - .set("stroke-linejoin", svg_linejoin(stroke.join)); - if let Some(opacity) = opacity { - rect = rect.set("stroke-opacity", opacity); - } - } - push_node_with_optional_blend( - &mut svg_doc, - &mut group_stack, - rect, - Some(blend_mode), - ); - } - continue; - } - } - crate::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { - let gradient_id = - format!("paint{gradient_count}_radial_{root_id_suffix}"); - gradient_count += 1; - if let Some(gradient) = svg_radial_gradient_def( - &gradient_id, - rx, - ry, - rw, - rh, - transform.as_ref(), - stops.as_slice(), - ) { - deferred_gradients.push(Box::new(gradient)); - has_defs = true; - let mut rect = Rectangle::new() - .set("x", fmt_f64(rx)) - .set("y", fmt_f64(ry)) - .set("width", fmt_f64(rw)) - .set("height", fmt_f64(rh)) - .set("fill", format!("url(#{gradient_id})")); - rect = apply_corner_radii_rect(rect, &effective_corner_radii); - if frame_rect_uses_transform - && has_complex_frame_strokes_early - && frame_stroke_width_early > 0.0 - { - push_node_with_optional_blend( - &mut svg_doc, - &mut group_stack, - rect, - Some(blend_mode), - ); - push_complex_frame_stroke_overlays( - &mut svg_doc, - &mut group_stack, - &mut gradient_count, - &mut deferred_gradients, - &mut has_defs, - &root_id_suffix, - rx, - ry, - rw, - rh, - &effective_corner_radii, - frame_stroke_width_early, - &raw_frame_strokes_early, - blend_mode, - ); - } else { - if let Some(stroke) = stroke.as_ref() { - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, - ); - rect = rect - .set("stroke", hex) - .set("stroke-width", fmt_f64(stroke.width.max(0.0))) - .set("stroke-linecap", svg_linecap(stroke.cap)) - .set("stroke-linejoin", svg_linejoin(stroke.join)); - if let Some(opacity) = opacity { - rect = rect.set("stroke-opacity", opacity); - } - } - push_node_with_optional_blend( - &mut svg_doc, - &mut group_stack, - rect, - Some(blend_mode), - ); - } - continue; - } - } - crate::anyrender_renderer::FillPaint::GradientAngular { - transform, - stops, - } => { - let gradient_id = - format!("paint{gradient_count}_angular_{root_id_suffix}"); - gradient_count += 1; - let clip_id = format!("{gradient_id}_clip_path"); - let clip_rect = Rectangle::new() - .set("x", fmt_f64(rx)) - .set("y", fmt_f64(ry)) - .set("width", fmt_f64(rw)) - .set("height", fmt_f64(rh)); - defs = defs.add(ClipPath::new().set("id", clip_id.clone()).add(clip_rect)); + if fills.len() == 1 + && let Some((fill, blend_mode)) = fills.first() + { + match fill { + crate::render::anyrender_renderer::FillPaint::GradientLinear { + transform, + stops, + } => { + let gradient_id = + format!("paint{gradient_count}_linear_{root_id_suffix}"); + gradient_count += 1; + if let Some(gradient) = svg_linear_gradient_def( + &gradient_id, + rx, + ry, + rw, + rh, + transform.as_ref(), + stops.as_slice(), + ) { + deferred_gradients.push(Box::new(gradient)); has_defs = true; - - let center_x = rx + rw * 0.5; - let center_y = ry + rh * 0.5; - let scale_x = rw / 2000.0; - let scale_y = rh / 2000.0; - let mut foreign = ForeignObject::new() - .set("x", "-1011.11") - .set("y", "-1011.11") - .set("width", "2022.22") - .set("height", "2022.22"); - let mut div = Element::new("div"); - svg::Node::assign(&mut div, "xmlns", "http://www.w3.org/1999/xhtml"); - svg::Node::assign( - &mut div, - "style", - svg_angular_conic_style(transform.as_ref(), stops.as_slice()), - ); - foreign = foreign.add(div); - let inner = Group::new() - .set( - "transform", - format!( - "matrix({} 0 0 {} {} {})", - fmt_f64(scale_x), - fmt_f64(scale_y), - fmt_f64(center_x), - fmt_f64(center_y) - ), - ) - .add(foreign); - let group = Group::new() - .set("clip-path", format!("url(#{clip_id})")) - .set("data-figma-skip-parse", "true") - .add(inner); - let metadata_rect = Rectangle::new() + let mut rect = Rectangle::new() .set("x", fmt_f64(rx)) .set("y", fmt_f64(ry)) .set("width", fmt_f64(rw)) .set("height", fmt_f64(rh)) - .set( - "data-figma-gradient-fill", - svg_angular_metadata_attr(rx, ry, rw, rh, None, stops.as_slice()), + .set("fill", format!("url(#{gradient_id})")); + rect = apply_corner_radii_rect(rect, &effective_corner_radii); + if frame_rect_uses_transform + && has_complex_frame_strokes_early + && frame_stroke_width_early > 0.0 + { + // FRAME node with complex strokes: emit fill-only rect + // then push all stroke overlays (solid + gradient) separately. + push_node_with_optional_blend( + &mut svg_doc, + &mut group_stack, + rect, + Some(blend_mode), ); - let angular_group = Group::new().add(group).add(metadata_rect); - push_node_with_optional_blend( - &mut svg_doc, - &mut group_stack, - angular_group, - Some(blend_mode), - ); - - if let Some(stroke) = stroke.as_ref() { - let mut rect = Rectangle::new() - .set("x", fmt_f64(rx)) - .set("y", fmt_f64(ry)) - .set("width", fmt_f64(rw)) - .set("height", fmt_f64(rh)) - .set("fill", "none") - .set("stroke-width", fmt_f64(stroke.width.max(0.0))) - .set("stroke-linecap", svg_linecap(stroke.cap)) - .set("stroke-linejoin", svg_linejoin(stroke.join)); - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, + push_complex_frame_stroke_overlays( + &mut svg_doc, + &mut group_stack, + &mut gradient_count, + &mut deferred_gradients, + &mut has_defs, + &root_id_suffix, + rx, + ry, + rw, + rh, + &effective_corner_radii, + frame_stroke_width_early, + &raw_frame_strokes_early, + blend_mode, ); - rect = rect.set("stroke", hex); - if let Some(opacity) = opacity { - rect = rect.set("stroke-opacity", opacity); + } else { + if let Some(stroke) = stroke.as_ref() { + let (hex, opacity) = svg_color_paint_values( + to_u8(stroke.color.r), + to_u8(stroke.color.g), + to_u8(stroke.color.b), + stroke.color.a, + ); + rect = rect + .set("stroke", hex) + .set("stroke-width", fmt_f64(stroke.width.max(0.0))) + .set("stroke-linecap", svg_linecap(stroke.cap)) + .set("stroke-linejoin", svg_linejoin(stroke.join)); + if let Some(opacity) = opacity { + rect = rect.set("stroke-opacity", opacity); + } } push_node_with_optional_blend( &mut svg_doc, @@ -934,150 +712,59 @@ pub fn run_cli() -> Result<(), String> { } continue; } - crate::anyrender_renderer::FillPaint::Image { - image_hash, - data_base64, - alpha, - scale_mode, - image_transform, - } => { - let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); - pattern_count += 1; - let image_key = image_hash - .clone() - .unwrap_or_else(|| format!("inline:{data_base64}")); - let image_id = if let Some(existing) = image_defs.get(&image_key) { - existing.clone() - } else { - let new_id = format!("image{image_count}_{root_id_suffix}"); - image_count += 1; - image_defs.insert(image_key, new_id.clone()); - let image = Image::new() - .set("id", new_id.clone()) - .set("width", "1") - .set("height", "1") - .set("preserveAspectRatio", "none") - .set( - "xlink:href", - format!("data:image/png;base64,{data_base64}"), - ); - deferred_images.push(image); - new_id - }; - - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") - .set("width", "1") - .set("height", "1"); - let mut image_use = Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = svg_image_fill_transform( - *scale_mode, - image_transform.as_ref(), - rw, - rh, - ) { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), - ); - } - pattern = pattern.add(image_use); - defs = defs.add(pattern); + } + crate::render::anyrender_renderer::FillPaint::GradientRadial { + transform, + stops, + } => { + let gradient_id = + format!("paint{gradient_count}_radial_{root_id_suffix}"); + gradient_count += 1; + if let Some(gradient) = svg_radial_gradient_def( + &gradient_id, + rx, + ry, + rw, + rh, + transform.as_ref(), + stops.as_slice(), + ) { + deferred_gradients.push(Box::new(gradient)); has_defs = true; - has_xlink_namespace = true; - let mut rect = Rectangle::new() .set("x", fmt_f64(rx)) .set("y", fmt_f64(ry)) .set("width", fmt_f64(rw)) .set("height", fmt_f64(rh)) - .set("fill", format!("url(#{pattern_id})")); + .set("fill", format!("url(#{gradient_id})")); rect = apply_corner_radii_rect(rect, &effective_corner_radii); - if *alpha < 1.0 { - rect = rect.set("fill-opacity", fmt_f64(*alpha)); - } - if let Some(stroke) = stroke.as_ref() { - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, + if frame_rect_uses_transform + && has_complex_frame_strokes_early + && frame_stroke_width_early > 0.0 + { + push_node_with_optional_blend( + &mut svg_doc, + &mut group_stack, + rect, + Some(blend_mode), ); - rect = rect - .set("stroke", hex) - .set("stroke-width", fmt_f64(stroke.width.max(0.0))) - .set("stroke-linecap", svg_linecap(stroke.cap)) - .set("stroke-linejoin", svg_linejoin(stroke.join)); - if let Some(opacity) = opacity { - rect = rect.set("stroke-opacity", opacity); - } - } - if let Some(mode) = svg_mix_blend_mode(blend_mode) { - rect = rect.set("style", format!("mix-blend-mode:{mode}")); - } - super::effects::push_rect_with_effects( - &mut svg_doc, - &mut group_stack, - &mut defs, - &mut has_defs, - &root_id_suffix, - &mut effect_count, - &mut bg_blur_count, - rect, - None, - effects.as_slice(), - rx, - ry, - rw, - rh, - &effective_corner_radii, - &[], - ); - continue; - } - _ => {} - } - - if let Some(style) = svg_fill_style(fill) { - let mut rect = if frame_rect_uses_transform { - Rectangle::new() - .set("width", fmt_f64(rw)) - .set("height", fmt_f64(rh)) - .set( - "transform", - format!("translate({} {})", fmt_f64(rx), fmt_f64(ry)), - ) - } else { - Rectangle::new() - .set("x", fmt_f64(rx)) - .set("y", fmt_f64(ry)) - .set("width", fmt_f64(rw)) - .set("height", fmt_f64(rh)) - }; - rect = apply_corner_radii_rect(rect, &effective_corner_radii); - if let crate::anyrender_renderer::FillPaint::Solid(color) = fill { - let (hex, fill_opacity) = svg_color_paint_values( - to_u8(color.r), - to_u8(color.g), - to_u8(color.b), - color.a, - ); - rect = rect.set("fill", hex); - let opacity = fill_opacity; - if let Some(mode) = svg_mix_blend_mode(blend_mode) { - let mut group = Group::new().set("style", format!("mix-blend-mode:{mode}")); - if let Some(opacity) = opacity { - group = group.set("opacity", opacity); - } + push_complex_frame_stroke_overlays( + &mut svg_doc, + &mut group_stack, + &mut gradient_count, + &mut deferred_gradients, + &mut has_defs, + &root_id_suffix, + rx, + ry, + rw, + rh, + &effective_corner_radii, + frame_stroke_width_early, + &raw_frame_strokes_early, + blend_mode, + ); + } else { if let Some(stroke) = stroke.as_ref() { let (hex, opacity) = svg_color_paint_values( to_u8(stroke.color.r), @@ -1094,13 +781,172 @@ pub fn run_cli() -> Result<(), String> { rect = rect.set("stroke-opacity", opacity); } } - push_node(&mut svg_doc, &mut group_stack, group.add(rect)); - continue; - } else if let Some(opacity) = opacity { - rect = rect.set("opacity", opacity); + push_node_with_optional_blend( + &mut svg_doc, + &mut group_stack, + rect, + Some(blend_mode), + ); } + continue; + } + } + crate::render::anyrender_renderer::FillPaint::GradientAngular { + transform, + stops, + } => { + let gradient_id = + format!("paint{gradient_count}_angular_{root_id_suffix}"); + gradient_count += 1; + let clip_id = format!("{gradient_id}_clip_path"); + let clip_rect = Rectangle::new() + .set("x", fmt_f64(rx)) + .set("y", fmt_f64(ry)) + .set("width", fmt_f64(rw)) + .set("height", fmt_f64(rh)); + defs = + defs.add(ClipPath::new().set("id", clip_id.clone()).add(clip_rect)); + has_defs = true; + + let center_x = rx + rw * 0.5; + let center_y = ry + rh * 0.5; + let scale_x = rw / 2000.0; + let scale_y = rh / 2000.0; + let mut foreign = ForeignObject::new() + .set("x", "-1011.11") + .set("y", "-1011.11") + .set("width", "2022.22") + .set("height", "2022.22"); + let mut div = Element::new("div"); + svg::Node::assign(&mut div, "xmlns", "http://www.w3.org/1999/xhtml"); + svg::Node::assign( + &mut div, + "style", + svg_angular_conic_style(transform.as_ref(), stops.as_slice()), + ); + foreign = foreign.add(div); + let inner = Group::new() + .set( + "transform", + format!( + "matrix({} 0 0 {} {} {})", + fmt_f64(scale_x), + fmt_f64(scale_y), + fmt_f64(center_x), + fmt_f64(center_y) + ), + ) + .add(foreign); + let group = Group::new() + .set("clip-path", format!("url(#{clip_id})")) + .set("data-figma-skip-parse", "true") + .add(inner); + let metadata_rect = Rectangle::new() + .set("x", fmt_f64(rx)) + .set("y", fmt_f64(ry)) + .set("width", fmt_f64(rw)) + .set("height", fmt_f64(rh)) + .set( + "data-figma-gradient-fill", + svg_angular_metadata_attr( + rx, + ry, + rw, + rh, + None, + stops.as_slice(), + ), + ); + let angular_group = Group::new().add(group).add(metadata_rect); + push_node_with_optional_blend( + &mut svg_doc, + &mut group_stack, + angular_group, + Some(blend_mode), + ); + + if let Some(stroke) = stroke.as_ref() { + let mut rect = Rectangle::new() + .set("x", fmt_f64(rx)) + .set("y", fmt_f64(ry)) + .set("width", fmt_f64(rw)) + .set("height", fmt_f64(rh)) + .set("fill", "none") + .set("stroke-width", fmt_f64(stroke.width.max(0.0))) + .set("stroke-linecap", svg_linecap(stroke.cap)) + .set("stroke-linejoin", svg_linejoin(stroke.join)); + let (hex, opacity) = svg_color_paint_values( + to_u8(stroke.color.r), + to_u8(stroke.color.g), + to_u8(stroke.color.b), + stroke.color.a, + ); + rect = rect.set("stroke", hex); + if let Some(opacity) = opacity { + rect = rect.set("stroke-opacity", opacity); + } + push_node_with_optional_blend( + &mut svg_doc, + &mut group_stack, + rect, + Some(blend_mode), + ); + } + continue; + } + crate::render::anyrender_renderer::FillPaint::Image { + image_hash, + data_base64, + alpha, + scale_mode, + image_transform, + } => { + let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); + pattern_count += 1; + let image_key = image_hash + .clone() + .unwrap_or_else(|| format!("inline:{data_base64}")); + let image_id = if let Some(existing) = image_defs.get(&image_key) { + existing.clone() } else { - rect = apply_fill_style_rect(rect, &style); + let new_id = format!("image{image_count}_{root_id_suffix}"); + image_count += 1; + image_defs.insert(image_key, new_id.clone()); + let image = Image::new() + .set("id", new_id.clone()) + .set("width", "1") + .set("height", "1") + .set("preserveAspectRatio", "none") + .set( + "xlink:href", + format!("data:image/png;base64,{data_base64}"), + ); + deferred_images.push(image); + new_id + }; + + let pattern = svg_image_pattern( + &pattern_id, + &image_id, + *scale_mode, + image_transform.as_ref(), + rw, + rh, + data_base64, + ); + defs = defs.add(pattern); + has_defs = true; + has_xlink_namespace = true; + + let mut rect = Rectangle::new() + .set("x", fmt_f64(rx)) + .set("y", fmt_f64(ry)) + .set("width", fmt_f64(rw)) + .set("height", fmt_f64(rh)) + .set("fill", format!("url(#{pattern_id})")); + rect = apply_corner_radii_rect(rect, &effective_corner_radii); + if *alpha < 1.0 { + rect = rect.set("fill-opacity", fmt_f64(*alpha)); } if let Some(stroke) = stroke.as_ref() { let (hex, opacity) = svg_color_paint_values( @@ -1118,44 +964,141 @@ pub fn run_cli() -> Result<(), String> { rect = rect.set("stroke-opacity", opacity); } } - if svg_mix_blend_mode(blend_mode).is_some() { - super::effects::push_group_with_blend_and_effects( - &mut svg_doc, - &mut group_stack, - &mut defs, - &mut has_defs, - &root_id_suffix, - &mut effect_count, - Group::new().add(rect), - blend_mode, - effects.as_slice(), - rx, - ry, - rw, - rh, - ); - } else { - super::effects::push_rect_with_effects( - &mut svg_doc, - &mut group_stack, - &mut defs, - &mut has_defs, - &root_id_suffix, - &mut effect_count, - &mut bg_blur_count, - rect, - stroke.as_ref(), - effects.as_slice(), - rx, - ry, - rw, - rh, - &effective_corner_radii, - &[], - ); + if let Some(mode) = svg_mix_blend_mode(blend_mode) { + rect = rect.set("style", format!("mix-blend-mode:{mode}")); } + super::effects::push_rect_with_effects( + &mut svg_doc, + &mut group_stack, + &mut defs, + &mut has_defs, + &root_id_suffix, + &mut effect_count, + &mut bg_blur_count, + rect, + None, + effects.as_slice(), + rx, + ry, + rw, + rh, + &effective_corner_radii, + &[], + ); continue; } + _ => {} + } + + if let Some(style) = svg_fill_style(fill) { + let mut rect = if frame_rect_uses_transform { + Rectangle::new() + .set("width", fmt_f64(rw)) + .set("height", fmt_f64(rh)) + .set( + "transform", + format!("translate({} {})", fmt_f64(rx), fmt_f64(ry)), + ) + } else { + Rectangle::new() + .set("x", fmt_f64(rx)) + .set("y", fmt_f64(ry)) + .set("width", fmt_f64(rw)) + .set("height", fmt_f64(rh)) + }; + rect = apply_corner_radii_rect(rect, &effective_corner_radii); + if let crate::render::anyrender_renderer::FillPaint::Solid(color) = fill { + let (hex, fill_opacity) = svg_color_paint_values( + to_u8(color.r), + to_u8(color.g), + to_u8(color.b), + color.a, + ); + rect = rect.set("fill", hex); + let opacity = fill_opacity; + if let Some(mode) = svg_mix_blend_mode(blend_mode) { + let mut group = + Group::new().set("style", format!("mix-blend-mode:{mode}")); + if let Some(opacity) = opacity { + group = group.set("opacity", opacity); + } + if let Some(stroke) = stroke.as_ref() { + let (hex, opacity) = svg_color_paint_values( + to_u8(stroke.color.r), + to_u8(stroke.color.g), + to_u8(stroke.color.b), + stroke.color.a, + ); + rect = rect + .set("stroke", hex) + .set("stroke-width", fmt_f64(stroke.width.max(0.0))) + .set("stroke-linecap", svg_linecap(stroke.cap)) + .set("stroke-linejoin", svg_linejoin(stroke.join)); + if let Some(opacity) = opacity { + rect = rect.set("stroke-opacity", opacity); + } + } + push_node(&mut svg_doc, &mut group_stack, group.add(rect)); + continue; + } else if let Some(opacity) = opacity { + rect = rect.set("opacity", opacity); + } + } else { + rect = apply_fill_style_rect(rect, &style); + } + if let Some(stroke) = stroke.as_ref() { + let (hex, opacity) = svg_color_paint_values( + to_u8(stroke.color.r), + to_u8(stroke.color.g), + to_u8(stroke.color.b), + stroke.color.a, + ); + rect = rect + .set("stroke", hex) + .set("stroke-width", fmt_f64(stroke.width.max(0.0))) + .set("stroke-linecap", svg_linecap(stroke.cap)) + .set("stroke-linejoin", svg_linejoin(stroke.join)); + if let Some(opacity) = opacity { + rect = rect.set("stroke-opacity", opacity); + } + } + if svg_mix_blend_mode(blend_mode).is_some() { + super::effects::push_group_with_blend_and_effects( + &mut svg_doc, + &mut group_stack, + &mut defs, + &mut has_defs, + &root_id_suffix, + &mut effect_count, + Group::new().add(rect), + blend_mode, + effects.as_slice(), + rx, + ry, + rw, + rh, + ); + } else { + super::effects::push_rect_with_effects( + &mut svg_doc, + &mut group_stack, + &mut defs, + &mut has_defs, + &root_id_suffix, + &mut effect_count, + &mut bg_blur_count, + rect, + stroke.as_ref(), + effects.as_slice(), + rx, + ry, + rw, + rh, + &effective_corner_radii, + &[], + ); + } + continue; } } let raw_frame_strokes = raw_frame_strokes_early; @@ -1163,7 +1106,10 @@ pub fn run_cli() -> Result<(), String> { let frame_stroke_width = frame_stroke_width_early; for (fill, fill_blend) in fills { match &fill { - crate::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientLinear { + transform, + stops, + } => { let gradient_id = format!("paint{gradient_count}_linear_{root_id_suffix}"); gradient_count += 1; @@ -1211,7 +1157,10 @@ pub fn run_cli() -> Result<(), String> { } continue; } - crate::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientRadial { + transform, + stops, + } => { let gradient_id = format!("paint{gradient_count}_radial_{root_id_suffix}"); gradient_count += 1; @@ -1259,7 +1208,7 @@ pub fn run_cli() -> Result<(), String> { } continue; } - crate::anyrender_renderer::FillPaint::GradientAngular { + crate::render::anyrender_renderer::FillPaint::GradientAngular { transform, stops, } => { @@ -1272,7 +1221,8 @@ pub fn run_cli() -> Result<(), String> { .set("y", fmt_f64(ry)) .set("width", fmt_f64(rw)) .set("height", fmt_f64(rh)); - defs = defs.add(ClipPath::new().set("id", clip_id.clone()).add(clip_rect)); + defs = + defs.add(ClipPath::new().set("id", clip_id.clone()).add(clip_rect)); has_defs = true; let center_x = rx + rw * 0.5; @@ -1315,7 +1265,14 @@ pub fn run_cli() -> Result<(), String> { .set("height", fmt_f64(rh)) .set( "data-figma-gradient-fill", - svg_angular_metadata_attr(rx, ry, rw, rh, None, stops.as_slice()), + svg_angular_metadata_attr( + rx, + ry, + rw, + rh, + None, + stops.as_slice(), + ), ); let angular_group = Group::new().add(group).add(metadata_rect); push_node_with_optional_blend( @@ -1326,7 +1283,7 @@ pub fn run_cli() -> Result<(), String> { ); continue; } - crate::anyrender_renderer::FillPaint::Image { + crate::render::anyrender_renderer::FillPaint::Image { image_hash, data_base64, alpha, @@ -1338,51 +1295,34 @@ pub fn run_cli() -> Result<(), String> { let image_key = image_hash .clone() .unwrap_or_else(|| format!("inline:{data_base64}")); - let image_id = if let Some(existing) = image_defs.get(&image_key) { - existing.clone() - } else { - let new_id = format!("image{image_count}_{root_id_suffix}"); - image_count += 1; - image_defs.insert(image_key, new_id.clone()); - let image = Image::new() - .set("id", new_id.clone()) - .set("width", "1") - .set("height", "1") - .set("preserveAspectRatio", "none") - .set( - "xlink:href", - format!("data:image/png;base64,{data_base64}"), - ); - deferred_images.push(image); - new_id - }; + let image_id = if let Some(existing) = image_defs.get(&image_key) { + existing.clone() + } else { + let new_id = format!("image{image_count}_{root_id_suffix}"); + image_count += 1; + image_defs.insert(image_key, new_id.clone()); + let image = Image::new() + .set("id", new_id.clone()) + .set("width", "1") + .set("height", "1") + .set("preserveAspectRatio", "none") + .set( + "xlink:href", + format!("data:image/png;base64,{data_base64}"), + ); + deferred_images.push(image); + new_id + }; - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") - .set("width", "1") - .set("height", "1"); - let mut image_use = Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = svg_image_fill_transform( + let pattern = svg_image_pattern( + &pattern_id, + &image_id, *scale_mode, image_transform.as_ref(), rw, rh, - ) { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), - ); - } - pattern = pattern.add(image_use); + data_base64, + ); defs = defs.add(pattern); has_defs = true; has_xlink_namespace = true; @@ -1424,11 +1364,11 @@ pub fn run_cli() -> Result<(), String> { } if let Some(style) = svg_fill_style(&fill) { let mut rect = Rectangle::new() - .set("x", fmt_f64(rx)) - .set("y", fmt_f64(ry)) - .set("width", fmt_f64(rw)) - .set("height", fmt_f64(rh)); - rect = apply_corner_radii_rect(rect, &effective_corner_radii); + .set("x", fmt_f64(rx)) + .set("y", fmt_f64(ry)) + .set("width", fmt_f64(rw)) + .set("height", fmt_f64(rh)); + rect = apply_corner_radii_rect(rect, &effective_corner_radii); rect = apply_fill_style_rect(rect, &style); if svg_mix_blend_mode(&fill_blend).is_some() { super::effects::push_group_with_blend_and_effects( @@ -1512,69 +1452,74 @@ pub fn run_cli() -> Result<(), String> { effects, rotation, text_outline, + authored, .. } => { let has_native_geometry = !fill_paths.is_empty() || !stroke_paths.is_empty(); - if !text_outline && !has_native_geometry { - if let Some(svg_base64) = svg_base64.as_ref() { - let mut image = Image::new() - .set("x", fmt_f64(x + tx)) - .set("y", fmt_f64(y + ty)) - .set("width", fmt_f64(width.max(0.0))) - .set("height", fmt_f64(height.max(0.0))) - .set("preserveAspectRatio", "none") - .set( - "xlink:href", - format!("data:image/svg+xml;base64,{svg_base64}"), - ); - if rotation.abs() > 1e-6 { - let angle = rotation.to_degrees(); - image = image.set( - "transform", - format!( - "rotate({} {} {})", - fmt_f64(angle), - fmt_f64(x + tx), - fmt_f64(y + ty) - ), - ); - } - has_xlink_namespace = true; - let group = Group::new().add(image); - super::effects::push_group_with_blend_and_effects( - &mut svg_doc, - &mut group_stack, - &mut defs, - &mut has_defs, - &root_id_suffix, - &mut effect_count, - group, - &blend, - effects.as_slice(), - x + tx, - y + ty, - width.max(0.0), - height.max(0.0), + if !text_outline + && !has_native_geometry + && let Some(svg_base64) = svg_base64.as_ref() + { + let mut image = Image::new() + .set("x", fmt_f64(x + tx)) + .set("y", fmt_f64(y + ty)) + .set("width", fmt_f64(width.max(0.0))) + .set("height", fmt_f64(height.max(0.0))) + .set("preserveAspectRatio", "none") + .set( + "xlink:href", + format!("data:image/svg+xml;base64,{svg_base64}"), + ); + if rotation.abs() > 1e-6 { + let angle = rotation.to_degrees(); + image = image.set( + "transform", + format!( + "rotate({} {} {})", + fmt_f64(angle), + fmt_f64(x + tx), + fmt_f64(y + ty) + ), ); - continue; } + has_xlink_namespace = true; + let group = Group::new().add(image); + super::effects::push_group_with_blend_and_effects( + &mut svg_doc, + &mut group_stack, + &mut defs, + &mut has_defs, + &root_id_suffix, + &mut effect_count, + group, + &blend, + effects.as_slice(), + x + tx, + y + ty, + width.max(0.0), + height.max(0.0), + ); + continue; } - let node = doc.get_node(node_id); - let figma_type = node.map(|n| n.figma_type.as_str()).unwrap_or(""); - let is_rectangle_node = figma_type == "RECTANGLE"; + let node = scene.node(node_id); + let kind = node.map(|n| &n.kind); + let is_rectangle_node = matches!(kind, Some(NodeKind::Rectangle)); + let is_ellipse_node = matches!(kind, Some(NodeKind::Ellipse)); + // A rectangle emits its node's own stroke paints (gradients + // included) as overlays; its primitive's gradient stroke + // would only move its geometry. + let stroke = + if is_rectangle_node && stroke.as_ref().is_some_and(|s| s.gradient.is_some()) { + None + } else { + stroke + }; + let is_line_node = matches!(kind, Some(NodeKind::Line)); if !text_outline && is_rectangle_node && svg_base64.is_none() { let rotation_degrees = rotation.to_degrees(); - let ResolvedRectGeometry { - rx, - ry, - rw, - rh, - .. - } = resolve_rect_geometry( - &doc, - node_id, + let ResolvedRectGeometry { rx, ry, rw, rh, .. } = resolve_rect_geometry( + node, &layout_boxes, - &source_bboxes, x, y, width, @@ -1585,7 +1530,7 @@ pub fn run_cli() -> Result<(), String> { &fill_paths, stroke.as_ref(), ); - let mut rect_corner_radii = frame_proto::CornerRadii::default(); + let mut rect_corner_radii = crate::document::proto::CornerRadii::default(); let mut rect = Rectangle::new() .set("x", fmt_f64(rx)) .set("y", fmt_f64(ry)) @@ -1603,62 +1548,42 @@ pub fn run_cli() -> Result<(), String> { ); } if let Some(node) = node { - let corner_radius = node - .raw - .get("rectangleCornerRadii") - .and_then(|v| v.as_array()) - .and_then(|arr| { - if arr.len() >= 4 { - let tl = arr[0].as_f64().unwrap_or(0.0).max(0.0); - let tr = arr[1].as_f64().unwrap_or(0.0).max(0.0); - let br = arr[2].as_f64().unwrap_or(0.0).max(0.0); - let bl = arr[3].as_f64().unwrap_or(0.0).max(0.0); - rect_corner_radii = frame_proto::CornerRadii { - top_left: tl, - top_right: tr, - bottom_right: br, - bottom_left: bl, - }; - if (tl - tr).abs() <= f64::EPSILON - && (tl - br).abs() <= f64::EPSILON - && (tl - bl).abs() <= f64::EPSILON - { - Some(tl) - } else { - None - } - } else { - None - } - }) - .or_else(|| node.raw.get("cornerRadius").and_then(|v| v.as_f64())); - if let Some(corner_radius) = corner_radius { - rect_corner_radii = frame_proto::CornerRadii::uniform(corner_radius); - if corner_radius > 0.0 { - rect = rect.set("rx", fmt_f64(corner_radius)); + let r = node.corner_radii; + if (r.top_left - r.top_right).abs() <= f64::EPSILON + && (r.top_left - r.bottom_right).abs() <= f64::EPSILON + && (r.top_left - r.bottom_left).abs() <= f64::EPSILON + { + rect_corner_radii = + crate::document::proto::CornerRadii::uniform(r.top_left); + if r.top_left > 0.0 { + rect = rect.set("rx", fmt_f64(r.top_left)); } + } else { + rect_corner_radii = r; } } let node_blend_mode = svg_mix_blend_mode(&blend); - let fill_blend_mode = fills.first().and_then(|(_, fill_blend)| { - svg_mix_blend_mode(fill_blend) - }); + let fill_blend_mode = fills + .first() + .and_then(|(_, fill_blend)| svg_mix_blend_mode(fill_blend)); let blend_mode = fill_blend_mode.or(node_blend_mode); let mut blend_group_opacity: Option = None; - let raw_strokes = node - .and_then(|n| source_stroke_data.get(&n.figma_id).map(|(p, _)| p.clone())) - .filter(|p| !p.is_empty()) - .or_else(|| node.map(|n| raw_stroke_paints(&n.raw))) - .unwrap_or_default(); + let raw_strokes = node.map(|n| stroke_paints(n, &scene)).unwrap_or_default(); let has_complex_raw_strokes = raw_strokes.len() > 1 || raw_strokes.iter().any(|paint| { - !matches!(paint, crate::anyrender_renderer::FillPaint::Solid(_)) + !matches!( + paint, + crate::render::anyrender_renderer::FillPaint::Solid(_) + ) }); let mut emit_rect = true; let mut fill_is_image = false; if let Some((fill, _)) = fills.first() { match fill { - crate::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientLinear { + transform, + stops, + } => { let gradient_id = format!("paint{gradient_count}_linear_{root_id_suffix}"); gradient_count += 1; @@ -1676,7 +1601,10 @@ pub fn run_cli() -> Result<(), String> { rect = rect.set("fill", format!("url(#{gradient_id})")); } } - crate::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientRadial { + transform, + stops, + } => { let gradient_id = format!("paint{gradient_count}_radial_{root_id_suffix}"); gradient_count += 1; @@ -1694,7 +1622,7 @@ pub fn run_cli() -> Result<(), String> { rect = rect.set("fill", format!("url(#{gradient_id})")); } } - crate::anyrender_renderer::FillPaint::GradientAngular { + crate::render::anyrender_renderer::FillPaint::GradientAngular { transform, stops, } => { @@ -1707,7 +1635,8 @@ pub fn run_cli() -> Result<(), String> { .set("y", fmt_f64(ry)) .set("width", fmt_f64(rw)) .set("height", fmt_f64(rh)); - defs = defs.add(ClipPath::new().set("id", clip_id.clone()).add(clip_rect)); + defs = defs + .add(ClipPath::new().set("id", clip_id.clone()).add(clip_rect)); has_defs = true; let center_x = rx + rw * 0.5; @@ -1720,7 +1649,11 @@ pub fn run_cli() -> Result<(), String> { .set("width", "2022.22") .set("height", "2022.22"); let mut div = Element::new("div"); - svg::Node::assign(&mut div, "xmlns", "http://www.w3.org/1999/xhtml"); + svg::Node::assign( + &mut div, + "xmlns", + "http://www.w3.org/1999/xhtml", + ); svg::Node::assign( &mut div, "style", @@ -1751,21 +1684,27 @@ pub fn run_cli() -> Result<(), String> { .set("height", fmt_f64(rh)) .set( "data-figma-gradient-fill", - svg_angular_metadata_attr(rx, ry, rw, rh, None, stops.as_slice()), + svg_angular_metadata_attr( + rx, + ry, + rw, + rh, + None, + stops.as_slice(), + ), ); push_node(&mut svg_doc, &mut group_stack, metadata_rect); emit_rect = stroke.is_some(); rect = rect.set("fill", "none"); } - crate::anyrender_renderer::FillPaint::Image { + crate::render::anyrender_renderer::FillPaint::Image { image_hash, data_base64, alpha, scale_mode, image_transform, } => { - let pattern_id = - format!("pattern{pattern_count}_{root_id_suffix}"); + let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); pattern_count += 1; let image_key = image_hash .clone() @@ -1789,29 +1728,15 @@ pub fn run_cli() -> Result<(), String> { new_id }; - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") - .set("width", "1") - .set("height", "1"); - let mut image_use = Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = - svg_image_fill_transform(*scale_mode, image_transform.as_ref(), rw, rh) - { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), - ); - } - pattern = pattern.add(image_use); + let pattern = svg_image_pattern( + &pattern_id, + &image_id, + *scale_mode, + image_transform.as_ref(), + rw, + rh, + data_base64, + ); defs = defs.add(pattern); has_defs = true; has_xlink_namespace = true; @@ -1822,7 +1747,9 @@ pub fn run_cli() -> Result<(), String> { fill_is_image = true; } _ => { - if let crate::anyrender_renderer::FillPaint::Solid(color) = fill { + if let crate::render::anyrender_renderer::FillPaint::Solid(color) = + fill + { let (hex, fill_opacity) = svg_color_paint_values( to_u8(color.r), to_u8(color.g), @@ -1847,15 +1774,7 @@ pub fn run_cli() -> Result<(), String> { let stroke_width = stroke .as_ref() .map(|s| s.width.max(0.0)) - .or_else(|| { - node.and_then(|n| source_stroke_data.get(&n.figma_id)) - .and_then(|(_, w)| *w) - .map(|w| w.max(0.0)) - }) - .or_else(|| { - node.and_then(|n| n.raw.get("strokeWeight").and_then(|v| v.as_f64())) - .map(|w| w.max(0.0)) - }) + .or_else(|| node.map(|n| n.stroke.weight.max(0.0))) .unwrap_or(0.0); let mut stroke_overlays: Vec = Vec::new(); if has_complex_raw_strokes && stroke_width > 0.0 { @@ -1879,10 +1798,11 @@ pub fn run_cli() -> Result<(), String> { ); } if !rect_corner_radii.is_zero() { - stroke_rect = apply_corner_radii_rect(stroke_rect, &rect_corner_radii); + stroke_rect = + apply_corner_radii_rect(stroke_rect, &rect_corner_radii); } match paint { - crate::anyrender_renderer::FillPaint::Solid(color) => { + crate::render::anyrender_renderer::FillPaint::Solid(color) => { let (hex, opacity) = svg_color_paint_values( to_u8(color.r), to_u8(color.g), @@ -1894,7 +1814,7 @@ pub fn run_cli() -> Result<(), String> { stroke_rect = stroke_rect.set("stroke-opacity", opacity); } } - crate::anyrender_renderer::FillPaint::GradientLinear { + crate::render::anyrender_renderer::FillPaint::GradientLinear { transform, stops, } => { @@ -1912,13 +1832,13 @@ pub fn run_cli() -> Result<(), String> { ) { deferred_gradients.push(Box::new(gradient)); has_defs = true; - stroke_rect = - stroke_rect.set("stroke", format!("url(#{gradient_id})")); + stroke_rect = stroke_rect + .set("stroke", format!("url(#{gradient_id})")); } else { continue; } } - crate::anyrender_renderer::FillPaint::GradientRadial { + crate::render::anyrender_renderer::FillPaint::GradientRadial { transform, stops, } => { @@ -1936,8 +1856,8 @@ pub fn run_cli() -> Result<(), String> { ) { deferred_gradients.push(Box::new(gradient)); has_defs = true; - stroke_rect = - stroke_rect.set("stroke", format!("url(#{gradient_id})")); + stroke_rect = stroke_rect + .set("stroke", format!("url(#{gradient_id})")); } else { continue; } @@ -1964,7 +1884,10 @@ pub fn run_cli() -> Result<(), String> { } if emit_rect { if let Some(mode) = blend_mode { - if fill_is_image && stroke_overlays.is_empty() && blend_group_opacity.is_none() { + if fill_is_image + && stroke_overlays.is_empty() + && blend_group_opacity.is_none() + { rect = rect.set("style", format!("mix-blend-mode:{mode}")); push_node(&mut svg_doc, &mut group_stack, rect); } else { @@ -2005,10 +1928,8 @@ pub fn run_cli() -> Result<(), String> { // Use circle primitives only for full ellipses. Arc/slice ellipses are // exported as paths and must remain paths. if !text_outline - && figma_type == "ELLIPSE" - && fill_paths - .iter() - .all(|d| svg_path_is_full_ellipse_like(d)) + && is_ellipse_node + && fill_paths.iter().all(|d| svg_path_is_full_ellipse_like(d)) && stroke_paths .iter() .all(|d| svg_path_is_full_ellipse_like(d)) @@ -2018,11 +1939,11 @@ pub fn run_cli() -> Result<(), String> { let mut cx_box_w = width.max(0.0); let mut cy_box_h = height.max(0.0); if let Some(node) = node { - if let Some((bx, by, bw, bh)) = source_bboxes.get(&node.figma_id) { - cx_box_x = *bx; - cy_box_y = *by; - cx_box_w = (*bw).max(0.0); - cy_box_h = (*bh).max(0.0); + if let Some((bx, by, bw, bh)) = source_bounds(node) { + cx_box_x = bx; + cy_box_y = by; + cx_box_w = bw.max(0.0); + cy_box_h = bh.max(0.0); } else if let Some(b) = layout_boxes.get(&node_id).copied() { cx_box_x = b.x; cy_box_y = b.y; @@ -2032,14 +1953,14 @@ pub fn run_cli() -> Result<(), String> { } let mut ew = width.max(0.0); let mut eh = height.max(0.0); - if let Some(d) = fill_paths.first() { - if let Some((min_x, min_y, max_x, max_y)) = svg_path_bounds(d, 0.0, 0.0) { - let pw = (max_x - min_x).max(0.0); - let ph = (max_y - min_y).max(0.0); - if pw > 0.0 && ph > 0.0 { - ew = pw; - eh = ph; - } + if let Some(d) = fill_paths.first() + && let Some((min_x, min_y, max_x, max_y)) = svg_path_bounds(d, 0.0, 0.0) + { + let pw = (max_x - min_x).max(0.0); + let ph = (max_y - min_y).max(0.0); + if pw > 0.0 && ph > 0.0 { + ew = pw; + eh = ph; } } let cx = cx_box_x + tx + (cx_box_w * 0.5); @@ -2051,54 +1972,42 @@ pub fn run_cli() -> Result<(), String> { let mut circle_cy = cy; let mut circle_transform: Option = None; if let Some(node) = node { - if let Some(rt) = node.raw.get("relativeTransform").and_then(|v| v.as_array()) { - if rt.len() >= 2 { - let row0 = rt[0].as_array(); - let row1 = rt[1].as_array(); - if let (Some(row0), Some(row1)) = (row0, row1) { - if row0.len() >= 3 && row1.len() >= 3 { - let a = row0[0].as_f64().unwrap_or(1.0); - let c = row0[1].as_f64().unwrap_or(0.0); - let b = row1[0].as_f64().unwrap_or(0.0); - let d = row1[1].as_f64().unwrap_or(1.0); - let det = a * d - b * c; - let is_rotation = det > 0.0 - && (a - d).abs() <= 1e-4 - && (c + b).abs() <= 1e-4; - if det < -1e-6 { - // The circle sits at local origin (r, r); use the - // absoluteBoundingBox center (cx, cy) to derive the - // correct world-space translation rather than the - // local relativeTransform tx/ty, which is relative to - // the immediate parent and ignores the ancestor chain. - // SVG matrix(a,b,c,d,e,f): x'=a*x+c*y+e, y'=b*x+d*y+f - // For circle at (r,r) → (cx,cy): e = cx-(a+c)*r - let e = cx - (a + c) * r; - let f = cy - (b + d) * r; - circle_cx = r; - circle_cy = r; - circle_transform = Some(format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - )); - } else if is_rotation { - let angle = b.atan2(a).to_degrees(); - if angle.abs() > 0.0001 { - circle_transform = Some(format!( - "rotate({} {} {})", - fmt_f64(angle), - fmt_f64(cx), - fmt_f64(cy) - )); - } - } - } - } + // relativeTransform rows [[a c e] [b d f]] in SVG matrix naming. + let t = node.transform; + let (a, c, b, d) = (t.a, t.b, t.c, t.d); + let det = a * d - b * c; + let is_rotation = + det > 0.0 && (a - d).abs() <= 1e-4 && (c + b).abs() <= 1e-4; + if det < -1e-6 { + // The circle sits at local origin (r, r); use the + // absoluteBoundingBox center (cx, cy) to derive the + // correct world-space translation rather than the + // local relativeTransform tx/ty, which is relative to + // the immediate parent and ignores the ancestor chain. + // SVG matrix(a,b,c,d,e,f): x'=a*x+c*y+e, y'=b*x+d*y+f + // For circle at (r,r) → (cx,cy): e = cx-(a+c)*r + let e = cx - (a + c) * r; + let f = cy - (b + d) * r; + circle_cx = r; + circle_cy = r; + circle_transform = Some(format!( + "matrix({} {} {} {} {} {})", + fmt_f64(a), + fmt_f64(b), + fmt_f64(c), + fmt_f64(d), + fmt_f64(e), + fmt_f64(f) + )); + } else if is_rotation { + let angle = b.atan2(a).to_degrees(); + if angle.abs() > 0.0001 { + circle_transform = Some(format!( + "rotate({} {} {})", + fmt_f64(angle), + fmt_f64(cx), + fmt_f64(cy) + )); } } } @@ -2122,20 +2031,21 @@ pub fn run_cli() -> Result<(), String> { ); let mut group = Group::new(); if fills.is_empty() { - let mut base = new_circle_or_ellipse(circle_cx, circle_cy, rx, ry) - .set("fill", "none"); + let mut base = + new_circle_or_ellipse(circle_cx, circle_cy, rx, ry).set("fill", "none"); if let Some(transform) = &circle_transform { base = base.set("transform", transform.clone()); } group = group.add(base); } else { for (fill, fill_blend) in &fills { - let mut fill_circle = new_circle_or_ellipse(circle_cx, circle_cy, rx, ry); + let mut fill_circle = + new_circle_or_ellipse(circle_cx, circle_cy, rx, ry); if let Some(transform) = &circle_transform { fill_circle = fill_circle.set("transform", transform.clone()); } match fill { - crate::anyrender_renderer::FillPaint::GradientLinear { + crate::render::anyrender_renderer::FillPaint::GradientLinear { transform, stops, } => { @@ -2159,7 +2069,7 @@ pub fn run_cli() -> Result<(), String> { fill_circle = fill_circle.set("fill", "none"); } } - crate::anyrender_renderer::FillPaint::GradientRadial { + crate::render::anyrender_renderer::FillPaint::GradientRadial { transform, stops, } => { @@ -2183,25 +2093,30 @@ pub fn run_cli() -> Result<(), String> { fill_circle = fill_circle.set("fill", "none"); } } - crate::anyrender_renderer::FillPaint::GradientAngular { + crate::render::anyrender_renderer::FillPaint::GradientAngular { transform, stops, } => { + // The conic below is placed as Figma exports it: by the + // transform from the node's box to the gradient's space, + // the inverse of frame's. + let transform = transform.as_ref().and_then(|t| t.inverse()); let gradient_id = format!("paint{gradient_count}_angular_{root_id_suffix}"); gradient_count += 1; let clip_id = format!("{gradient_id}_clip_path"); - let mut clip_circle = new_circle_or_ellipse(circle_cx, circle_cy, rx, ry); + let mut clip_circle = + new_circle_or_ellipse(circle_cx, circle_cy, rx, ry); if let Some(transform) = &circle_transform { - clip_circle = clip_circle.set("transform", transform.clone()); + clip_circle = + clip_circle.set("transform", transform.clone()); } - defs = defs.add(ClipPath::new().set("id", clip_id.clone()).add(clip_circle)); + defs = defs.add( + ClipPath::new().set("id", clip_id.clone()).add(clip_circle), + ); has_defs = true; - let stroke_weight = node - .and_then(|n| n.raw.get("strokeWeight")) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); + let stroke_weight = node.map_or(0.0, |n| n.stroke.weight); let (a, b, c, d) = if let Some(t) = transform.as_ref() { let rw = 2.0 * r; ( @@ -2227,11 +2142,18 @@ pub fn run_cli() -> Result<(), String> { .set("width", fo_size.clone()) .set("height", fo_size); let mut div = Element::new("div"); - svg::Node::assign(&mut div, "xmlns", "http://www.w3.org/1999/xhtml"); + svg::Node::assign( + &mut div, + "xmlns", + "http://www.w3.org/1999/xhtml", + ); svg::Node::assign( &mut div, "style", - svg_angular_conic_style(transform.as_ref(), stops.as_slice()), + svg_angular_conic_style( + transform.as_ref(), + stops.as_slice(), + ), ); foreign = foreign.add(div); let inner = Group::new() @@ -2254,39 +2176,41 @@ pub fn run_cli() -> Result<(), String> { .set("data-figma-skip-parse", "true") .add(inner), ); - group = group.add( - { - let metadata_val = svg_angular_metadata_attr( - cx - r, - cy - r, - r * 2.0, - r * 2.0, - transform.as_ref().map(|t| (t, cx, cy)), - stops.as_slice(), - ); - let mut circle = new_circle_or_ellipse(circle_cx, circle_cy, rx, ry) + group = group.add({ + let metadata_val = svg_angular_metadata_attr( + cx - r, + cy - r, + r * 2.0, + r * 2.0, + transform.as_ref().map(|t| (t, cx, cy)), + stops.as_slice(), + ); + let mut circle = + new_circle_or_ellipse(circle_cx, circle_cy, rx, ry) .set("data-figma-gradient-fill", metadata_val); - if let Some(transform) = &circle_transform { - circle = circle.set("transform", transform.clone()); - } - circle - }, - ); + if let Some(transform) = &circle_transform { + circle = circle.set("transform", transform.clone()); + } + circle + }); continue; } - crate::anyrender_renderer::FillPaint::Image { + crate::render::anyrender_renderer::FillPaint::Image { image_hash, data_base64, alpha, scale_mode, image_transform, } => { - let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); + let pattern_id = + format!("pattern{pattern_count}_{root_id_suffix}"); pattern_count += 1; let image_key = image_hash .clone() .unwrap_or_else(|| format!("inline:{data_base64}")); - let image_id = if let Some(existing) = image_defs.get(&image_key) { + let image_id = if let Some(existing) = + image_defs.get(&image_key) + { existing.clone() } else { let new_id = format!("image{image_count}_{root_id_suffix}"); @@ -2305,39 +2229,23 @@ pub fn run_cli() -> Result<(), String> { new_id }; - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") - .set("width", "1") - .set("height", "1"); - let mut image_use = - Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = svg_image_fill_transform( + let pattern = svg_image_pattern( + &pattern_id, + &image_id, *scale_mode, image_transform.as_ref(), r * 2.0, r * 2.0, - ) { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), - ); - } - pattern = pattern.add(image_use); + data_base64, + ); defs = defs.add(pattern); has_defs = true; has_xlink_namespace = true; - fill_circle = fill_circle.set("fill", format!("url(#{pattern_id})")); + fill_circle = + fill_circle.set("fill", format!("url(#{pattern_id})")); if *alpha < 1.0 { - fill_circle = fill_circle.set("fill-opacity", fmt_f64(*alpha)); + fill_circle = + fill_circle.set("fill-opacity", fmt_f64(*alpha)); } } _ => { @@ -2349,17 +2257,20 @@ pub fn run_cli() -> Result<(), String> { } } if let Some(mode) = svg_mix_blend_mode(fill_blend) { - fill_circle = fill_circle.set("style", format!("mix-blend-mode:{mode}")); + fill_circle = + fill_circle.set("style", format!("mix-blend-mode:{mode}")); } group = group.add(fill_circle); } } if let Some(stroke) = stroke.as_ref() { - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, + let (hex, opacity) = svg_stroke_paint( + stroke, + (circle_cx - rx, circle_cy - ry, rx * 2.0, ry * 2.0), + &mut gradient_count, + &root_id_suffix, + &mut deferred_gradients, + &mut has_defs, ); let mut stroke_circle = new_circle_or_ellipse(circle_cx, circle_cy, rx, ry) .set("fill", "none") @@ -2392,15 +2303,15 @@ pub fn run_cli() -> Result<(), String> { ); continue; } - if !text_outline && figma_type == "LINE" { + if !text_outline && is_line_node { let mut lx = x; let mut ly = y; let mut lw = width; if let Some(node) = node { - if let Some((bx, by, bw, _bh)) = source_bboxes.get(&node.figma_id) { - lx = *bx; - ly = *by; - lw = *bw; + if let Some((bx, by, bw, _bh)) = source_bounds(node) { + lx = bx; + ly = by; + lw = bw; } else if let Some(b) = layout_boxes.get(&node_id).copied() { lx = b.x; ly = b.y; @@ -2419,11 +2330,13 @@ pub fn run_cli() -> Result<(), String> { .set("x2", fmt_f64(x2)) .set("y2", fmt_f64(y_mid)); if let Some(stroke) = stroke.as_ref() { - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, + let (hex, opacity) = svg_stroke_paint( + stroke, + (x1, y_mid, x2 - x1, 0.0), + &mut gradient_count, + &root_id_suffix, + &mut deferred_gradients, + &mut has_defs, ); line = line .set("stroke", hex) @@ -2432,7 +2345,8 @@ pub fn run_cli() -> Result<(), String> { line = line.set("stroke-opacity", opacity); } if !stroke.dash_pattern.is_empty() { - line = line.set("stroke-dasharray", svg_dasharray(&stroke.dash_pattern)); + line = + line.set("stroke-dasharray", svg_dasharray(&stroke.dash_pattern)); } if stroke.dash_offset.abs() > f64::EPSILON { line = line.set("stroke-dashoffset", fmt_f64(stroke.dash_offset)); @@ -2477,102 +2391,110 @@ pub fn run_cli() -> Result<(), String> { let mut px = x + tx; let mut py = y + ty; let is_nonfull_ellipse = !text_outline - && figma_type == "ELLIPSE" + && is_ellipse_node && (!fill_paths.iter().all(|d| svg_path_is_full_ellipse_like(d)) - || !stroke_paths.iter().all(|d| svg_path_is_full_ellipse_like(d))); + || !stroke_paths + .iter() + .all(|d| svg_path_is_full_ellipse_like(d))); if is_nonfull_ellipse { let ellipse_inset = 0.4; px += ellipse_inset; py += ellipse_inset; if let Some(node) = node { - if let Some((bx, by, _bw, _bh)) = source_bboxes.get(&node.figma_id) { - px = *bx + tx + ellipse_inset; - py = *by + ty + ellipse_inset; + if let Some((bx, by, _bw, _bh)) = source_bounds(node) { + px = bx + tx + ellipse_inset; + py = by + ty + ellipse_inset; } else if let Some(b) = layout_boxes.get(&node_id).copied() { px = b.x + tx + ellipse_inset; py = b.y + ty + ellipse_inset; } } - } else if !text_outline { - if let Some(node) = node { - if let Some((bx, by, bw, bh)) = source_bboxes.get(&node.figma_id) { - let target = (*bx + tx, *by + ty, (*bw).max(0.0), (*bh).max(0.0)); - let source_paths = if fill_paths.is_empty() { - &stroke_paths - } else { - &fill_paths - }; - if let Some((raw_min_x, raw_min_y, _, _)) = union_path_bounds(source_paths, 0.0, 0.0) - { - // Some parity exports emit absolute path data; infer translation - // from the source bbox so we don't double-translate. - let candidate_px = target.0 - raw_min_x; - let candidate_py = target.1 - raw_min_y; - if let (Some(default_bounds), Some(candidate_bounds)) = ( - union_path_bounds(source_paths, px, py), - union_path_bounds(source_paths, candidate_px, candidate_py), - ) { - let default_err = bbox_alignment_error(default_bounds, target); - let candidate_err = bbox_alignment_error(candidate_bounds, target); - if candidate_err + 1e-4 < default_err { - px = candidate_px; - py = candidate_py; - } + } else if !text_outline + && let Some(node) = node + && let Some((bx, by, bw, bh)) = source_bounds(node) + { + let target = (bx + tx, by + ty, bw.max(0.0), bh.max(0.0)); + let source_paths = if fill_paths.is_empty() { + &stroke_paths + } else { + &fill_paths + }; + if let Some((raw_min_x, raw_min_y, _, _)) = + union_path_bounds(source_paths, 0.0, 0.0) + { + // Some parity exports emit absolute path data; infer translation + // from the source bbox so we don't double-translate. + let candidate_px = target.0 - raw_min_x; + let candidate_py = target.1 - raw_min_y; + if let (Some(default_bounds), Some(candidate_bounds)) = ( + union_path_bounds(source_paths, px, py), + union_path_bounds(source_paths, candidate_px, candidate_py), + ) { + let default_err = bbox_alignment_error(default_bounds, target); + let candidate_err = bbox_alignment_error(candidate_bounds, target); + if candidate_err + 1e-4 < default_err { + px = candidate_px; + py = candidate_py; } } } - } } // Apply 2×2 rotation to path geometry for Vector/Shape nodes with non-trivial rotation. // Figma convention: path data is in local coords; render by applying the 2×2 rotation to // the path coordinates and translating so the rotated local center aligns with the // absoluteBoundingBox center in screen space. - let (fill_paths, stroke_paths) = if !text_outline - && rotation.abs() > 1e-6 - && has_native_geometry - { - // Resolve the absolute bounding box center for rotation. - // Prefer source_bboxes (Figma absoluteBoundingBox) when available, - // fall back to the layout box center. - let bbox_center = node - .and_then(|n| { - source_bboxes - .get(&n.figma_id) - .map(|(bx, by, bw, bh)| (*bx + tx + *bw / 2.0, *by + ty + *bh / 2.0)) - }) - .or_else(|| { - Some((x + tx + width / 2.0, y + ty + height / 2.0)) - }); - if let Some((abs_cx, abs_cy)) = bbox_center { - let cos_r = rotation.cos(); - let sin_r = rotation.sin(); - // chain_sx/chain_sy: translate such that rotated local center → abs center - let chain_sx = - abs_cx - cos_r * (width / 2.0) + sin_r * (height / 2.0); - let chain_sy = - abs_cy - sin_r * (width / 2.0) - cos_r * (height / 2.0); - // kurbo Affine::new([a,b,c,d,e,f]) is column-major: |a c e| / |b d f| - // Figma 2×2: x'=cos_r*lx - sin_r*ly, y'=sin_r*lx + cos_r*ly - let rot = - kurbo::Affine::new([cos_r, sin_r, -sin_r, cos_r, 0.0, 0.0]); - let apply = |paths: Vec| -> Vec { - paths - .into_iter() - .filter_map(|d| { - let bp = kurbo::BezPath::from_svg(&d).ok()?; - Some((rot * bp).to_svg()) - }) - .collect() - }; - px = chain_sx; - py = chain_sy; - (apply(fill_paths), apply(stroke_paths)) + let (fill_paths, stroke_paths) = + if !text_outline && rotation.abs() > 1e-6 && has_native_geometry { + // Resolve the absolute bounding box center for rotation. + // Prefer the source bounds (Figma absoluteBoundingBox) when available, + // fall back to the layout box center. + let bbox_center = node + .and_then(source_bounds) + .map(|(bx, by, bw, bh)| (bx + tx + bw / 2.0, by + ty + bh / 2.0)) + .or_else(|| Some((x + tx + width / 2.0, y + ty + height / 2.0))); + if let Some((abs_cx, abs_cy)) = bbox_center { + let cos_r = rotation.cos(); + let sin_r = rotation.sin(); + // chain_sx/chain_sy: translate such that rotated local center → abs center + let chain_sx = abs_cx - cos_r * (width / 2.0) + sin_r * (height / 2.0); + let chain_sy = abs_cy - sin_r * (width / 2.0) - cos_r * (height / 2.0); + // kurbo Affine::new([a,b,c,d,e,f]) is column-major: |a c e| / |b d f| + // Figma 2×2: x'=cos_r*lx - sin_r*ly, y'=sin_r*lx + cos_r*ly + let rot = kurbo::Affine::new([cos_r, sin_r, -sin_r, cos_r, 0.0, 0.0]); + let apply = |paths: Vec| -> Vec { + paths + .into_iter() + .filter_map(|d| { + let bp = kurbo::BezPath::from_svg(&d).ok()?; + Some((rot * bp).to_svg()) + }) + .collect() + }; + px = chain_sx; + py = chain_sy; + (apply(fill_paths), apply(stroke_paths)) + } else { + (fill_paths, stroke_paths) + } } else { (fill_paths, stroke_paths) - } - } else { - (fill_paths, stroke_paths) - }; + }; + // An authored stroke without stroke geometry strokes the whole + // fill geometry, but open subpaths are lines: only the closed + // ones are filled. + let (fill_paths, stroked_fill_paths) = + if authored && stroke.is_some() && stroke_paths.is_empty() { + let closed: Vec = fill_paths + .iter() + .filter_map(|d| { + crate::render::anyrender_renderer::subpaths::closed_subpaths_svg(d) + }) + .filter(|d| !d.is_empty()) + .collect(); + (closed, Some(fill_paths)) + } else { + (fill_paths, None) + }; for d in &fill_paths { if let Some((min_x, min_y, max_x, max_y)) = svg_path_bounds(d, px, py) { accumulate_bounds_if_active( @@ -2608,16 +2530,24 @@ pub fn run_cli() -> Result<(), String> { for d in &fill_paths { if let Some((min_x, min_y, max_x, max_y)) = svg_path_bounds(d, px, py) { fill_bounds = match fill_bounds { - Some((bx0, by0, bx1, by1)) => { - Some((bx0.min(min_x), by0.min(min_y), bx1.max(max_x), by1.max(max_y))) - } + Some((bx0, by0, bx1, by1)) => Some(( + bx0.min(min_x), + by0.min(min_y), + bx1.max(max_x), + by1.max(max_y), + )), None => Some((min_x, min_y, max_x, max_y)), }; } } let (fill_x, fill_y, fill_w, fill_h) = if let Some((min_x, min_y, max_x, max_y)) = fill_bounds { - (min_x, min_y, (max_x - min_x).max(0.0), (max_y - min_y).max(0.0)) + ( + min_x, + min_y, + (max_x - min_x).max(0.0), + (max_y - min_y).max(0.0), + ) } else { (px, py, width.max(0.0), height.max(0.0)) }; @@ -2632,7 +2562,7 @@ pub fn run_cli() -> Result<(), String> { let mut fill_ref: Option = None; let mut fill_path_opacity: Option = None; match fill { - crate::anyrender_renderer::FillPaint::Solid(color) => { + crate::render::anyrender_renderer::FillPaint::Solid(color) => { let (hex, opacity) = svg_color_paint_values( to_u8(color.r), to_u8(color.g), @@ -2646,7 +2576,10 @@ pub fn run_cli() -> Result<(), String> { fill_path_opacity = opacity; } } - crate::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientLinear { + transform, + stops, + } => { let gradient_id = format!("paint{gradient_count}_linear_{root_id_suffix}"); gradient_count += 1; @@ -2664,7 +2597,10 @@ pub fn run_cli() -> Result<(), String> { fill_ref = Some(format!("url(#{gradient_id})")); } } - crate::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientRadial { + transform, + stops, + } => { let gradient_id = format!("paint{gradient_count}_radial_{root_id_suffix}"); gradient_count += 1; @@ -2682,8 +2618,10 @@ pub fn run_cli() -> Result<(), String> { fill_ref = Some(format!("url(#{gradient_id})")); } } - crate::anyrender_renderer::FillPaint::GradientAngular { .. } => {} - crate::anyrender_renderer::FillPaint::Image { + crate::render::anyrender_renderer::FillPaint::GradientAngular { + .. + } => {} + crate::render::anyrender_renderer::FillPaint::Image { image_hash, data_base64, alpha, @@ -2714,32 +2652,15 @@ pub fn run_cli() -> Result<(), String> { new_id }; - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") - .set("width", "1") - .set("height", "1"); - let mut image_use = Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = svg_image_fill_transform( + let pattern = svg_image_pattern( + &pattern_id, + &image_id, *scale_mode, image_transform.as_ref(), fill_w, fill_h, - ) { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), - ); - } - pattern = pattern.add(image_use); + data_base64, + ); defs = defs.add(pattern); has_defs = true; has_xlink_namespace = true; @@ -2768,302 +2689,289 @@ pub fn run_cli() -> Result<(), String> { path_group = path_group.set("opacity", opacity); } } else if is_nonfull_ellipse { - let mut fill_stack_opacity: Option = None; - for (fill_idx, (fill, fill_blend)) in fills.iter().enumerate() { - let mut fill_ref: Option = None; - let mut fill_path_opacity: Option = None; - match fill { - crate::anyrender_renderer::FillPaint::Solid(color) => { - let (hex, opacity) = svg_color_paint_values( - to_u8(color.r), - to_u8(color.g), - to_u8(color.b), - color.a, - ); - fill_ref = Some(hex); - if fills.len() > 1 && fill_idx == 0 { - fill_stack_opacity = opacity; - } else { - fill_path_opacity = opacity; - } + let mut fill_stack_opacity: Option = None; + for (fill_idx, (fill, fill_blend)) in fills.iter().enumerate() { + let mut fill_ref: Option = None; + let mut fill_path_opacity: Option = None; + match fill { + crate::render::anyrender_renderer::FillPaint::Solid(color) => { + let (hex, opacity) = svg_color_paint_values( + to_u8(color.r), + to_u8(color.g), + to_u8(color.b), + color.a, + ); + fill_ref = Some(hex); + if fills.len() > 1 && fill_idx == 0 { + fill_stack_opacity = opacity; + } else { + fill_path_opacity = opacity; } - crate::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { - let gradient_id = - format!("paint{gradient_count}_linear_{root_id_suffix}"); - gradient_count += 1; - if let Some(gradient) = svg_linear_gradient_def( - &gradient_id, - fill_x, - fill_y, - fill_w, - fill_h, - transform.as_ref(), - stops.as_slice(), - ) { - deferred_gradients.push(Box::new(gradient)); - has_defs = true; - fill_ref = Some(format!("url(#{gradient_id})")); - } + } + crate::render::anyrender_renderer::FillPaint::GradientLinear { + transform, + stops, + } => { + let gradient_id = + format!("paint{gradient_count}_linear_{root_id_suffix}"); + gradient_count += 1; + if let Some(gradient) = svg_linear_gradient_def( + &gradient_id, + fill_x, + fill_y, + fill_w, + fill_h, + transform.as_ref(), + stops.as_slice(), + ) { + deferred_gradients.push(Box::new(gradient)); + has_defs = true; + fill_ref = Some(format!("url(#{gradient_id})")); } - crate::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { - let gradient_id = - format!("paint{gradient_count}_radial_{root_id_suffix}"); - gradient_count += 1; - if let Some(gradient) = svg_radial_gradient_def( - &gradient_id, - fill_x, - fill_y, - fill_w, - fill_h, - transform.as_ref(), - stops.as_slice(), - ) { - deferred_gradients.push(Box::new(gradient)); - has_defs = true; - fill_ref = Some(format!("url(#{gradient_id})")); - } + } + crate::render::anyrender_renderer::FillPaint::GradientRadial { + transform, + stops, + } => { + let gradient_id = + format!("paint{gradient_count}_radial_{root_id_suffix}"); + gradient_count += 1; + if let Some(gradient) = svg_radial_gradient_def( + &gradient_id, + fill_x, + fill_y, + fill_w, + fill_h, + transform.as_ref(), + stops.as_slice(), + ) { + deferred_gradients.push(Box::new(gradient)); + has_defs = true; + fill_ref = Some(format!("url(#{gradient_id})")); } - crate::anyrender_renderer::FillPaint::GradientAngular { .. } => {} - crate::anyrender_renderer::FillPaint::Image { - image_hash, - data_base64, - alpha, - scale_mode, - image_transform, - } => { - let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); - pattern_count += 1; - let image_key = image_hash - .clone() - .unwrap_or_else(|| format!("inline:{data_base64}")); - let image_id = if let Some(existing) = image_defs.get(&image_key) { - existing.clone() - } else { - let new_id = format!("image{image_count}_{root_id_suffix}"); - image_count += 1; - image_defs.insert(image_key, new_id.clone()); - let image = Image::new() - .set("id", new_id.clone()) - .set("width", "1") - .set("height", "1") - .set("preserveAspectRatio", "none") - .set( - "xlink:href", - format!("data:image/png;base64,{data_base64}"), - ); - deferred_images.push(image); - new_id - }; - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") + } + crate::render::anyrender_renderer::FillPaint::GradientAngular { + .. + } => {} + crate::render::anyrender_renderer::FillPaint::Image { + image_hash, + data_base64, + alpha, + scale_mode, + image_transform, + } => { + let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); + pattern_count += 1; + let image_key = image_hash + .clone() + .unwrap_or_else(|| format!("inline:{data_base64}")); + let image_id = if let Some(existing) = image_defs.get(&image_key) { + existing.clone() + } else { + let new_id = format!("image{image_count}_{root_id_suffix}"); + image_count += 1; + image_defs.insert(image_key, new_id.clone()); + let image = Image::new() + .set("id", new_id.clone()) .set("width", "1") - .set("height", "1"); - let mut image_use = Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = svg_image_fill_transform( - *scale_mode, - image_transform.as_ref(), - fill_w, - fill_h, - ) { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), + .set("height", "1") + .set("preserveAspectRatio", "none") + .set( + "xlink:href", + format!("data:image/png;base64,{data_base64}"), ); - } - pattern = pattern.add(image_use); - defs = defs.add(pattern); - has_defs = true; - has_xlink_namespace = true; - fill_ref = Some(format!("url(#{pattern_id})")); - if *alpha < 1.0 { - fill_path_opacity = Some(fmt_f64(*alpha)); - } + deferred_images.push(image); + new_id + }; + let pattern = svg_image_pattern( + &pattern_id, + &image_id, + *scale_mode, + image_transform.as_ref(), + fill_w, + fill_h, + data_base64, + ); + defs = defs.add(pattern); + has_defs = true; + has_xlink_namespace = true; + fill_ref = Some(format!("url(#{pattern_id})")); + if *alpha < 1.0 { + fill_path_opacity = Some(fmt_f64(*alpha)); } } - let Some(fill_ref) = fill_ref else { - continue; - }; - - for d in &fill_paths { - let mut path = SvgPath::new() - .set("d", d.as_str()) - .set("transform", transform.clone()) - .set("fill", fill_ref.clone()); - if let Some(opacity) = &fill_path_opacity { - path = path.set("fill-opacity", opacity.clone()); - } - if let Some(mode) = svg_mix_blend_mode(fill_blend) { - path = path.set("style", format!("mix-blend-mode:{mode}")); - } - path_group = path_group.add(path); + } + let Some(fill_ref) = fill_ref else { + continue; + }; + + for d in &fill_paths { + let mut path = SvgPath::new() + .set("d", d.as_str()) + .set("transform", transform.clone()) + .set("fill", fill_ref.clone()); + if let Some(opacity) = &fill_path_opacity { + path = path.set("fill-opacity", opacity.clone()); } + if let Some(mode) = svg_mix_blend_mode(fill_blend) { + path = path.set("style", format!("mix-blend-mode:{mode}")); + } + path_group = path_group.add(path); } - if let Some(opacity) = fill_stack_opacity { - path_group = path_group.set("opacity", opacity); - } - } else { - for (fill, fill_blend) in fills.iter() { - let mut fill_ref: Option = None; - let mut fill_path_opacity: Option = None; - match fill { - crate::anyrender_renderer::FillPaint::Solid(color) => { - let (hex, opacity) = svg_color_paint_values( - to_u8(color.r), - to_u8(color.g), - to_u8(color.b), - color.a, - ); - fill_ref = Some(hex); - if effects.is_empty() { - fill_path_opacity = opacity; - } else if fills.len() == 1 { - effect_group_opacity = opacity; - } else { - fill_path_opacity = opacity; - } + } + if let Some(opacity) = fill_stack_opacity { + path_group = path_group.set("opacity", opacity); + } + } else { + for (fill, fill_blend) in fills.iter() { + let mut fill_ref: Option = None; + let mut fill_path_opacity: Option = None; + match fill { + crate::render::anyrender_renderer::FillPaint::Solid(color) => { + let (hex, opacity) = svg_color_paint_values( + to_u8(color.r), + to_u8(color.g), + to_u8(color.b), + color.a, + ); + fill_ref = Some(hex); + if effects.is_empty() { + fill_path_opacity = opacity; + } else if fills.len() == 1 { + effect_group_opacity = opacity; + } else { + fill_path_opacity = opacity; } - crate::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { - let gradient_id = - format!("paint{gradient_count}_linear_{root_id_suffix}"); - gradient_count += 1; - if let Some(gradient) = svg_linear_gradient_def( - &gradient_id, - fill_x, - fill_y, - fill_w, - fill_h, - transform.as_ref(), - stops.as_slice(), - ) { - deferred_gradients.push(Box::new(gradient)); - has_defs = true; - fill_ref = Some(format!("url(#{gradient_id})")); - } + } + crate::render::anyrender_renderer::FillPaint::GradientLinear { + transform, + stops, + } => { + let gradient_id = + format!("paint{gradient_count}_linear_{root_id_suffix}"); + gradient_count += 1; + if let Some(gradient) = svg_linear_gradient_def( + &gradient_id, + fill_x, + fill_y, + fill_w, + fill_h, + transform.as_ref(), + stops.as_slice(), + ) { + deferred_gradients.push(Box::new(gradient)); + has_defs = true; + fill_ref = Some(format!("url(#{gradient_id})")); } - crate::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { - let gradient_id = - format!("paint{gradient_count}_radial_{root_id_suffix}"); - gradient_count += 1; - if let Some(gradient) = svg_radial_gradient_def( - &gradient_id, - fill_x, - fill_y, - fill_w, - fill_h, - transform.as_ref(), - stops.as_slice(), - ) { - deferred_gradients.push(Box::new(gradient)); - has_defs = true; - fill_ref = Some(format!("url(#{gradient_id})")); - } + } + crate::render::anyrender_renderer::FillPaint::GradientRadial { + transform, + stops, + } => { + let gradient_id = + format!("paint{gradient_count}_radial_{root_id_suffix}"); + gradient_count += 1; + if let Some(gradient) = svg_radial_gradient_def( + &gradient_id, + fill_x, + fill_y, + fill_w, + fill_h, + transform.as_ref(), + stops.as_slice(), + ) { + deferred_gradients.push(Box::new(gradient)); + has_defs = true; + fill_ref = Some(format!("url(#{gradient_id})")); } - crate::anyrender_renderer::FillPaint::GradientAngular { .. } => {} - crate::anyrender_renderer::FillPaint::Image { - image_hash, - data_base64, - alpha, - scale_mode, - image_transform, - } => { - let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); - pattern_count += 1; - let image_key = image_hash - .clone() - .unwrap_or_else(|| format!("inline:{data_base64}")); - let image_id = if let Some(existing) = image_defs.get(&image_key) { - existing.clone() - } else { - let new_id = format!("image{image_count}_{root_id_suffix}"); - image_count += 1; - image_defs.insert(image_key, new_id.clone()); - let image = Image::new() - .set("id", new_id.clone()) - .set("width", "1") - .set("height", "1") - .set("preserveAspectRatio", "none") - .set( - "xlink:href", - format!("data:image/png;base64,{data_base64}"), - ); - deferred_images.push(image); - new_id - }; - let mut pattern = Pattern::new() - .set("id", pattern_id.clone()) - .set("patternContentUnits", "objectBoundingBox") + } + crate::render::anyrender_renderer::FillPaint::GradientAngular { + .. + } => {} + crate::render::anyrender_renderer::FillPaint::Image { + image_hash, + data_base64, + alpha, + scale_mode, + image_transform, + } => { + let pattern_id = format!("pattern{pattern_count}_{root_id_suffix}"); + pattern_count += 1; + let image_key = image_hash + .clone() + .unwrap_or_else(|| format!("inline:{data_base64}")); + let image_id = if let Some(existing) = image_defs.get(&image_key) { + existing.clone() + } else { + let new_id = format!("image{image_count}_{root_id_suffix}"); + image_count += 1; + image_defs.insert(image_key, new_id.clone()); + let image = Image::new() + .set("id", new_id.clone()) .set("width", "1") - .set("height", "1"); - let mut image_use = Use::new().set("xlink:href", format!("#{image_id}")); - if let Some((a, b, c, d, e, f)) = svg_image_fill_transform( - *scale_mode, - image_transform.as_ref(), - fill_w, - fill_h, - ) { - image_use = image_use.set( - "transform", - format!( - "matrix({} {} {} {} {} {})", - fmt_f64(a), - fmt_f64(b), - fmt_f64(c), - fmt_f64(d), - fmt_f64(e), - fmt_f64(f) - ), + .set("height", "1") + .set("preserveAspectRatio", "none") + .set( + "xlink:href", + format!("data:image/png;base64,{data_base64}"), ); - } - pattern = pattern.add(image_use); - defs = defs.add(pattern); - has_defs = true; - has_xlink_namespace = true; - fill_ref = Some(format!("url(#{pattern_id})")); - if *alpha < 1.0 { - fill_path_opacity = Some(fmt_f64(*alpha)); - } + deferred_images.push(image); + new_id + }; + let pattern = svg_image_pattern( + &pattern_id, + &image_id, + *scale_mode, + image_transform.as_ref(), + fill_w, + fill_h, + data_base64, + ); + defs = defs.add(pattern); + has_defs = true; + has_xlink_namespace = true; + fill_ref = Some(format!("url(#{pattern_id})")); + if *alpha < 1.0 { + fill_path_opacity = Some(fmt_f64(*alpha)); } } + } - let Some(fill_ref) = fill_ref else { - continue; - }; - for d in &fill_paths { - let mut path = SvgPath::new() - .set("d", d.as_str()) - .set("transform", transform.clone()) - .set("fill", fill_ref.clone()); - if let Some(opacity) = &fill_path_opacity { - path = path.set("fill-opacity", opacity.clone()); - } - if let Some(mode) = svg_mix_blend_mode(fill_blend) { - path = path.set("style", format!("mix-blend-mode:{mode}")); - } - path_group = path_group.add(path); + let Some(fill_ref) = fill_ref else { + continue; + }; + for d in &fill_paths { + let mut path = SvgPath::new() + .set("d", d.as_str()) + .set("transform", transform.clone()) + .set("fill", fill_ref.clone()); + if let Some(opacity) = &fill_path_opacity { + path = path.set("fill-opacity", opacity.clone()); } + if let Some(mode) = svg_mix_blend_mode(fill_blend) { + path = path.set("style", format!("mix-blend-mode:{mode}")); + } + path_group = path_group.add(path); } } + } if let Some(opacity) = effect_group_opacity { path_group = path_group.set("opacity", opacity); } if let Some(stroke) = stroke.as_ref() { - let (hex, opacity) = svg_color_paint_values( - to_u8(stroke.color.r), - to_u8(stroke.color.g), - to_u8(stroke.color.b), - stroke.color.a, + // Paths are node-local (translated into place). + let (hex, opacity) = svg_stroke_paint( + stroke, + (0.0, 0.0, width, height), + &mut gradient_count, + &root_id_suffix, + &mut deferred_gradients, + &mut has_defs, ); - let stroke_source_paths = if fill_paths.is_empty() { + let stroke_source_paths = if let Some(paths) = &stroked_fill_paths { + paths + } else if fill_paths.is_empty() { &stroke_paths } else { &fill_paths @@ -3074,10 +2982,16 @@ pub fn run_cli() -> Result<(), String> { .set("transform", transform.clone()) .set("stroke", hex.clone()) .set("stroke-width", fmt_f64(stroke.width.max(0.0))); - if !matches!(stroke.cap, crate::anyrender_renderer::StrokeCap::None) { + if !matches!( + stroke.cap, + crate::render::anyrender_renderer::StrokeCap::None + ) { path = path.set("stroke-linecap", svg_linecap(stroke.cap)); } - if !matches!(stroke.join, crate::anyrender_renderer::StrokeJoin::Miter) { + if !matches!( + stroke.join, + crate::render::anyrender_renderer::StrokeJoin::Miter + ) { path = path.set("stroke-linejoin", svg_linejoin(stroke.join)); } if !stroke.dash_pattern.is_empty() { @@ -3109,8 +3023,18 @@ pub fn run_cli() -> Result<(), String> { height.max(0.0), ); } - // Figma parity SVG exports are path-based for text. - PaintPrimitive::Text { .. } => {} + // Figma text arrives as outlines (`Path`s); authored text is a + // `` carrying its family, weight, style and width. + text @ PaintPrimitive::Text { .. } => { + if let Some(el) = super::text::text_element( + &text, + tx, + ty, + crate::render::font_book::FontBook::bundled(), + ) { + push_node(&mut svg_doc, &mut group_stack, el); + } + } } } @@ -3156,7 +3080,7 @@ fn push_node_with_optional_blend( svg_doc: &mut svg::Document, group_stack: &mut Vec, mut node: N, - blend: Option<&crate::anyrender_renderer::BlendMix>, + blend: Option<&crate::render::anyrender_renderer::BlendMix>, ) { if let Some(mode) = blend.and_then(svg_mix_blend_mode) { svg::Node::assign(&mut node, "style", format!("mix-blend-mode:{mode}")); @@ -3193,10 +3117,10 @@ fn push_complex_frame_stroke_overlays( ry: f64, rw: f64, rh: f64, - corner_radii: &frame_proto::CornerRadii, + corner_radii: &crate::document::proto::CornerRadii, frame_stroke_width: f64, - raw_frame_strokes: &[crate::anyrender_renderer::FillPaint], - fill_blend: &crate::anyrender_renderer::BlendMix, + raw_frame_strokes: &[crate::render::anyrender_renderer::FillPaint], + fill_blend: &crate::render::anyrender_renderer::BlendMix, ) { for stroke_paint in raw_frame_strokes { let mut stroke_rect = svg::node::element::Rectangle::new() @@ -3209,19 +3133,15 @@ fn push_complex_frame_stroke_overlays( stroke_rect = apply_corner_radii_rect(stroke_rect, corner_radii); match stroke_paint { - crate::anyrender_renderer::FillPaint::Solid(color) => { - let (hex, opacity) = svg_color_paint_values( - to_u8(color.r), - to_u8(color.g), - to_u8(color.b), - color.a, - ); + crate::render::anyrender_renderer::FillPaint::Solid(color) => { + let (hex, opacity) = + svg_color_paint_values(to_u8(color.r), to_u8(color.g), to_u8(color.b), color.a); stroke_rect = stroke_rect.set("stroke", hex); if let Some(opacity) = opacity { stroke_rect = stroke_rect.set("stroke-opacity", opacity); } } - crate::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientLinear { transform, stops } => { let stroke_gradient_id = format!("paint{gradient_count}_linear_{root_id_suffix}"); *gradient_count += 1; if let Some(gradient) = svg_linear_gradient_def( @@ -3240,7 +3160,7 @@ fn push_complex_frame_stroke_overlays( continue; } } - crate::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { + crate::render::anyrender_renderer::FillPaint::GradientRadial { transform, stops } => { let stroke_gradient_id = format!("paint{gradient_count}_radial_{root_id_suffix}"); *gradient_count += 1; if let Some(gradient) = svg_radial_gradient_def( @@ -3287,10 +3207,7 @@ fn union_path_bounds(paths: &[String], tx: f64, ty: f64) -> Option<(f64, f64, f6 } #[cfg(feature = "svg_export")] -fn bbox_alignment_error( - bounds: (f64, f64, f64, f64), - target: (f64, f64, f64, f64), -) -> f64 { +fn bbox_alignment_error(bounds: (f64, f64, f64, f64), target: (f64, f64, f64, f64)) -> f64 { let (b0x, b0y, b1x, b1y) = bounds; let (tx, ty, tw, th) = target; let bw = (b1x - b0x).max(0.0); @@ -3300,10 +3217,11 @@ fn bbox_alignment_error( #[cfg(feature = "svg_export")] fn resolve_rect_geometry( - doc: &frame_proto::FrameDocument, - node_id: frame_proto::NodeId, - layout_boxes: &std::collections::HashMap, - source_bboxes: &std::collections::HashMap, + node: Option<&crate::document::proto::Node>, + layout_boxes: &std::collections::HashMap< + crate::document::proto::NodeId, + crate::render::anyrender_renderer::LayoutBox, + >, x: f64, y: f64, width: f64, @@ -3312,37 +3230,36 @@ fn resolve_rect_geometry( ty: f64, rotation: f64, fill_paths: &[String], - stroke: Option<&crate::anyrender_renderer::StrokeStyle>, + stroke: Option<&crate::render::anyrender_renderer::StrokeStyle>, ) -> ResolvedRectGeometry { - let node = doc.get_node(node_id); let mut rx = x + tx; let mut ry = y + ty; let mut rw = width.max(0.0); let mut rh = height.max(0.0); let mut used_bbox_fallback = false; if rw <= 1.0 || rh <= 1.0 { - if let Some(node) = node { - if let Some((bx, by, bw, bh)) = source_bboxes.get(&node.figma_id) { - rx = *bx + tx; - ry = *by + ty; - rw = *bw; - rh = *bh; - used_bbox_fallback = true; - } + if let Some(node) = node + && let Some((bx, by, bw, bh)) = source_bounds(node) + { + rx = bx + tx; + ry = by + ty; + rw = bw; + rh = bh; + used_bbox_fallback = true; } - if !used_bbox_fallback { - if let Some(b) = layout_boxes.get(&node_id).copied() { - rx = b.x + tx; - ry = b.y + ty; - rw = b.width.max(0.0); - rh = b.height.max(0.0); - used_bbox_fallback = true; - } + if !used_bbox_fallback && let Some(b) = node.and_then(|n| layout_boxes.get(&n.id)).copied() + { + rx = b.x + tx; + ry = b.y + ty; + rw = b.width.max(0.0); + rh = b.height.max(0.0); + used_bbox_fallback = true; } } if rotation.abs() > 1e-6 { - if let Some((width_local, height_local)) = - fill_paths.first().and_then(|d| svg_local_rect_size_from_path(d)) + if let Some((width_local, height_local)) = fill_paths + .first() + .and_then(|d| svg_local_rect_size_from_path(d)) { rw = width_local; rh = height_local; @@ -3367,8 +3284,7 @@ fn resolve_rect_geometry( } if let Some(stroke) = stroke { let stroke_align_inside = node - .and_then(|n| n.raw.get("strokeAlign").and_then(|v| v.as_str())) - .map(|v| v.eq_ignore_ascii_case("INSIDE")) + .map(|n| n.stroke.align == crate::document::proto::StrokeAlign::Inside) .unwrap_or(false); if used_bbox_fallback || stroke_align_inside { let inset = stroke.width.max(0.0) * 0.5; @@ -3378,12 +3294,7 @@ fn resolve_rect_geometry( rh = (rh - inset * 2.0).max(0.0); } } - ResolvedRectGeometry { - rx, - ry, - rw, - rh, - } + ResolvedRectGeometry { rx, ry, rw, rh } } #[cfg(feature = "svg_export")] @@ -3405,7 +3316,7 @@ fn apply_fill_style_rect( #[cfg(feature = "svg_export")] pub(super) fn apply_corner_radii_rect( mut rect: svg::node::element::Rectangle, - corner_radii: &frame_proto::CornerRadii, + corner_radii: &crate::document::proto::CornerRadii, ) -> svg::node::element::Rectangle { let tl = corner_radii.top_left.max(0.0); let tr = corner_radii.top_right.max(0.0); @@ -3422,8 +3333,10 @@ pub(super) fn apply_corner_radii_rect( } #[cfg(feature = "svg_export")] -pub(super) fn svg_mix_blend_mode(mode: &crate::anyrender_renderer::BlendMix) -> Option<&'static str> { - use crate::anyrender_renderer::BlendMix; +pub(super) fn svg_mix_blend_mode( + mode: &crate::render::anyrender_renderer::BlendMix, +) -> Option<&'static str> { + use crate::render::anyrender_renderer::BlendMix; match mode { BlendMix::Multiply => Some("multiply"), BlendMix::Screen => Some("screen"), @@ -3479,16 +3392,11 @@ fn new_circle_or_ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> svg::node::eleme } #[cfg(feature = "svg_export")] -fn svg_fill_style(fill: &crate::anyrender_renderer::FillPaint) -> Option { - use crate::anyrender_renderer::FillPaint; +fn svg_fill_style(fill: &crate::render::anyrender_renderer::FillPaint) -> Option { + use crate::render::anyrender_renderer::FillPaint; match fill { FillPaint::Solid(c) => { - let (hex, opacity) = svg_color_paint_values( - to_u8(c.r), - to_u8(c.g), - to_u8(c.b), - c.a, - ); + let (hex, opacity) = svg_color_paint_values(to_u8(c.r), to_u8(c.g), to_u8(c.b), c.a); Some(FillStyle::Solid { hex, opacity }) } FillPaint::GradientLinear { .. } @@ -3500,7 +3408,7 @@ fn svg_fill_style(fill: &crate::anyrender_renderer::FillPaint) -> Option, + transform: Option<&crate::render::anyrender_renderer::AffineTransform2D>, u: f64, v: f64, ) -> (f64, f64) { @@ -3513,7 +3421,7 @@ fn gradient_transform_point( #[cfg(feature = "svg_export")] fn svg_gradient_stop( - stop: &crate::anyrender_renderer::GradientStop, + stop: &crate::render::anyrender_renderer::GradientStop, ) -> svg::node::element::Stop { let (hex, opacity) = svg_color_paint_values( to_u8(stop.color.r), @@ -3591,7 +3499,7 @@ fn svg_local_rect_size_from_path(d: &str) -> Option<(f64, f64)> { match cmd { 'M' | 'L' => { - for pair in nums.chunks_exact(2) { + for pair in nums.as_chunks::<2>().0 { curr_x = pair[0]; curr_y = pair[1]; min_x = min_x.min(curr_x); @@ -3602,7 +3510,7 @@ fn svg_local_rect_size_from_path(d: &str) -> Option<(f64, f64)> { } } 'm' | 'l' => { - for pair in nums.chunks_exact(2) { + for pair in nums.as_chunks::<2>().0 { curr_x += pair[0]; curr_y += pair[1]; min_x = min_x.min(curr_x); @@ -3655,12 +3563,60 @@ fn svg_local_rect_size_from_path(d: &str) -> Option<(f64, f64)> { _ => return None, } } - if !saw_point || !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { + if !saw_point + || !min_x.is_finite() + || !min_y.is_finite() + || !max_x.is_finite() + || !max_y.is_finite() + { return None; } Some(((max_x - min_x).max(0.0), (max_y - min_y).max(0.0))) } +/// A stroke's SVG paint and opacity: its colour, or for a linear or radial +/// gradient stroke a `url(#…)` to a gradient laid across `(x, y, w, h)` (in +/// the stroked element's user space) as a fill's would be. A zero-width or +/// zero-height box (a line) is widened to the stroke's width. Angular +/// gradients keep their first stop's colour. +#[cfg(feature = "svg_export")] +fn svg_stroke_paint( + stroke: &crate::render::anyrender_renderer::StrokeStyle, + (x, y, w, h): (f64, f64, f64, f64), + gradient_count: &mut u64, + root_id_suffix: &str, + deferred_gradients: &mut Vec>, + has_defs: &mut bool, +) -> (String, Option) { + use crate::render::anyrender_renderer::FillPaint; + let (x, y, w, h) = crate::render::anyrender_renderer::stroke_gradient_box(stroke, x, y, w, h); + let def: Option<(String, Box)> = match &stroke.gradient { + Some(FillPaint::GradientLinear { transform, stops }) => { + let id = format!("paint{gradient_count}_linear_{root_id_suffix}"); + svg_linear_gradient_def(&id, x, y, w, h, transform.as_ref(), stops) + .map(|g| (id, Box::new(g) as Box)) + } + Some(FillPaint::GradientRadial { transform, stops }) => { + let id = format!("paint{gradient_count}_radial_{root_id_suffix}"); + svg_radial_gradient_def(&id, x, y, w, h, transform.as_ref(), stops) + .map(|g| (id, Box::new(g) as Box)) + } + _ => None, + }; + if let Some((id, def)) = def { + *gradient_count += 1; + deferred_gradients.push(def); + *has_defs = true; + return (format!("url(#{id})"), None); + } + svg_color_paint_values( + to_u8(stroke.color.r), + to_u8(stroke.color.g), + to_u8(stroke.color.b), + stroke.color.a, + ) +} + #[cfg(feature = "svg_export")] fn svg_linear_gradient_def( id: &str, @@ -3668,8 +3624,8 @@ fn svg_linear_gradient_def( y: f64, width: f64, height: f64, - transform: Option<&crate::anyrender_renderer::AffineTransform2D>, - stops: &[crate::anyrender_renderer::GradientStop], + transform: Option<&crate::render::anyrender_renderer::AffineTransform2D>, + stops: &[crate::render::anyrender_renderer::GradientStop], ) -> Option { if stops.is_empty() || width <= 0.0 || height <= 0.0 { return None; @@ -3696,8 +3652,8 @@ fn svg_radial_gradient_def( y: f64, width: f64, height: f64, - transform: Option<&crate::anyrender_renderer::AffineTransform2D>, - stops: &[crate::anyrender_renderer::GradientStop], + transform: Option<&crate::render::anyrender_renderer::AffineTransform2D>, + stops: &[crate::render::anyrender_renderer::GradientStop], ) -> Option { if stops.is_empty() || width <= 0.0 || height <= 0.0 { return None; @@ -3718,11 +3674,8 @@ fn svg_radial_gradient_def( let rx = (ax.0 * ax.0 + ax.1 * ax.1).sqrt() * 0.5; let ry = (ay.0 * ay.0 + ay.1 * ay.1).sqrt() * 0.5; let angle = ax.1.atan2(ax.0).to_degrees(); - let mut gradient_transform = format!( - "translate({} {})", - fmt_f64(center_x), - fmt_f64(center_y) - ); + let mut gradient_transform = + format!("translate({} {})", fmt_f64(center_x), fmt_f64(center_y)); if angle.abs() > 1e-6 { gradient_transform.push_str(&format!(" rotate({})", fmt_f64(angle))); } @@ -3748,19 +3701,19 @@ fn svg_radial_gradient_def( #[cfg(feature = "svg_export")] fn svg_angular_conic_style( - _transform: Option<&crate::anyrender_renderer::AffineTransform2D>, - stops: &[crate::anyrender_renderer::GradientStop], + _transform: Option<&crate::render::anyrender_renderer::AffineTransform2D>, + stops: &[crate::render::anyrender_renderer::GradientStop], ) -> String { let mut parts = Vec::new(); // Seamless wrap: prepend first stop color at 0deg if gradient doesn't start there - if let Some(first) = stops.first() { - if first.offset.clamp(0.0, 1.0) * 360.0 > 1e-6 { - let r = to_u8(first.color.r); - let g = to_u8(first.color.g); - let b = to_u8(first.color.b); - let a = fmt_f64(first.color.a.clamp(0.0, 1.0)); - parts.push(format!("rgba({r}, {g}, {b}, {a}) 0deg")); - } + if let Some(first) = stops.first() + && first.offset.clamp(0.0, 1.0) * 360.0 > 1e-6 + { + let r = to_u8(first.color.r); + let g = to_u8(first.color.g); + let b = to_u8(first.color.b); + let a = fmt_f64(first.color.a.clamp(0.0, 1.0)); + parts.push(format!("rgba({r}, {g}, {b}, {a}) 0deg")); } for stop in stops { let r = to_u8(stop.color.r); @@ -3771,14 +3724,14 @@ fn svg_angular_conic_style( parts.push(format!("rgba({r}, {g}, {b}, {a}) {deg}deg")); } // Seamless wrap: append last stop color at 360deg if gradient doesn't end there - if let Some(last) = stops.last() { - if last.offset.clamp(0.0, 1.0) * 360.0 < 360.0 - 1e-6 { - let r = to_u8(last.color.r); - let g = to_u8(last.color.g); - let b = to_u8(last.color.b); - let a = fmt_f64(last.color.a.clamp(0.0, 1.0)); - parts.push(format!("rgba({r}, {g}, {b}, {a}) 360deg")); - } + if let Some(last) = stops.last() + && last.offset.clamp(0.0, 1.0) * 360.0 < 360.0 - 1e-6 + { + let r = to_u8(last.color.r); + let g = to_u8(last.color.g); + let b = to_u8(last.color.b); + let a = fmt_f64(last.color.a.clamp(0.0, 1.0)); + parts.push(format!("rgba({r}, {g}, {b}, {a}) 360deg")); } format!( "background:conic-gradient(from 90deg,{});height:100%;width:100%;opacity:1", @@ -3792,8 +3745,12 @@ fn svg_angular_metadata_attr( y: f64, width: f64, height: f64, - gradient_transform: Option<(&crate::anyrender_renderer::AffineTransform2D, f64, f64)>, - stops: &[crate::anyrender_renderer::GradientStop], + gradient_transform: Option<( + &crate::render::anyrender_renderer::AffineTransform2D, + f64, + f64, + )>, + stops: &[crate::render::anyrender_renderer::GradientStop], ) -> String { fn raw_f(v: f64) -> String { // Debug format always includes a decimal point for whole numbers (e.g. 1.0), @@ -3801,7 +3758,7 @@ fn svg_angular_metadata_attr( // the f32→f64 precision that Figma embeds in its JSON. format!("{v:?}") } - fn color_json(c: &crate::anyrender_renderer::Rgba) -> String { + fn color_json(c: &crate::render::anyrender_renderer::Rgba) -> String { format!( "{{\"r\":{},\"g\":{},\"b\":{},\"a\":{}}}", raw_f(c.r.clamp(0.0, 1.0)), @@ -3810,7 +3767,7 @@ fn svg_angular_metadata_attr( raw_f(c.a.clamp(0.0, 1.0)), ) } - fn stop_json(stop: &crate::anyrender_renderer::GradientStop) -> String { + fn stop_json(stop: &crate::render::anyrender_renderer::GradientStop) -> String { format!( "{{\"color\":{},\"position\":{}}}", color_json(&stop.color), @@ -3833,7 +3790,12 @@ fn svg_angular_metadata_attr( }; format!( "{{\"type\":\"GRADIENT_ANGULAR\",\"stops\":[{stops_json}],\"stopsVar\":[],\"transform\":{{\"m00\":{},\"m01\":{},\"m02\":{},\"m10\":{},\"m11\":{},\"m12\":{}}},\"opacity\":1.0,\"blendMode\":\"NORMAL\",\"visible\":true}}", - raw_f(m00), raw_f(m01), raw_f(m02), raw_f(m10), raw_f(m11), raw_f(m12) + raw_f(m00), + raw_f(m01), + raw_f(m02), + raw_f(m10), + raw_f(m11), + raw_f(m12) ) } @@ -3999,6 +3961,40 @@ pub(super) fn push_node( } } +/// Open a group that also nests correctly while a mask definition is being +/// captured (its content lands in the mask, not the document). +#[cfg(feature = "svg_export")] +fn open_scoped_group( + groups: &mut Vec, + group: svg::node::element::Group, +) { + let mut group = Some(group); + MASK_CAPTURE_STATE.with(|slot| { + if let Some(state) = slot.borrow_mut().as_mut() { + state.groups.push(group.take().expect("group present")); + } + }); + if let Some(group) = group { + groups.push(group); + } +} + +/// Close the group [`open_scoped_group`] opened, adding it to its parent. +#[cfg(feature = "svg_export")] +fn close_scoped_group(doc: &mut svg::Document, groups: &mut Vec) { + let captured = + MASK_CAPTURE_STATE.with(|slot| slot.borrow_mut().as_mut().map(|state| state.groups.pop())); + match captured { + Some(Some(group)) => push_node(doc, groups, group), + Some(None) => {} + None => { + if let Some(group) = groups.pop() { + push_node(doc, groups, group); + } + } + } +} + #[cfg(feature = "svg_export")] fn push_root_node(doc: &mut svg::Document, node: N) { let current = std::mem::replace(doc, svg::Document::new()); @@ -4068,12 +4064,7 @@ fn svg_path_bounds(d: &str, tx: f64, ty: f64) -> Option<(f64, f64, f64, f64)> { if bb.width() <= 0.0 || bb.height() <= 0.0 { return None; } - Some(( - bb.x0 + tx, - bb.y0 + ty, - bb.x1 + tx, - bb.y1 + ty, - )) + Some((bb.x0 + tx, bb.y0 + ty, bb.x1 + tx, bb.y1 + ty)) } #[cfg(feature = "svg_export")] @@ -4092,19 +4083,19 @@ pub(super) fn svg_color_paint_values(r: u8, g: u8, b: u8, a: f64) -> (String, Op } #[cfg(feature = "svg_export")] -pub(super) fn svg_linecap(cap: crate::anyrender_renderer::StrokeCap) -> &'static str { +pub(super) fn svg_linecap(cap: crate::render::anyrender_renderer::StrokeCap) -> &'static str { match cap { - crate::anyrender_renderer::StrokeCap::Round => "round", - crate::anyrender_renderer::StrokeCap::Square => "square", + crate::render::anyrender_renderer::StrokeCap::Round => "round", + crate::render::anyrender_renderer::StrokeCap::Square => "square", _ => "butt", } } #[cfg(feature = "svg_export")] -pub(super) fn svg_linejoin(join: crate::anyrender_renderer::StrokeJoin) -> &'static str { +pub(super) fn svg_linejoin(join: crate::render::anyrender_renderer::StrokeJoin) -> &'static str { match join { - crate::anyrender_renderer::StrokeJoin::Round => "round", - crate::anyrender_renderer::StrokeJoin::Bevel => "bevel", + crate::render::anyrender_renderer::StrokeJoin::Round => "round", + crate::render::anyrender_renderer::StrokeJoin::Bevel => "bevel", _ => "miter", } } @@ -4118,18 +4109,116 @@ fn svg_dasharray(pattern: &[f64]) -> String { .join(" ") } +/// The `` filling an element with an image (`image_id`, a 1×1 +/// `` def) in `scale_mode`, in the element's bounding-box units +/// (`width × height` px). A tile is the image at the size its transform +/// gives it (image px → node px; identity = its own size), repeated from +/// the box's top-left and offset by the transform's translation (its +/// rotation and skew aren't exported); every other mode covers the box +/// once. +#[cfg(feature = "svg_export")] +#[allow(clippy::too_many_arguments)] +fn svg_image_pattern( + pattern_id: &str, + image_id: &str, + scale_mode: crate::render::anyrender_renderer::ImageScaleMode, + image_transform: Option<&crate::render::anyrender_renderer::AffineTransform2D>, + width: f64, + height: f64, + data_base64: &str, +) -> svg::node::element::Pattern { + let pattern = svg::node::element::Pattern::new() + .set("id", pattern_id.to_string()) + .set("patternContentUnits", "objectBoundingBox"); + let image_use = svg::node::element::Use::new().set("xlink:href", format!("#{image_id}")); + if scale_mode == crate::render::anyrender_renderer::ImageScaleMode::Tile + && width > 0.0 + && height > 0.0 + && let Some((iw, ih)) = image_pixel_size(data_base64) + { + let t = image_transform + .copied() + .unwrap_or_else(crate::render::anyrender_renderer::AffineTransform2D::identity); + let tw = f64::from(iw) * t.a.hypot(t.c) / width; + let th = f64::from(ih) * t.b.hypot(t.d) / height; + if tw > 0.0 && th > 0.0 { + return pattern + .set("x", fmt_f64(t.tx / width)) + .set("y", fmt_f64(t.ty / height)) + .set("width", fmt_f64(tw)) + .set("height", fmt_f64(th)) + .add(image_use.set( + "transform", + format!("scale({} {})", fmt_f64(tw), fmt_f64(th)), + )); + } + } + let pattern = pattern.set("width", "1").set("height", "1"); + let image_use = match svg_image_fill_transform(scale_mode, image_transform, width, height) { + Some((a, b, c, d, e, f)) => image_use.set( + "transform", + format!( + "matrix({} {} {} {} {} {})", + fmt_f64(a), + fmt_f64(b), + fmt_f64(c), + fmt_f64(d), + fmt_f64(e), + fmt_f64(f) + ), + ), + None => image_use, + }; + pattern.add(image_use) +} + +/// An encoded image's pixel size, read from its header (PNG, GIF, JPEG). +#[cfg(feature = "svg_export")] +fn image_pixel_size(data_base64: &str) -> Option<(u32, u32)> { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD; + let head = b64.decode(data_base64.get(..32)?).ok()?; + if head.starts_with(b"\x89PNG\r\n\x1a\n") && head.get(12..16) == Some(b"IHDR") { + let be = |i: usize| u32::from_be_bytes([head[i], head[i + 1], head[i + 2], head[i + 3]]); + return Some((be(16), be(20))); + } + if head.starts_with(b"GIF8") { + let le = |i: usize| u32::from(u16::from_le_bytes([head[i], head[i + 1]])); + return Some((le(6), le(8))); + } + if head.starts_with(&[0xff, 0xd8]) { + // Walk the segments to a start-of-frame. + let bytes = b64.decode(data_base64).ok()?; + let mut i = 2; + while i + 9 < bytes.len() { + if bytes[i] != 0xff { + return None; + } + let marker = bytes[i + 1]; + let len = usize::from(u16::from_be_bytes([bytes[i + 2], bytes[i + 3]])); + if matches!(marker, 0xc0..=0xcf) && !matches!(marker, 0xc4 | 0xc8 | 0xcc) { + let h = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]])); + let w = u32::from(u16::from_be_bytes([bytes[i + 7], bytes[i + 8]])); + return Some((w, h)); + } + i += 2 + len; + } + } + None +} + #[cfg(feature = "svg_export")] fn svg_image_fill_transform( - scale_mode: crate::anyrender_renderer::ImageScaleMode, - image_transform: Option<&crate::anyrender_renderer::AffineTransform2D>, + scale_mode: crate::render::anyrender_renderer::ImageScaleMode, + image_transform: Option<&crate::render::anyrender_renderer::AffineTransform2D>, width: f64, height: f64, ) -> Option<(f64, f64, f64, f64, f64, f64)> { - if let Some(t) = image_transform { - if !t.is_identity() { - // Return SVG column-major coeffs: (a, c, b, d, tx, ty) - return Some(t.to_svg_matrix_coeffs()); - } + if let Some(t) = image_transform + && !t.is_identity() + { + // Return SVG column-major coeffs: (a, c, b, d, tx, ty) + return Some(t.to_svg_matrix_coeffs()); } if width <= 0.0 || height <= 0.0 { @@ -4137,7 +4226,7 @@ fn svg_image_fill_transform( } let aspect = width / height; match scale_mode { - crate::anyrender_renderer::ImageScaleMode::Fill => { + crate::render::anyrender_renderer::ImageScaleMode::Fill => { let sy = aspect; let ty = (1.0 - sy) * 0.5; if (sy - 1.0).abs() <= f64::EPSILON && ty.abs() <= f64::EPSILON { @@ -4146,7 +4235,7 @@ fn svg_image_fill_transform( Some((1.0, 0.0, 0.0, sy, 0.0, ty)) } } - crate::anyrender_renderer::ImageScaleMode::Fit => { + crate::render::anyrender_renderer::ImageScaleMode::Fit => { let sx = 1.0 / aspect; let tx = (1.0 - sx) * 0.5; if (sx - 1.0).abs() <= f64::EPSILON && tx.abs() <= f64::EPSILON { @@ -4155,9 +4244,9 @@ fn svg_image_fill_transform( Some((sx, 0.0, 0.0, 1.0, tx, 0.0)) } } - crate::anyrender_renderer::ImageScaleMode::Crop - | crate::anyrender_renderer::ImageScaleMode::Stretch - | crate::anyrender_renderer::ImageScaleMode::Tile => None, + crate::render::anyrender_renderer::ImageScaleMode::Crop + | crate::render::anyrender_renderer::ImageScaleMode::Stretch + | crate::render::anyrender_renderer::ImageScaleMode::Tile => None, } } diff --git a/crates/frame/src/render/svg/scene_source.rs b/crates/frame/src/render/svg/scene_source.rs new file mode 100644 index 00000000..490ee598 --- /dev/null +++ b/crates/frame/src/render/svg/scene_source.rs @@ -0,0 +1,102 @@ +//! What the SVG exporter reads from the scene beyond the paint primitives: +//! the exported node, the source tool's absolute bounds, raw stroke stacks. + +#[cfg(feature = "svg_export")] +use crate::document::proto::{Node, PaintKind, Scene}; + +/// The exported node: the first page's top-level node whose source id is +/// `root_source_id`, else its first top-level node. +#[cfg(feature = "svg_export")] +pub(super) fn export_root<'a>(scene: &'a Scene, root_source_id: Option<&str>) -> Option<&'a Node> { + let page = scene.node(*scene.pages().first()?)?; + let mut tops = page.children.iter().filter_map(|id| scene.node(*id)); + let first = tops.clone().next()?; + Some( + root_source_id + .and_then(|id| tops.find(|n| source_id(n) == Some(id))) + .unwrap_or(first), + ) +} + +/// The node's source-tool id (a Figma node id). +#[cfg(feature = "svg_export")] +pub(super) fn source_id(node: &Node) -> Option<&str> { + node.source.as_ref().map(|s| s.id.as_str()) +} + +#[cfg(feature = "svg_export")] +thread_local! { + /// How many transform groups (`PaintPrimitive::GroupStart`) the exporter + /// is currently inside. + static GROUP_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Track entering (`+1`) / leaving (`-1`) a transform group; `None` resets. +#[cfg(feature = "svg_export")] +pub(super) fn set_group_depth(delta: Option) { + GROUP_DEPTH.with(|d| d.set(delta.map_or(0, |delta| d.get().saturating_add_signed(delta)))); +} + +/// The source tool's absolute bounds `(x, y, w, h)`, sizes clamped to ≥ 0. +/// +/// `None` inside a transform group: absolute bounds already include the +/// ancestors' transforms, which the enclosing `` applies again. +#[cfg(feature = "svg_export")] +pub(super) fn source_bounds(node: &Node) -> Option<(f64, f64, f64, f64)> { + if GROUP_DEPTH.with(std::cell::Cell::get) > 0 { + return None; + } + let b = node.source.as_ref()?.bounds?; + Some((b.x, b.y, b.width.max(0.0), b.height.max(0.0))) +} + +/// The exported node's bounds, when it has a positive area. +#[cfg(feature = "svg_export")] +pub(super) fn export_bbox(node: &Node) -> Option<(f64, f64, f64, f64)> { + let b = node.source.as_ref()?.bounds?; + (b.width > 0.0 && b.height > 0.0).then_some((b.x, b.y, b.width, b.height)) +} + +/// The exported node's first visible solid fill, as the canvas background +/// (the paint opacity is the alpha; the color's own alpha is ignored). +#[cfg(feature = "svg_export")] +pub(super) fn export_base_color(node: &Node) -> Option<(u8, u8, u8, u8)> { + fn to_u8(v: f64) -> u8 { + (v * 255.0).round().clamp(0.0, 255.0) as u8 + } + node.fills + .iter() + .filter(|p| p.visible) + .find_map(|p| match &p.kind { + PaintKind::Solid { color } => Some(( + to_u8(color.r), + to_u8(color.g), + to_u8(color.b), + to_u8(p.opacity), + )), + _ => None, + }) +} + +/// The node's visible stroke paints (solid, linear and radial only), in +/// stack order. +#[cfg(feature = "svg_export")] +pub(super) fn stroke_paints( + node: &Node, + scene: &Scene, +) -> Vec { + node.stroke + .paints + .iter() + .filter(|p| { + p.visible + && matches!( + p.kind, + PaintKind::Solid { .. } + | PaintKind::LinearGradient { .. } + | PaintKind::RadialGradient { .. } + ) + }) + .filter_map(|p| crate::render::anyrender_renderer::fill_paint(p, scene)) + .collect() +} diff --git a/crates/frame/src/render/svg/text.rs b/crates/frame/src/render/svg/text.rs new file mode 100644 index 00000000..390c9dc4 --- /dev/null +++ b/crates/frame/src/render/svg/text.rs @@ -0,0 +1,236 @@ +//! Text primitives as SVG ``: family, weight, style and width carried +//! as font attributes (the viewer sets the glyphs), laid out line by line +//! the way the painter lays them out. +//! +//! Figma imports never reach this: their text is exported outlines +//! (`Path` primitives), which the parity corpus compares. + +use crate::render::font_book::{FontBook, FontSpec}; +use svg::node::element::{TSpan, Text}; + +use crate::render::anyrender_renderer::{PaintPrimitive, Rgba, TextAlignment, TextCase}; + +use super::render::{fmt_f64, svg_color_paint_values, to_u8}; + +/// Where the painter sets a text box's first glyph, relative to the box. +const ANCHOR_SHIFT: f64 = 1.0; + +fn cased(text: &str, case: &TextCase) -> String { + match case { + TextCase::Upper | TextCase::SmallCaps | TextCase::SmallCapsForced => text.to_uppercase(), + TextCase::Lower => text.to_lowercase(), + TextCase::Title => { + let mut out = String::with_capacity(text.len()); + let mut start = true; + for c in text.chars() { + if start && !c.is_whitespace() { + out.extend(c.to_uppercase()); + } else { + out.push(c); + } + start = c.is_whitespace(); + } + out + } + TextCase::Original => text.to_string(), + } +} + +/// `font-family`: the family asked for, the face it resolves to, the book's +/// fallbacks, then a generic — so a viewer without frame's fonts still +/// sets something close. +fn family_list(fonts: &FontBook, spec: &FontSpec) -> String { + let mut names: Vec = Vec::new(); + let mut push = |n: &str| { + if !names + .iter() + .any(|m| crate::render::font_book::fold(m) == crate::render::font_book::fold(n)) + { + names.push(n.to_string()); + } + }; + if let Some(f) = &spec.family { + push(f); + } + if let Some(r) = fonts.resolve(&spec.query()) { + push(&r.face.family); + } + for f in fonts.fallback() { + push(f); + } + let quoted: Vec = names + .iter() + .map(|n| { + if n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + n.clone() + } else { + format!("'{n}'") + } + }) + .collect(); + format!("{}, sans-serif", quoted.join(", ")) +} + +/// Font attributes for `spec` — every one on the node, only what differs +/// from `base` on a run. +fn font_attrs( + fonts: &FontBook, + spec: &FontSpec, + base: Option<&FontSpec>, +) -> Vec<(&'static str, String)> { + let q = spec.query(); + let b = base.map(FontSpec::query); + let mut a = Vec::new(); + if b.is_none_or(|b| { + crate::render::font_book::fold(b.family.unwrap_or("")) + != crate::render::font_book::fold(q.family.unwrap_or("")) + }) { + a.push(("font-family", family_list(fonts, spec))); + } + if b.is_none_or(|b| b.weight != q.weight) { + a.push(("font-weight", fmt_f64(f64::from(q.weight)))); + } + if q.italic || b.is_some_and(|b| b.italic) { + a.push(( + "font-style", + if q.italic { "italic" } else { "normal" }.to_string(), + )); + } + if (q.width - 100.0).abs() > f32::EPSILON + || b.is_some_and(|b| (b.width - 100.0).abs() > f32::EPSILON) + { + a.push(("font-stretch", format!("{}%", fmt_f64(f64::from(q.width))))); + } + a +} + +fn fill(color: &Rgba) -> (String, Option) { + svg_color_paint_values(to_u8(color.r), to_u8(color.g), to_u8(color.b), color.a) +} + +/// A `Text` primitive as an SVG ``, offset by (`dx`, `dy`); `None` +/// for any other primitive or empty text. +#[must_use] +pub fn text_element( + primitive: &PaintPrimitive, + dx: f64, + dy: f64, + fonts: &FontBook, +) -> Option { + let PaintPrimitive::Text { + x, + y, + text, + color, + font_size, + line_height, + letter_spacing, + text_case, + font, + style_runs, + width, + align, + .. + } = primitive + else { + return None; + }; + let text = cased(text, text_case); + if text.is_empty() { + return None; + } + let size = font_size.max(1.0); + let line_height = line_height.unwrap_or(size * 1.2).max(1.0); + let ascent = fonts + .chain(&font.query()) + .primary() + .map_or(size * 0.8, |f| f.ascent(size)); + let (anchor, ax) = match align { + TextAlignment::Left => ("start", 0.0), + TextAlignment::Center => ("middle", width * 0.5), + TextAlignment::Right => ("end", *width), + }; + let (x0, y0) = (x + dx + ANCHOR_SHIFT + ax, y + dy + ANCHOR_SHIFT + ascent); + + let (hex, opacity) = fill(color); + let mut el = Text::new("") + .set("font-size", fmt_f64(size)) + .set("fill", hex); + if let Some(o) = opacity { + el = el.set("fill-opacity", o); + } + for (k, v) in font_attrs(fonts, font, None) { + el = el.set(k, v); + } + if letter_spacing.abs() > f64::EPSILON { + el = el.set("letter-spacing", fmt_f64(*letter_spacing)); + } + if anchor != "start" { + el = el.set("text-anchor", anchor); + } + + // Split the runs (or the whole text) into lines of styled spans. + let spans: Vec<( + String, + Option<&crate::render::anyrender_renderer::TextStyleRun>, + )> = if style_runs.is_empty() { + vec![(text.clone(), None)] + } else { + style_runs + .iter() + .map(|r| (cased(&r.text, text_case), Some(r))) + .collect() + }; + let mut lines: Vec< + Vec<( + String, + Option<&crate::render::anyrender_renderer::TextStyleRun>, + )>, + > = vec![Vec::new()]; + for (s, run) in spans { + for (i, part) in s.split('\n').enumerate() { + if i > 0 { + lines.push(Vec::new()); + } + if !part.is_empty() + && let Some(line) = lines.last_mut() + { + line.push((part.to_string(), run)); + } + } + } + for (i, line) in lines.into_iter().enumerate() { + let mut row = TSpan::new("") + .set("x", fmt_f64(x0)) + .set("y", fmt_f64(y0 + i as f64 * line_height)); + for (s, run) in line { + let Some(run) = run else { + row = row.add(svg::node::Text::new(s)); + continue; + }; + let mut span = TSpan::new(s); + for (k, v) in font_attrs(fonts, &run.font().over(font), Some(font)) { + span = span.set(k, v); + } + if let Some(sz) = run.font_size.filter(|s| (s - size).abs() > f64::EPSILON) { + span = span.set("font-size", fmt_f64(sz)); + } + if let Some(ls) = run + .letter_spacing + .filter(|l| (l - letter_spacing).abs() > f64::EPSILON) + { + span = span.set("letter-spacing", fmt_f64(ls)); + } + if let Some(c) = run.color.as_ref().filter(|c| *c != color) { + let (hex, opacity) = fill(c); + span = span.set("fill", hex); + if let Some(o) = opacity { + span = span.set("fill-opacity", o); + } + } + row = row.add(span); + } + el = el.add(row); + } + Some(el) +} diff --git a/crates/frame/src/surface/kit/KIT.md b/crates/frame/src/surface/kit/KIT.md new file mode 100644 index 00000000..e93af0ff --- /dev/null +++ b/crates/frame/src/surface/kit/KIT.md @@ -0,0 +1,377 @@ +# The hardware kit — conventions + +`surface-kit` is frame's premade component library: controls in the style of +real audio hardware, ported from FTS's `fts-audio-ui::hardware` kit +(`processor/libs/ui/fts-audio-ui/src/hardware`). Agents list them +(`surface_kit::components()`, the `components` tool) and place them by id +(`surface_kit::place`, the `add_component` tool). + +## What a component is + +A `BuildFn(&mut Builder, parent, &Place) -> NodeId` that inserts **plain frame +nodes** (frames, ellipses, rectangles, vectors, text) under `parent` and +registers a `surface_proto::Control` on the root (`b.controls.insert(root, …)`) +whose **slots** point at the moving parts: + +| role | used for | +|---|---| +| `Needle` | anything that rotates with the value (a knob's turning body, a VU needle). Rotates about the **centre of its parent** — so put it inside a square frame centred on the pivot. Draw it at the **centre of travel** (pointing up for a standard 300° knob). A `Frame` in this slot turns its whole subtree (text included), so a knob's body is a square frame filling the knob holding everything that turns with the shaft: teeth, knurl, index, wing/beak and their shadows, numerals on the skirt. (A `Group`'s own transform is *not* inherited — rotating containers must be frames.) | +| `ValueArc` | an arc drawn from start of travel to the value | +| `Fill` / `Thumb` / `PeakHold` | bars and sliders (drawn at full extent / top). On a `Fader`/`Meter` they move along its orientation; on a `Gauge` (no orientation) a `Fill` grows along its own long side and a `Thumb` travels along its lane's — so a wide lane slides sideways | +| `Value` / `Label` | readout text / legend text (an authored legend wins). `Control::format` (`ValueFormat`: decimals, unit, note name, one label per step) says how `Value` prints | +| `Led` | lamps: full opacity when on, 20% (and no emission) when off (for `ButtonMode::Select` banks, when *its* option is selected). On a `Gauge`/`Meter` the `Led`s are a **ladder**: lamp `i` of `n` (slot order) lights once the reading reaches position `(i + 1) / n` — the binding's curve puts each printed threshold there | +| `Glow` | brightness follows the value: opacity = position (so its emission scales too) — a lamp on a scalar stream | +| `Trace` / `Area` | a `Plot`'s or `Curve`'s line (stroke it, `align` centre) / the region between it and the baseline (fill it, a top → bottom gradient): the runtime rewrites the vector's geometry, decimated to its width, in the node's own box — make them the plot area's size | +| `Marker` | a node centred on a `Curve` at its `marker` input (the compressor's input ball); hidden without a reading | + +A `Handle` needs no slot: its node (a touch-sized frame, the dot drawn +centred in it) is moved to its point in its **parent**, which is the area +its axes span. + +Nothing else is special. A placed component can be restyled or taken apart +like any other nodes. + +## Placing: what `Place` says + +| field | effect | +|---|---| +| `ink` | `Ink::Light` (default, each component's own silkscreen), `Ink::Dark` (`DARK_INK`, for grey/aluminium panels) or `Ink::Color("#hex")`: every printed scale, numeral, detent, tick and panel legend. Legends printed *on* a cap keep the cap's ink; VU cards print in the card's ink. | +| `keep_case` | legends, options and captions as written (`"kHz"`, `"Trim dB"`) instead of capitals | +| `sweep` | travel in degrees for knobs, rotary switches and levers. Without it a knob given `options` is **stepped**: it prints the options as its scale and turns ≤ 60° a detent, ≤ 300° in all (`detent_sweep`); a rotary switch the same; a lever ±62° | +| `touch` | a **tight box**: the control's box (hit target) is the moving part — the knob body, the dial, the paddle's reach — padded out to at least this edge (`TOUCH_MIN` = 44 for the lint), centred on the shaft. The printed scale and legend stay where they were and hang outside it. `None` keeps the full drawn box, which auto layout needs; to lay tight components out, `place_in_cell` wraps one in a holder the size of its drawing (`drawn_bounds`) | +| `reverse` | banks, push-in columns, LED rows and ladders run last option first (a column whose top is the param's last option; a GR ladder lit right to left) | +| `inner` | the cap of a concentric pair (`knob.concentric`): its binding, legend, sweep | +| `select` | a meter's source switched by a choice param (`SourceSelect` of `MeterTap { stream, channel, offset_db }`, `None` = off): the 1176's GR / +4 / +8 / OFF. Bind the default stream too | +| `format` | the control's `ValueFormat` | +| `options` | also carries `key=value` settings and flags for components configured that way (`panel.*`: `w=`, `h=`; LED meters: `floor=`, `ceiling=`, `segments=`, `gr`, `vertical`; lamps, LED meters, footswitch LEDs: `color=#hex`, or per lamp `colors=#a,#b,…` (`Place::lamp_color`); footswitch and single buttons: `momentary`; VU meters: `glass`, a glass shader over the window on GPU renderers) — `Place::opt`, `Place::flag` | + +Builders read these through `Place::ink_color(light)`, `Place::case()`, and +`Place::control(kind)` (a `Control` carrying the binding, format and +select), and end with `registry::tighten` when `touch` is set. + +## Rules + +- **Ids**: `family.style` — `knob.daka`, `knob.marconi`, `meter.vu_ivory`, + `button.square_lit`, `switch.toggle_bat`, `panel.brushed`. Snake case. +- **Size**: `Place::size` is the primary dimension (knob diameter, meter width, + button edge). Default to `ComponentDef::default_size`. Everything scales from + it — never hard-code pixel sizes that don't. +- **Variants**: colours/finishes as `variants` (`["black", "red", "blue", "grey"]`). + Unknown variants are rejected by `place` before your builder runs. +- **Legend + scale**: honour `Place::legend` and `Place::scale` (printed scale + labels, first = start of travel) where the style prints them; otherwise use + the source kit's defaults for that style. +- **Binding**: pass `Place::binding` into the control unchanged. (One + exception: a needle meter whose printed scale isn't linear in dB — a VU + card — swaps a *linear* stream binding's `curve` for its scale law, which + the runtime's `Gauge` applies over `floor_db..ceiling_db`, so the needle + lands on the printed marks. See `meters::scale`.) + `ComponentDef::binds` says what it expects: `"param"`, `"choice param"`, + `"toggle param"`, `"level stream"`, `"action"`, `"none"` (panels). +- **Kinds**: knobs → `ControlKind::Knob { sweep_degrees }` (use the style's real + travel, or `Place::sweep`); needle meters → `ControlKind::Gauge { sweep_degrees, floor_db, ceiling_db }`; + LED bars and ladders → a `Gauge` with no sweep whose `Led`s are the lamps + (`floor > ceiling` reads a gain-reduction span, 0 dB first); bar meters → + `ControlKind::Meter`; buttons → `ControlKind::Button { mode }` + (Toggle / Momentary / Trigger / `Select { index, count }` for banks); + footswitches → `Footswitch` (or `Button { Momentary }`); faders/levers → + `Fader`; rotary selectors and paddle levers → `Knob` over a choice param + (stepped), their pointer/paddle a `Needle` frame in a square centred on the + pivot so it turns as one body; stream lamps → `Readout` with a `Glow`. +- **Concentric pairs**: the inner control's node sits *inside* the outer + control's node and box (`knob.concentric`). `design-lint` reads that + nesting as a declared pair, not an overlap, and the runtime's hit test + gives the inner (painted later) its box and the outer the rest. +- **Meters at rest**: a level stream rests at silence (the floor); a + gain-reduction stream rests at **0 dB** (a GR needle on a VU card sits on + 0 VU) and attacks *downward*, recovering at the release rate. Readings of + either sign count as reduction. Author lamps and covers in their rest + state so a still render with no data reads right. +- **0 VU = −18 dBFS**: every needle VU sets `Control::reference_dbfs` to + `surface_proto::VU_REFERENCE_DBFS`, so a level stream at −18 dBFS reads + 0 VU. Gain reduction ignores it (already relative to unity). A meter + switch's `+4`/`+8` ranges are `MeterTap::offset(-4.0 / -8.0)` on top. + LED bars, columns and ladders stay in dBFS. +- **Paint** with `crate::draw` helpers: `linear(angle, stops)` (CSS angles), + `radial_at(cx, cy, r, stops)` (unit-square hot-spot), `conic(start, stops)` + (brushed metal), `drop_shadow`/`inner_shadow`, geometry `polar`, `sector`, + `lobed`, `regular`, `polygon`, `rotated`. Angles: degrees, 0 = up, clockwise. +- **What turns and what doesn't**: the room's light doesn't rotate with a + knob. Keep lit shading (off-centre gradients, highlights, rim light, + directional shadows) *outside* the `Needle` frame — round tiers under it, + highlights in a fixed overlay above it — and paint turning parts with + rotation-invariant fills (flat, or radial about the shaft). A turning + part's shadow has no offset (it would swing round with it): give the + part's outline one zero-offset `drop_shadow` — the renderer shadows a + vector along its real outline, so it turns with the part. +- **Light**: `draw::emissive(node, color, intensity, radius)` sets + `Node::emission` where real hardware glows (lamps, jewels, backlit caps, + LEDs, a lit meter card or needle). Put it on the node an `Led` slot dims, + so the light goes out with the lamp; keep intensities tasteful (≈0.3–0.9). + It's optional — a component must read without an effects stage. +- **Descriptions** are for a model choosing a component: what it looks like, + which unit it's from, when to use it. One or two sentences. + +## Materials (`materials`, `material.*`) + +What surfaces are made of, and the rendered parts built from them +(`src/materials/`). New work in the Neural-render style should reach for +these instead of hand-rolled gradients. + +- **One light**: `materials::light` — a key light from the **upper left** + (`AZIMUTH` = −35°, kit angles). Lit faces tilt toward it; a rim catches + it on its upper-left arc; a dome's hotspot sits up-left + (`light::hotspot`); shadows fall down and a little right + (`light::shadow_offset`). Helpers: `sheen(lit, dark)` (a face's broad + gradient), `stand(height, strength)` (contact + cast drop shadows for a + part standing off the panel), `edge(name, rect, radius, depth, strength, + raised)` (a bevel or recess as two gradient strokes). Use `edge`, not + `bevel`/`recess` (inner shadows), on anything large: each inner shadow is + an effect layer over the node's whole area, and a page of them exhausts + vello's buffers (a blank render). +- **Paints**: `materials::fill(b, parent, rect, radius, &Material)` covers a + box and returns a clipped frame. `Material`: `Tolex { grain: Levant | + Bronco | Elephant | Fine, color, scale }`, `Leather`, `Croc`, `Powder { + coarse }`, `Hammertone`, `Brushed { dir }`, `Spun`, `Wood { dir }`, + `GrilleCloth { Basket | Wheat | Oxblood | Black | Silver }`, + `Perforated { pitch }`, `Expanded { pitch }`, `DiamondGrille`, + `ChevronGrille`, `Chrome`, `Nickel`, `Plastic { gloss }`, `Rubber`. + `Material::parse("tolex:bronco", Some("#hex"))` reads the spec strings the + parts' options take. +- **Textures**: pixels only where a surface needs them (grain, pebble, + dimples, pores, weave, perforation) — `materials::texture` generates + small tileable tiles in code (periodic value/fBm/cellular noise, shaded + by the kit's light; weaves drawn per pixel), PNG-encodes them and puts + them in the scene's asset table (content-addressed: a texture used by + ten surfaces is stored once). A relief tile is white-where-lit, + black-where-shaded with alpha as strength, so the paint's own hue stays. + The renderer doesn't repeat a `Tile` image, so a surface is a clipped + frame of image rects (≤ `MAX_TILES` = 48; the tile grows past that). + Tiles are 192–256 px drawn at 128–256 design px: 1–60 KB each. + Everything else (lighting, wood figure, spun rings, diamond piping, + chevrons) is a few gradient or vector nodes. +- **Cost** (a 480 × 320 swatch): chrome, nickel, spun 6 nodes, no pixels; + brushed 9 nodes + 4 tiles (19 KB); wood 11 + 4 (33 KB); tolex, powder, + hammertone, leather, plastic 17–18 nodes + 12 tiles (36–51 KB); croc + 11 + 6 (61 KB); rubber 40 + 35 (51 KB); cloths, perforated, expanded, + chevron, diamond 25–35 nodes + 20 tiles (1–47 KB). A rendered knob is + 15–17 nodes and no pixels; `panel.pedal_3d` 33 nodes; `panel.amp_head` + ~100 nodes, `panel.amp_combo` ~140 (≤ 80 tiles, ≤ 112 KB of textures). +- **`knob.render`**: the generic rendered knob. Variant = cap (`flat`, + `domed`, `fluted`, `knurled`, `chicken_head`, `pointer`); options + `material=plastic|rubber|metal|chrome|bakelite`, `color=`, `skirt=none| + plain|flared|ridged|chrome`, `skirt_color=`, `pointer=`, `bevel=`, + `spec=`, `shadow=` (0..2), `ticks=dots|lines|none`, `legend=top`; from + Rust `materials::knob::{Look, build}`. It follows *What turns and what + doesn't*: the body (Needle) holds only rotation-invariant fills (flat, or + radial about the shaft) — the lobes, teeth, beak, face disc and pointer — + with zero-offset shadows; the occlusion, the side wall (the knob's + height, seen under its top), skirt and every lit overlay (face sheen, rim + light, flute light, the specular hotspot) are fixed, so the hotspot stays + put while the pointer turns. +- **`knob.3d`**: the same knob rendered per pixel on the GPU, for the + Neural-render look vectors can't reach (ribs that turn under a fixed + light). It is `knob.render` with everything that stands off the panel — + occlusion, skirt, wall, the turning `body` (Needle), the lit overlays — + moved into a `cap` frame carrying `shader: knob.3d` (render's built-in: + a distance-field cap, skirt and insert, sphere-traced from a slightly + front-on camera, GGX + clearcoat + anisotropic spun metal, occlusion in + the grooves, a soft shadow on the panel; see render's `fx/CONTRACT.md`). + The printed ring and legend stay vector. With the stage (`--fx gpu`) the + shader replaces the `cap` subtree and turns with the control's position; + without it (`--fx off`/`fallback`, SVG, Figma) the vector knob draws + unchanged. Variant = preset: `pedal_chicken` (Cory Wong pedals: black + flutes, polished teardrop insert, flared skirt), `teardrop` (satin + teardrop on a knurled skirt), `fluted_cream`, `knurled_dome` (Halfman OD: + glossy oxblood dome over a knurled band), `spun_metal` (spun aluminium + in a black skirt), `bakelite_chicken` (Signature 83), `amp_pointer` (Nolly: + dome crossed by a fin), `gold_knurl` (THX pedals), `white_knurl` (Clean + Machine), `stepped_cream` (Gravity Tank), `amp_skirted`, and + `knob.render`'s caps `flat`, `domed`, `fluted`, `knurled`, + `chicken_head`, `pointer`. Options override the preset: `color=`, + `skirt_color=`, `pointer=`, `insert_color=`, `metal=`/`rough=`/`coat=`/ + `aniso=` (0..1), `spec=`/`shadow=` (0..2), `ribs=`, `tilt=` (degrees), + `ticks=`, `legend=top`. From Rust: `materials::knob3d::{preset, Look3d, + build}` — a `Look3d` is the shader's params and `Const` inputs + (`Look3d::shader`) plus the fallback `Look`. Cost: 16–17 vector nodes + (the fallback) and one GPU pass of ~10 µs per knob (RTX 4080); the stage + skips rendering a generator's subtree, so a knob adds no vello pass. +- **`panel.pedal_3d`**: `panel.pedal`'s classes (one height of 286, widths + by class) as a render: the lid in any finish (`finish=powder| + powder:coarse|brushed|hammertone|gloss|satin|anodized|chrome|…`), its + edge rolling into the front face, the base plate, side (or top) jacks + with hex nuts, `screws`, contact and cast shadows. +- **`panel.amp_head`, `panel.amp_combo`**: a covered box (`tolex=` any + material spec, `color=`) with lit edges, metal corners (`corners=chrome| + black|brass|none`), a handle (`handle=leather|strap|none`), feet, a + faceplate that stands proud (`face=`, `face_color=`, `face_at=top| + bottom`, `face_frac=`) and a recessed grille (`grille=cloth:wheat|…| + diamond|chevron|perforated|expanded`) framed by piping (`piping=#hex| + none`). From Rust, `materials::cabinet::cabinet` returns the `faceplate` + and `grille` frames to fill. +- **Swatches**: every material is a `material.*` component (family + `material`, binds none, `w=`/`h=`, `color=`, `scale=`) — for the sheet, + and for an agent that wants a patch of tolex or cloth. + +```sh +nix develop . -c cargo run --release -p frame-cli --features anyrender --bin kit_sheet -- --family material --fx gpu --scale 2 --out materials.png +nix develop . -c cargo run --release -p frame-cli --features anyrender --bin kit_sheet -- --only knob.render --fx gpu --scale 2 --out knobs.png +nix develop . -c cargo run --release -p frame-cli --features anyrender --bin kit_sheet -- --only knob.3d --fx gpu --scale 2 --out knobs3d.png --save-markup knobs3d.frame +# the ribs turn, the light stays: value 0 → 1 over five frames +nix develop . -c cargo run --release -p frame-cli --features anyrender --bin render_surface -- --markup knobs3d.frame --fx gpu --scale 2 --frames 5 --fps 1 --columns 5 --set-at 0:delay/mix=0 --set-at 1:delay/mix=0.25 --set-at 2:delay/mix=0.5 --set-at 3:delay/mix=0.75 --set-at 4:delay/mix=1 --out turn.png +nix develop . -c cargo run --release -p frame-cli --features anyrender --bin kit_sheet -- --only panel.pedal_3d --fx gpu --scale 2 --out pedals.png +nix develop . -c cargo run --release -p frame-cli --features anyrender --bin kit_sheet -- --only panel.amp_head,panel.amp_combo --fx gpu --scale 2 --out amps.png +``` +## Lettering + +Every renderer (vello, SVG export, frame-embed's outline text, caret +measurement) sets text from one font book, so a node's `font_family`, +`font_weight`, `font_style` (`"italic"`, `"Bold Italic"`, `"condensed"`) and +`font_width` (percent, 75 = condensed) — and each run's — are drawn as +written. Variable families are instanced on their axes (`wght`, `wdth`, +`ital`/`slnt`); a weight a static family lacks is emboldened and a missing +italic slanted. Unknown families and missing glyphs fall back to Inter. +**Never fake weight** with overlapping offset copies or glyph outlines: set +the weight, or pick a heavier family. + +Build lettered text with `draw::styled_text(…, &Lettering)` or +`draw::lettered(node, &Lettering)`; `Lettering::family(family::OSWALD) +.weight(700.0).italic().width(75.0)`. The bundled families (`draw::family::*`, +`render_fonts::BUNDLED`; markup `font="…"`): + +| family | what it suits | weights | +|---|---|---| +| `Inter` | UI sans (default): labels, values, legends | 100–900, italic | +| `Oswald` | condensed display sans: model names, badges, big legends | 200–700 | +| `Saira` | technical sans with a width axis (`width` 50–125): condensed/extended rack lettering | 100–900 | +| `Michroma` | wide engraved caps: brushed-metal panels, hi-fi faceplates | 400 | +| `Orbitron` | geometric tech caps: synth, modern-metal logos | 400–900 | +| `Dancing Script` | flowing script: signature logos, boutique badges | 400–700 | +| `Pacifico` | fat retro brush script: surf, funk, 50s logos | 400 | +| `Caveat Brush` | marker handwriting: scribbled labels, tape strips | 400 | +| `Knewave` | heavy brush marker: punk, funk, graffiti logos | 400 | +| `Playfair Display` | high-contrast serif: boutique and valve-era plates | 400–900 | +| `DSEG7 Classic` | 7-segment LCD digits: tuners, BPM, time | 300/400/700, italic | +| `DSEG14 Classic` | 14-segment alphanumeric LCD: preset names | 400/700, italic | +| `Doto` | dot matrix: VFD and LED-matrix displays | 100–900 | +| `VT323` | pixel terminal: CRT readouts | 400 | +| `JetBrains Mono` | monospace: readouts that must not jitter | 100–800 | +| `UnifrakturCook` | blackletter: metal and gothic logos | 700 | +| `Pirata One` | condensed gothic display: metal logos | 400 | + +A document can carry more as `font/ttf`/`font/otf` assets +(`Scene::add_asset`); renderers register them over the host's book, so a +shared surface brings its own lettering. Hosts extend their book with +`FontBook::register`. + +## Visualizers (`viz.*`) + +Graphs, not hardware: `viz.spectrum`, `viz.gr_history`, `viz.scope` +(one feed each, bound by `Place::binding`) and `viz.eq_graph`, `viz.comp`, +`viz.adsr`, which find their params by namespace (`options: ns=`) on a +fixed pattern — `surface_kit::viz::catalog` builds the params and feeds a +placed component expects, for engines and mock catalogs alike. + +- **Structure**: a panel; a *well* (the lit plot background); a *plot + area* frame holding the grid and the handles (unclipped, so a handle at + an edge shows whole); inside it a clipped *traces* frame holding one + area-sized frame per `Plot` / `Curve` control with its `Area` then + `Trace` vectors. Labels are text nodes printed round the area. +- **Look**: soft grid (`#ffffff0f`, majors `#ffffff21`), labels `#8e98a8` + at 9 px, a lit line (`draw::emissive`) over a gradient region; band + colours from `viz::BAND_COLORS`. +- **Handles**: a 44 px frame (the touch box) with the dot centred in it. + `design-lint` doesn't check handles for overlap (they move with their + values), and needs their parent to have a size (`handle-area`). +- **Curves** share one evaluation when their provider and inputs match — + give every band curve of an EQ all the bands' inputs and pick its own + with `series`. +## Motion + +Parts that move by themselves carry `Node::motion` (see +`docs/visualizer-framework.md` §9). `surface_kit::motion` has the presets — +`spring_follow(param)`, `spring_lag(input, target, travel)` (physics on a part +a control already moves), `ring_on(level_feed, freq, decay)`, +`spin(turns_per_second)`, `flicker(amount)`, `pulse(rate, low)` — and +`motion::add(b, node, m)`. In the kit: `meter.lamp` warms and cools through a +spring (a lag on its `Glow`), and `flicker` (an `options` flag on `meter.lamp` +and the VU faces with a lamp) makes a filament's light waver. Keep motion +optional in the same way as light: a component must read right at rest. +Anything always moving (flicker, LFOs) keeps hosts drawing, so make it opt-in. + a combined curve reads every item's inputs; a per-item curve (in a + `Repeat`) reads only its own. +- **Collections** (`viz.eq_graph`): items are a `Repeat` template over an + indexed family (`/b{n}_*`, shown while `b{n}_used`), not N copies. + The plot area is a `Canvas` (double-click adds, marquee, graph menu); + per-item editing is one context `Popover` and one context `Menu` + following the active item; names are text params edited in a + `TextField`. Floating panels don't use `emissive` (light composites over + them); the `glow` frame at the bottom of the traces is where a GPU glow + layer goes. See `docs/eq-parity.md`. + +### The EQ graph's look + +`viz.eq_graph` draws as signal's rig EQ (processor's `EqGraphWidget`): a +full-bleed plot on `#0a0a0a`; the `eq.glow` light first (the analyser as a +hue-swept field and a bloom per band, fed by the bound spectrum and the band +params, its span following `/range`) — its fallback is the analyser +layers, as processor draws them only without the glow; then processor's +grid (`eq.grid` for the dB lines), band curves at 25 % / 60 % of the band's +colour, stems from node to 0 dB, the gold sum, and node rings (a 3 px 8 % +ring 4 px out, a 40 % outline); frequency and dB labels and the range +picker where processor's DOM puts them. The band panel, chip and menus are +unchanged and paint after the light. Variant `panel` keeps the lit-panel +look (the glow in its `glow` frame under the traces). `no_range` hides the +range picker, as the rig shows none (Blitz draws its `

= (0..=12) + .map(|k| draw::polar(p.l + rw * 0.06, p.b, rw * 0.12, 90.0 * f64::from(k) / 12.0)) + .collect(); + ui::line(b, room, "door swing", &swing, 0.6, &ui::alpha(l.wall, 0.4)); +} + +/// The room in plan in `sheet` (`w × h`), filling it. +fn plan(b: &mut Builder<'_>, sheet: NodeId, (w, h): (f64, f64), l: &Look) { + // The sheet's grid. + let mut grid = Vec::new(); + let mut x = 6.0; + while x < w { + grid.push(vec![(x, 0.0), (x, h)]); + x += 14.0; + } + let mut y = 6.0; + while y < h { + grid.push(vec![(0.0, y), (w, y)]); + y += 14.0; + } + b.add( + sheet, + ui::strokes("grid", &grid, 0.5, draw::solid("#ffffff08")), + ); + let p = Plan::new(w, h); + let room = b.add(sheet, draw::frame("Room", 0.0, 0.0, w, h)); + wavefront(b, room, &p); + // The paths: second order under, fainter, up with the decay. + let second = b.add( + room, + ui::glowing( + ui::strokes( + "second order", + &p.second(), + 0.9, + draw::solid(&ui::alpha(RAY, 0.55)), + ), + RAY, + 0.45, + kit::GLOW, + ), + ); + motion::add( + b, + second, + Motion::new(Target::Opacity) + .input(format!( + "(0.1 + 0.8 * norm({VERB}/decay)) * (0.3 + 0.7 * param({VERB}/on))" + )) + .range(0.0, 1.0) + .dynamics(Dynamics::spring(200.0, 26.0)), + ); + let first = b.add( + room, + ui::glowing( + ui::strokes("first order", &p.first(), 1.3, draw::solid(RAY)), + RAY, + 1.1, + kit::GLOW, + ), + ); + // Its light comes up with the decay: a live room rings brighter. + motion::add( + b, + first, + Motion::new(Target::Opacity) + .input(format!( + "(0.35 + 0.65 * param({VERB}/on)) * (0.7 + 0.3 * norm({VERB}/decay))" + )) + .range(0.0, 1.0), + ); + ui::line( + b, + room, + "direct", + &[p.src, p.ear], + 0.9, + &ui::alpha(l.wall, 0.45), + ); + for path in p.first() { + let (x, y) = path[1]; + b.add( + room, + ui::glowing( + draw::circle("bounce", x, y, 2.2, draw::solid(RAY)), + RAY, + 1.3, + kit::GLOW, + ), + ); + } + walls(b, room, &p, l); + // The source and the listener. + // The drum sounds: it is the room's light source. + let drum = b.add( + room, + ui::glowing( + draw::circle( + "drum", + p.src.0, + p.src.1, + 6.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#f3ecff"), (1.0, "#8b6fd1")]), + ), + RAY, + 0.9, + kit::BLOOM, + ), + ); + motion::add( + b, + drum, + Motion::new(Target::Opacity) + .input(format!("0.55 + 0.45 * param({VERB}/on)")) + .range(0.0, 1.0), + ); + b.add( + room, + draw::stroked( + draw::circle("drum rim", p.src.0, p.src.1, 6.0, draw::solid("#00000000")), + "#1a1424", + 1.2, + ), + ); + ui::line( + b, + room, + "mic stand", + &[p.ear, (p.ear.0 + 9.0, p.ear.1 + 9.0)], + 1.2, + "#f3ecff", + ); + b.add( + room, + ui::glowing( + draw::circle("mic", p.ear.0, p.ear.1, 3.2, draw::solid("#f3ecff")), + RAY, + 1.0, + kit::GLOW, + ), + ); +} + +/// The tap diagram in `at` (x, y, w, h) on `sheet` for a plan `plan_wh`: +/// the direct sound and each reflection at its path's time, the +/// first-order taps bright, the second-order ones up with the decay; the +/// taps spread as the room grows. +fn taps( + b: &mut Builder<'_>, + sheet: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + plan_wh: (f64, f64), + l: &Look, +) { + let g = b.add(sheet, draw::frame("Taps", x, y, w, h)); + let base = h - 14.0; + ui::line( + b, + g, + "axis", + &[(0.0, base), (w, base)], + 0.8, + &ui::alpha(l.line, 0.8), + ); + for k in 0..=4 { + let tx = w * f64::from(k) / 4.0; + ui::line( + b, + g, + "tick", + &[(tx, base), (tx, base + 3.0)], + 0.8, + &ui::alpha(l.line, 0.8), + ); + } + ui::silk_left(b, g, "Early reflections", 0.0, 8.0, 5.5, l.wall, 800.0); + ui::silk_left(b, g, "0", 0.0, base + 9.0, 5.0, l.line, 700.0); + ui::silk_right(b, g, "80 ms", w, base + 9.0, 5.0, l.line, 700.0); + let p = Plan::new(plan_wh.0, plan_wh.1); + let direct = length(&[p.src, p.ear]); + let longest = p.second().iter().map(|q| length(q)).fold(direct, f64::max) * 1.05; + let mut all: Vec<(f64, f64, bool)> = vec![(direct, 1.0, true)]; + all.extend(p.first().iter().map(|q| (length(q), 0.78, true))); + all.extend(p.second().iter().map(|q| (length(q), 0.5, false))); + for (i, (len, hgt, bright)) in all.iter().enumerate() { + let tall = (base - 16.0) * hgt * (0.9 + 0.1 * crate::reverb::jitter(i as f64)); + let fill = if *bright { + RAY.to_string() + } else { + ui::alpha(RAY, 0.7) + }; + let bar = b.add(g, draw::frame("tap", -1.0, base - tall, 2.4, tall)); + let line = draw::rect("line", 0.0, 0.0, 2.4, tall, draw::solid(&fill)); + b.add( + bar, + if *bright { + ui::glowing(line, RAY, 1.0, kit::GLOW) + } else { + line + }, + ); + // The room's size stretches the times (the bars keep their width). + let xpos = format!( + "{:.2} * (0.66 + 0.34 * norm({VERB}/size))", + len / longest * w + ); + motion::add( + b, + bar, + Motion::new(Target::X) + .input(xpos) + .range(0.0, 1.0) + .dynamics(Dynamics::spring(160.0, 24.0)), + ); + if !*bright { + motion::add( + b, + bar, + Motion::new(Target::StretchY) + .input(format!("0.1 + 0.9 * norm({VERB}/decay)")) + .range(0.0, 1.0) + .pivot(0.5, 1.0) + .dynamics(Dynamics::spring(200.0, 26.0)), + ); + } + } +} + +/// The picture in `at` (x, y, w, h) on `panel`: the sheet, the plan and, +/// when it is wide, the taps beside it, from `taps_top` down (below a +/// nameplate over the sheet's top right). Returns the sheet. +fn picture( + b: &mut Builder<'_>, + panel: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + taps_top: f64, + l: &Look, +) -> NodeId { + let mut sheet = draw::frame("Sheet", x, y, w, h); + sheet.fills = vec![draw::linear(180.0, &l.sheet)]; + sheet.clips_content = true; + sheet.corner_radii = frame::document::proto::CornerRadii::uniform(6.0); + sheet.effects = vec![draw::inner_shadow("#000000c0", 0.0, 2.0, 7.0)]; + let sheet = b.add(panel, sheet); + let wide = w > 360.0; + let pw = if wide { (h * 1.9).min(w * 0.55) } else { w }; + let room = b.add(sheet, draw::frame("Plan", 0.0, 0.0, pw, h)); + plan(b, room, (pw, h), l); + if wide { + taps( + b, + sheet, + (pw + 16.0, taps_top, w - pw - 30.0, h - taps_top - 6.0), + (pw, h), + l, + ); + } + ui::silk_left(b, sheet, "Live room · plan", 8.0, 9.0, 5.5, l.wall, 800.0); + kit::glass(b, sheet); + sheet +} + +/// The panel, `w` × `h`: ply (or felt) with its grain. +fn ground(b: &mut Builder<'_>, root: NodeId, (w, h): (f64, f64), l: &Look) -> NodeId { + let mut panel = draw::frame("Panel", 0.0, 0.0, w, h); + panel.fills = vec![draw::linear(180.0, &l.panel)]; + panel.corner_radii = frame::document::proto::CornerRadii::uniform(8.0); + let panel = b.add( + root, + draw::with_effects( + panel, + vec![ + draw::inner_shadow("#ffffff30", 0.0, 1.0, 0.0), + draw::inner_shadow("#00000050", 0.0, -2.0, 3.0), + ], + ), + ); + let rows = (h / 3.0).floor(); + let mut grain = Vec::new(); + let mut i = 0.0; + while i < rows { + let y = 1.0 + i * 3.0 + crate::reverb::jitter(i) * 0.8; + grain.push(vec![ + (0.0, y), + (w, y + crate::reverb::jitter(i * 1.7) * 2.0), + ]); + i += 1.0; + } + b.add( + panel, + ui::strokes("grain", &grain, 0.8, draw::solid(l.grain)), + ); + panel +} + +/// The nameplate's title. +fn title(b: &mut Builder<'_>, t: NodeId, h: f64, size: f64, ink: &str) { + let sans = draw::Lettering::family(draw::family::INTER).weight(300.0); + crate::suite_studio_blues::parts::set(b, t, NAME, 30.0, h / 2.0, size, ink, &sans, 0.02); +} + +#[must_use] +pub fn face(dark: bool) -> Face { + face_at(dark, kit::Tier::Desktop) +} + +/// The face at `tier`: the sheet beside the controls. On a phone the sheet +/// is the plan alone and the knobs are Size and Level. +#[must_use] +pub fn face_at(dark: bool, tier: kit::Tier) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(NAME, tier, |b, root| { + let panel = ground(b, root, (w, kit::H), l); + let sw = if small_room { + kit::PHONE_PICTURE_R - 10.0 + } else { + 560.0 + }; + let sheet = picture(b, panel, (10.0, 10.0, sw, kit::H - 20.0), 12.0, l); + let lit = ui::alpha(kit::PURPLE_LAMP, 0.55); + if small_room { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (6.0, 30.0), + 84.0, + 220.0, + 12.0, + (l.accent, l.dim), + |b, t| title(b, t, 30.0, 22.0, l.ink), + ); + let (tx, ty, td) = kit::PHONE_TIME; + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 11.0, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + let (xs, ky, kd) = kit::PHONE_KNOBS; + for (i, (id, legend)) in [("size", "Size"), ("level", "Level")].iter().enumerate() { + kit::knob3d( + b, + panel, + &a(id), + legend, + kd, + (xs[i], ky), + &l.knob, + l.ink, + l.dim, + ); + } + } else { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (10.0, 28.0), + 80.0, + 200.0, + 10.0, + (l.accent, l.dim), + |b, t| title(b, t, 28.0, 20.0, l.ink), + ); + let x0 = 10.0 + sw + 20.0; + let (tx, ty, td) = (x0 + 40.0, 78.0, 52.0); + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 8.5, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + for (i, (id, legend)) in [ + ("size", "Size"), + ("predelay", "Pre-Delay"), + ("level", "Level"), + ] + .iter() + .enumerate() + { + kit::knob3d( + b, + panel, + &a(id), + legend, + 34.0, + (x0 + 116.0 + 60.0 * i as f64, 90.0), + &l.knob, + l.ink, + l.dim, + ); + } + } + kit::face_bypass(b, panel, VERB, tier, "purple", "On"); + kit::flip_arrow(b, panel, VERB, tier, false, (l.ink, &ui::alpha(l.dim, 0.3))); + kit::responsive(b, panel, &[sheet]); + }); + let mut f = kit::reverb_face(kit::tier_file("80-verb-room", tier, dark), scene); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::time_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its back at `tier`: the panel, no sheet — the advanced controls across +/// it, and (standing alone) the nameplate, the bypass and the arrow. +#[must_use] +pub fn back_at(dark: bool, tier: kit::Tier) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(NAME, tier, |b, root| { + let panel = ground(b, root, (w, kit::H), l); + kit::back_knobs( + b, + panel, + tier, + &kit::VERB_MORE, + l.ink, + |b, p, id, legend, d, at| { + kit::knob3d(b, p, &a(id), legend, d, at, &l.knob, l.ink, l.dim); + }, + ); + let (np, title_w, preset_w, size, ts) = if small_room { + ((6.0, 30.0), 84.0, 220.0, 12.0, 22.0) + } else { + ((10.0, 28.0), 80.0, 200.0, 10.0, 20.0) + }; + kit::head(b, panel, VERB, true, |b, head| { + kit::nameplate_top_right( + b, + head, + VERB, + tier, + np, + title_w, + preset_w, + size, + (l.accent, l.dim), + |b, t| title(b, t, np.1, ts, l.ink), + ); + kit::face_bypass(b, head, VERB, tier, "purple", "On"); + }); + kit::flip_arrow(b, panel, VERB, tier, true, (l.ink, &ui::alpha(l.dim, 0.3))); + }); + let mut f = kit::reverb_face(kit::tier_file("80-verb-room-back", tier, dark), scene); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its tall face: the sheet — the plan and the taps under the nameplate — +/// across the top, shorter than half so the controls below it get big +/// knobs. +#[must_use] +pub fn tall(dark: bool) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let scene = kit::tall_lane(NAME, |b, root| { + let panel = ground(b, root, (kit::TALL_W, kit::TALL_H), l); + picture( + b, + panel, + (10.0, 10.0, kit::TALL_W - 20.0, ROWS.split - 16.0), + 50.0, + l, + ); + let t = kit::Tall { + ns: VERB, + main: &[ + ("size", "Size"), + ("predelay", "Pre-Delay"), + ("damping", "HF Damp"), + ("level", "Level"), + ], + inks: (l.ink, l.dim, &ui::alpha(kit::PURPLE_LAMP, 0.55), l.accent), + scrim: l.scrim, + bypass: ("purple", "On"), + title_w: 84.0, + }; + kit::tall_chrome_rows( + b, + panel, + &t, + ROWS, + |b, p, addr, legend, d, at| { + kit::knob3d(b, p, addr, legend, d, at, &l.knob, l.ink, l.dim); + }, + |b, p, addr, (cx, cy, d)| { + kit::knob3d_bare(b, p, addr, d, (cx, cy), &l.wheel); + }, + |b, tn| title(b, tn, 30.0, 22.0, l.ink), + ); + }); + let mut f = kit::reverb_face(kit::tall_file("80-verb-room", dark), scene); + f.params.extend(kit::tall_params(VERB)); + f +} diff --git a/examples/plugin-faces/src/rig_time/reverb/shell.rs b/examples/plugin-faces/src/rig_time/reverb/shell.rs new file mode 100644 index 00000000..e5b4ee7e --- /dev/null +++ b/examples/plugin-faces/src/rig_time/reverb/shell.rs @@ -0,0 +1,357 @@ +//! The layouts Swell, Reflections, Velvet, FreeVerb and Convolution share: +//! each draws its own ground, picture and lettering; this lays them out the +//! way Room lays out its own — the lane at its tiers (the picture at the +//! left, the brass-free Time knob and three knobs beside it, the nameplate, +//! bypass and More), the back (the advanced knobs), and the tall face (the +//! picture across a shorter top row, big knobs under it). +//! +//! Every picture carries a handle on `verb/decay`, drawn as the device's +//! own (a tail tab, a range gate, a slide pot, a trim flag), so the decay +//! is in the hand on the picture as well as on the Time knob. + +use frame::document::proto::NodeId; +use frame::surface::kit::draw; +use frame::surface::proto::{Axis, Control, ControlKind, HandleAxis}; +use frame::surface::widgets::Builder; + +use crate::face::Face; +use crate::reverb::ui; +use crate::rig_time::kit::{self, VERB}; + +/// The room a picture is drawn for: a phone's narrow well, a lane's, or a +/// tall face's wide, short one (whose top right the nameplate covers). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Room { + Phone, + Lane, + Tall, +} + +/// A finish's inks and knobs. +pub struct Look { + pub ink: &'static str, + pub dim: &'static str, + /// The nameplate's accent. + pub accent: &'static str, + /// The tall face's nameplate scrim. + pub scrim: &'static str, + /// The small knobs. + pub knob: [&'static str; 6], + /// The Time knob. + pub wheel: [&'static str; 6], +} + +/// A device: its name and file, its knobs, and how it draws itself. +pub struct Unit { + pub name: &'static str, + /// Its file's base (`90-verb-swell`). + pub base: &'static str, + pub title_w: f64, + /// Its bypass key's legend. + pub legend: &'static str, + /// The lane's three knobs beside the Time knob (the first and Level on + /// a phone). + pub lane: [(&'static str, &'static str); 3], + /// The tall face's first line. + pub tall: [(&'static str, &'static str); 4], + pub look: fn(bool) -> &'static Look, + /// The panel, `w × h` on the root: returns it. + pub ground: fn(&mut Builder<'_>, NodeId, (f64, f64), bool) -> NodeId, + /// The picture in (x, y, w, h) on the panel: returns what takes a + /// change of width. + pub picture: fn(&mut Builder<'_>, NodeId, (f64, f64, f64, f64), Room, bool) -> Vec, + /// The nameplate's title in its box, `h` tall, at `size`. + pub title: fn(&mut Builder<'_>, NodeId, f64, f64, bool), +} + +/// The tall faces' rows: a shorter picture, bigger knobs under it. +pub const ROWS: kit::TallRows = kit::TallRows { + split: 134.0, + main: (30.0, 50.0), + more: (126.0, 40.0), +}; + +fn a(id: &str) -> String { + format!("{VERB}/{id}") +} + +/// A well for a picture in (x, y, w, h) on `panel`: clipped, rounded, +/// shaded in, filled top to bottom with `fill`. The caller draws into it +/// and puts the glass over it last. +pub fn well( + b: &mut Builder<'_>, + panel: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + fill: &[(f64, &str)], + r: f64, +) -> NodeId { + let mut s = draw::frame(name, x, y, w, h); + s.fills = vec![draw::linear(180.0, fill)]; + s.clips_content = true; + s.corner_radii = frame::document::proto::CornerRadii::uniform(r); + s.effects = vec![draw::inner_shadow("#000000b0", 0.0, 2.0, 7.0)]; + b.add(panel, s) +} + +/// A handle on `verb/decay` riding along x in `parent`: its centre travels +/// from `x0` to `x1` at `y` (its top), `hw × hh`; `build` draws it in its +/// own box. Returns the travel (the frame to stretch with the picture). +pub fn decay_handle( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x0, x1, y): (f64, f64, f64), + (hw, hh): (f64, f64), + build: impl FnOnce(&mut Builder<'_>, NodeId), +) -> NodeId { + let travel = b.add( + parent, + draw::frame(&format!("{name} travel"), x0, y, x1 - x0, hh), + ); + let h = b.add(travel, draw::frame(name, 0.0, 0.0, hw, hh)); + build(b, h); + let kind = ControlKind::Handle { + x: Some(ui::bind(&a("decay"))), + y: None, + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + }; + b.controls + .insert(h, Control::new(kind).bound(ui::bind(&a("decay")))); + if let Some(n) = b.scene.node_mut(travel) { + n.layout_child.resize_x = Some(frame::document::proto::ResizeX::Stretch); + } + travel +} + +/// The lane face at `tier`. +#[must_use] +pub fn face_at(u: &Unit, dark: bool, tier: kit::Tier) -> Face { + let l = (u.look)(dark); + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(u.name, tier, |b, root| { + let panel = (u.ground)(b, root, (w, kit::H), dark); + let pw = if small_room { + kit::PHONE_PICTURE_R - 10.0 + } else { + 560.0 + }; + let stretch = (u.picture)( + b, + panel, + (10.0, 10.0, pw, kit::H - 20.0), + if small_room { Room::Phone } else { Room::Lane }, + dark, + ); + let lit = ui::alpha(kit::PURPLE_LAMP, 0.55); + if small_room { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (6.0, 30.0), + u.title_w + 4.0, + 220.0, + 12.0, + (l.accent, l.dim), + |b, t| (u.title)(b, t, 30.0, 22.0, dark), + ); + let (tx, ty, td) = kit::PHONE_TIME; + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 11.0, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + let (xs, ky, kd) = kit::PHONE_KNOBS; + for (i, (id, legend)) in [u.lane[0], ("level", "Level")].iter().enumerate() { + kit::knob3d( + b, + panel, + &a(id), + legend, + kd, + (xs[i], ky), + &l.knob, + l.ink, + l.dim, + ); + } + } else { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (10.0, 28.0), + u.title_w, + 200.0, + 10.0, + (l.accent, l.dim), + |b, t| (u.title)(b, t, 28.0, 20.0, dark), + ); + let x0 = 10.0 + pw + 20.0; + let (tx, ty, td) = (x0 + 40.0, 78.0, 52.0); + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 8.5, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + for (i, (id, legend)) in u.lane.iter().enumerate() { + kit::knob3d( + b, + panel, + &a(id), + legend, + 34.0, + (x0 + 116.0 + 60.0 * i as f64, 90.0), + &l.knob, + l.ink, + l.dim, + ); + } + } + kit::face_bypass(b, panel, VERB, tier, "purple", u.legend); + kit::flip_arrow(b, panel, VERB, tier, false, (l.ink, &ui::alpha(l.dim, 0.3))); + kit::responsive(b, panel, &stretch); + }); + let mut f = kit::reverb_face(kit::tier_file(u.base, tier, dark), scene); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::time_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its back at `tier`: the ground, no picture — the advanced controls +/// across it, and (standing alone) the nameplate, the bypass and the arrow. +#[must_use] +pub fn back_at(u: &Unit, dark: bool, tier: kit::Tier) -> Face { + let l = (u.look)(dark); + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(u.name, tier, |b, root| { + let panel = (u.ground)(b, root, (w, kit::H), dark); + kit::back_knobs( + b, + panel, + tier, + &kit::VERB_MORE, + l.ink, + |b, p, id, legend, d, at| { + kit::knob3d(b, p, &a(id), legend, d, at, &l.knob, l.ink, l.dim); + }, + ); + let (np, title_w, preset_w, size, ts) = if small_room { + ((6.0, 30.0), u.title_w + 4.0, 220.0, 12.0, 22.0) + } else { + ((10.0, 28.0), u.title_w, 200.0, 10.0, 20.0) + }; + kit::head(b, panel, VERB, true, |b, head| { + kit::nameplate_top_right( + b, + head, + VERB, + tier, + np, + title_w, + preset_w, + size, + (l.accent, l.dim), + |b, t| (u.title)(b, t, np.1, ts, dark), + ); + kit::face_bypass(b, head, VERB, tier, "purple", u.legend); + }); + kit::flip_arrow(b, panel, VERB, tier, true, (l.ink, &ui::alpha(l.dim, 0.3))); + }); + let mut f = kit::reverb_face( + kit::tier_file( + Box::leak(format!("{}-back", u.base).into_boxed_str()), + tier, + dark, + ), + scene, + ); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its tall face: the picture across a top row shorter than half, so the +/// knobs under it are big. +#[must_use] +pub fn tall(u: &Unit, dark: bool) -> Face { + let l = (u.look)(dark); + let scene = kit::tall_lane(u.name, |b, root| { + let panel = (u.ground)(b, root, (kit::TALL_W, kit::TALL_H), dark); + (u.picture)( + b, + panel, + (10.0, 10.0, kit::TALL_W - 20.0, ROWS.split - 16.0), + Room::Tall, + dark, + ); + let t = kit::Tall { + ns: VERB, + main: &u.tall, + inks: (l.ink, l.dim, &ui::alpha(kit::PURPLE_LAMP, 0.55), l.accent), + scrim: l.scrim, + bypass: ("purple", u.legend), + title_w: u.title_w + 4.0, + }; + kit::tall_chrome_rows( + b, + panel, + &t, + ROWS, + |b, p, addr, legend, d, at| { + kit::knob3d(b, p, addr, legend, d, at, &l.knob, l.ink, l.dim); + }, + |b, p, addr, (cx, cy, d)| { + kit::knob3d_bare(b, p, addr, d, (cx, cy), &l.wheel); + }, + |b, tn| (u.title)(b, tn, 30.0, 22.0, dark), + ); + }); + let mut f = kit::reverb_face(kit::tall_file(u.base, dark), scene); + f.params.extend(kit::tall_params(VERB)); + f +} + +/// A panel `w × h` on `root` filled with `fill`, lit along its top edge and +/// shaded along its bottom. Returns it. +pub fn panel( + b: &mut Builder<'_>, + root: NodeId, + (w, h): (f64, f64), + fill: &[(f64, &str)], + r: f64, +) -> NodeId { + let mut p = draw::frame("Panel", 0.0, 0.0, w, h); + p.fills = vec![draw::linear(180.0, fill)]; + p.corner_radii = frame::document::proto::CornerRadii::uniform(r); + b.add( + root, + draw::with_effects( + p, + vec![ + draw::inner_shadow("#ffffff2a", 0.0, 1.0, 0.0), + draw::inner_shadow("#00000050", 0.0, -2.0, 3.0), + ], + ), + ) +} diff --git a/examples/plugin-faces/src/rig_time/reverb/shimmer.rs b/examples/plugin-faces/src/rig_time/reverb/shimmer.rs new file mode 100644 index 00000000..ee503642 --- /dev/null +++ b/examples/plugin-faces/src/rig_time/reverb/shimmer.rs @@ -0,0 +1,809 @@ +//! Shimmer: a reverb whose tail is fed back through a pitch shifter, so +//! every pass round the loop comes back an interval higher (or lower) — +//! the note, then its octave, then the octave above that, a staircase of +//! light climbing out of the sound. Drawn as that cascade: time across, +//! pitch up, each pass a streak of light one step from the last. The step +//! is the shift (a fifth, an octave, or down), each pass fainter by the +//! shimmer amount; the longer the decay the further apart in time they +//! fall and the longer each one rings. A glint climbs the stairs while it +//! plays. +//! +//! The head of the cascade is a crystal you can move: up and down for +//! the shift, out for the decay. +//! +//! Two finishes: light, frosted pearl with an iridescent edge; dark, +//! midnight. + +use frame::document::proto::NodeId; +use frame::document::proto::motion::{Dynamics, Motion, Target}; +use frame::surface::kit::{draw, motion}; +use frame::surface::proto::{Axis, Control, ControlKind, HandleAxis}; +use frame::surface::widgets::Builder; + +use crate::face::Face; +use crate::reverb::ui; +use crate::rig_time::kit::{self, VERB}; + +const NAME: &str = "Shimmer"; +const FILE: &str = "86-verb-shimmer"; +const BACK: &str = "86-verb-shimmer-back"; +/// The tall face's rows: the picture a little under half, big knobs below. +const ROWS: kit::TallRows = kit::TallRows { + split: 134.0, + main: (30.0, 50.0), + more: (126.0, 40.0), +}; +/// The cascade's light: the section's purple, whitening as it climbs. +const LIGHT_0: &str = kit::PURPLE_LAMP; +const ICE: &str = "#eef0ff"; +/// Passes drawn: the note and five returns. +const PASSES: usize = 6; +/// The front knobs, in order. +const FRONT: [(&str, &str); 4] = [ + ("shim_amount", "Shimmer"), + ("shim_shift1", "Shift"), + ("size", "Size"), + ("level", "Level"), +]; + +/// One finish's colours. +struct Look { + ink: &'static str, + dim: &'static str, + panel: [(f64, &'static str); 3], + edge: [(f64, &'static str); 4], + well: [(f64, &'static str); 2], + knob: [&'static str; 6], + wheel: [&'static str; 6], + accent: &'static str, + scrim: &'static str, +} + +const LIGHT: Look = Look { + ink: "#1e2433", + dim: "#667086", + panel: [(0.0, "#f2f4f8"), (0.55, "#e4e8ef"), (1.0, "#d3d9e3")], + edge: [ + (0.0, "#f3c4e4"), + (0.35, "#c4d8f8"), + (0.7, "#cfc2ff"), + (1.0, "#bfeff0"), + ], + well: [(0.0, "#121a33"), (1.0, "#090e1f")], + knob: [ + "color=#f6f7fa", + "pointer=#3b1d6e", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#aab3c2", + ], + wheel: [ + "color=#f6f7fa", + "pointer=#3b1d6e", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#8f99aa", + ], + accent: "#6a3fc4", + scrim: "#e4e8efe6", +}; + +const DARK: Look = Look { + ink: "#e6e9f2", + dim: "#8b92a6", + panel: [(0.0, "#1a1f2e"), (0.55, "#131725"), (1.0, "#0c0f19")], + edge: [ + (0.0, "#f3c4e455"), + (0.35, "#c4d8f855"), + (0.7, "#cfc2ff55"), + (1.0, "#bfeff055"), + ], + well: [(0.0, "#0b1022"), (1.0, "#05070f")], + knob: [ + "color=#252c40", + "pointer=#d9c8ff", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#0b0e18", + ], + wheel: [ + "color=#20263a", + "pointer=#d9c8ff", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#090c15", + ], + accent: kit::PURPLE_LAMP, + scrim: "#131725e6", +}; + +fn a(id: &str) -> String { + format!("{VERB}/{id}") +} + +/// The cascade's geometry in a picture `w` × `h` whose band starts at +/// `top`: where the note starts, the pass spacing at no decay and at full, +/// the band's middle, and how far a pass climbs at a full octave (`a` the +/// note's own offset, `b` each pass's step). +#[derive(Clone, Copy)] +struct Stairs { + x0: f64, + s_min: f64, + s_max: f64, + mid: f64, + a: f64, + b: f64, +} + +impl Stairs { + fn new(w: f64, h: f64, top: f64) -> Self { + let x0 = if w < 300.0 { 18.0 } else { 34.0 }; + let s_max = (w - x0 - 14.0) / (PASSES as f64 - 1.0 + 1.7); + let (t, bot) = (top.max(14.0), h - 14.0); + let band = bot - t; + Self { + x0, + s_min: s_max * 0.45, + s_max, + mid: (t + bot) / 2.0, + a: band * 0.5, + b: band * 0.2, + } + } + + /// Pass `k`'s height now (an expression, picture coordinates). + fn y(&self, k: f64) -> String { + format!( + "({:.2} + param({VERB}/shim_shift1) / 12 * {:.2})", + self.mid, + self.a - k * self.b + ) + } + + /// How far the passes spread with the decay (a factor on `s_min`). + fn spread(&self) -> String { + format!( + "(1 + {:.3} * norm({VERB}/decay))", + self.s_max / self.s_min - 1.0 + ) + } +} + +/// The pitch step between two passes as a stroke from (0, `y0`) to +/// (`dx`, `y0 - dy`), an arrowhead at its end. +fn step_path(dx: f64, y0: f64, dy: f64) -> String { + let mut d = String::new(); + for i in 0..=12 { + let t = f64::from(i) / 12.0; + // A quadratic from the start up to the end, leaving flat. + let (cx, cy) = (dx * 0.15, y0 - dy); + let x = (1.0 - t).powi(2) * 2.0 + 2.0 * (1.0 - t) * t * cx + t * t * dx; + let y = (1.0 - t).powi(2) * y0 + 2.0 * (1.0 - t) * t * cy + t * t * (y0 - dy); + d.push_str(&format!( + "{} {x:.2} {y:.2} ", + if i == 0 { "M" } else { "L" } + )); + } + let (ex, ey) = (dx, y0 - dy); + d.push_str(&format!( + "M {:.2} {:.2} L {ex:.2} {ey:.2} L {:.2} {:.2}", + ex - 4.0, + ey - 2.5, + ex - 4.0, + ey + 2.5 + )); + d +} + +/// The cascade in `well` (`w` × `h`) from `top` down. +#[allow(clippy::too_many_lines)] +fn cascade(b: &mut Builder<'_>, well: NodeId, (w, h): (f64, f64), top: f64) { + let s = Stairs::new(w, h, top); + let compact = w < 300.0; + // The grid: octaves across, passes down. + let mut grid = Vec::new(); + for i in -2..=2 { + let y = s.mid + f64::from(i) * s.b * 1.25; + grid.push(vec![(s.x0 - 6.0, y), (w - 8.0, y)]); + } + for k in 0..PASSES { + let x = s.x0 + k as f64 * s.s_max; + grid.push(vec![(x, 8.0), (x, h - 8.0)]); + } + b.add( + well, + ui::strokes("grid", &grid, 0.5, draw::solid("#c4d0ff12")), + ); + // The pitch axis. + ui::line( + b, + well, + "pitch axis", + &[(s.x0 - 10.0, h - 10.0), (s.x0 - 10.0, top.max(14.0) - 2.0)], + 0.8, + "#c4d0ff55", + ); + ui::line( + b, + well, + "arrow", + &[ + (s.x0 - 13.0, top.max(14.0) + 2.0), + (s.x0 - 10.0, top.max(14.0) - 2.0), + (s.x0 - 7.0, top.max(14.0) + 2.0), + ], + 0.8, + "#c4d0ff55", + ); + if !compact { + ui::silk_left( + b, + well, + "Each pass re-pitched", + s.x0 - 4.0, + top.max(14.0) - 4.0, + 5.5, + "#c9d2f0", + 800.0, + ); + } + + let spread = s.spread(); + let lh = 12.0; + let base_len = s.s_min * 1.7; + for k in 0..PASSES { + let f = k as f64; + let t = f / (PASSES as f64 - 1.0); + let color = ui::blend(LIGHT_0, ICE, t * 0.7); + let g = b.add( + well, + draw::frame(&format!("pass {k}"), s.x0 + f * s.s_min, 0.0, base_len, lh), + ); + motion::add( + b, + g, + Motion::new(Target::X) + .input(format!( + "{:.2} * norm({VERB}/decay)", + f * (s.s_max - s.s_min) + )) + .range(0.0, 1.0) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + motion::add( + b, + g, + Motion::new(Target::Y) + .input(format!("{} - {:.1}", s.y(f), lh / 2.0)) + .range(0.0, 1.0) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + let fade = if k == 0 { + format!("0.45 + 0.55 * param({VERB}/on)") + } else { + format!( + "(0.05 + 0.95 * pow(norm({VERB}/shim_amount), {:.2}) * {:.2}) * (0.35 + 0.65 * param({VERB}/on))", + 0.4 * f, + 0.92_f64.powi(k as i32) + ) + }; + motion::add( + b, + g, + Motion::new(Target::Opacity).input(fade).range(0.0, 1.0), + ); + // What stretches with the decay: the streak and the step to the + // next pass. + let st = b.add(g, draw::frame("stretch", 0.0, 0.0, base_len, lh)); + motion::add( + b, + st, + Motion::new(Target::StretchX) + .input(spread.clone()) + .range(0.0, 1.0) + .pivot(0.0, 0.5) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + // The pass: a streak, bright where it starts, ringing out. + let streak = draw::rounded( + draw::rect( + "streak", + 0.0, + lh / 2.0 - 2.0, + base_len, + 4.0, + draw::linear( + 90.0, + &[ + (0.0, "#ffffff"), + (0.12, &color), + (0.6, &ui::alpha(&color, 0.6)), + (1.0, &ui::alpha(&color, 0.0)), + ], + ), + ), + 2.0, + ); + b.add(st, ui::glowing(streak, &color, 1.3, kit::GLOW)); + // Its sparkle: points of light along it, twinkling. + let mut dots = String::new(); + for j in 0..7 { + let u = + 0.12 + 0.11 * f64::from(j) + 0.03 * crate::reverb::jitter(f * 7.0 + f64::from(j)); + let dy = 4.5 * crate::reverb::jitter(f * 3.1 + f64::from(j) * 1.7); + dots.push_str(&draw::regular(base_len * u, lh / 2.0 + dy, 0.9, 6, 0.0)); + dots.push(' '); + } + let sparkle = b.add( + st, + ui::glowing( + ui::area("sparkle", dots, base_len, lh, draw::solid(ICE)), + &color, + 1.0, + kit::GLOW, + ), + ); + motion::add( + b, + sparkle, + Motion::new(Target::Opacity) + .input(format!( + "0.3 + 0.7 * pow(0.5 + 0.5 * sin(time * {:.1} + {f}), 3)", + 2.3 + 0.7 * f + )) + .range(0.0, 1.0), + ); + // Its partials, faint above it. + let ghost = draw::rect( + "partial", + 4.0, + lh / 2.0 - 5.0, + base_len * 0.7, + 1.0, + draw::linear( + 90.0, + &[ + (0.0, &ui::alpha(&color, 0.6)), + (1.0, &ui::alpha(&color, 0.0)), + ], + ), + ); + b.add(st, ghost); + if k + 1 < PASSES { + // The step up to the next pass, shaped by the shift: drawn at + // −12, 0 and +12 and blended between. + let span = s.b + 3.0; + let mut n = ui::strokes( + "step", + &[vec![(0.0, 0.0), (s.s_min, 2.0 * span)]], + 0.9, + draw::solid(&ui::alpha(ICE, 0.7)), + ); + n.transform = frame::document::proto::Transform::translate(0.0, lh / 2.0 - span); + n.size = frame::document::proto::Size { + width: s.s_min, + height: 2.0 * span, + }; + let shapes: Vec = [-12.0, 0.0, 12.0] + .iter() + .map(|sh: &f64| step_path(s.s_min, span, sh / 12.0 * s.b)) + .collect(); + n.fill_geometry = vec![shapes[2].clone()]; + let n = b.add(st, ui::glowing(n, LIGHT_0, 0.5, kit::GLOW)); + motion::add( + b, + n, + Motion::new(Target::Morph { shapes }) + .input(format!("(param({VERB}/shim_shift1) + 12) / 12")) + .range(0.0, 1.0), + ); + } + } + // The glints: up the stairs, again and again while it plays. + for (i, lag) in [0.0, 0.5].iter().enumerate() { + let c = format!("fract(time * 0.32 + {lag})"); + let span = (PASSES as f64 - 1.0) * s.s_min; + let glint = draw::path( + "glint", + -5.0, + -5.0, + 10.0, + 10.0, + "M 5 0 L 6 4 L 10 5 L 6 6 L 5 10 L 4 6 L 0 5 L 4 4 Z".into(), + draw::solid(ICE), + ); + let glint = b.add(well, ui::glowing(glint, LIGHT_0, 1.4, kit::GLOW)); + let x = format!("{:.2} + {c} * {span:.2} * {spread}", s.x0); + let y = format!( + "{:.2} + param({VERB}/shim_shift1) / 12 * ({:.2} - {c} * {:.2})", + s.mid, + s.a, + (PASSES as f64 - 1.0) * s.b + ); + motion::add(b, glint, Motion::new(Target::X).input(x).range(0.0, 1.0)); + motion::add(b, glint, Motion::new(Target::Y).input(y).range(0.0, 1.0)); + let fade = format!( + "param({VERB}/on) * sin({c} * 3.1416) * (0.3 + 0.7 * norm({VERB}/shim_amount))" + ); + motion::add( + b, + glint, + Motion::new(Target::Opacity).input(fade).range(0.0, 1.0), + ); + motion::add( + b, + glint, + Motion::new(Target::Rotate) + .input(c.clone()) + .range(0.0, 90.0 + 30.0 * i as f64) + .pivot(0.5, 0.5), + ); + } + + // The crystal at the head of the cascade: a handle on the shift (up, + // down) and the decay (out). + let last = PASSES as f64 - 1.0; + let (x0, x1) = (s.x0 + last * s.s_min, s.x0 + last * s.s_max); + let (y0, y1) = (s.mid - s.a + last * s.b, s.mid + s.a - last * s.b); + let (ya, yb) = (y0.min(y1), y0.max(y1)); + let area = b.add( + well, + draw::frame("crystal travel", x0, ya, x1 - x0, yb - ya), + ); + let hs = 22.0; + let g = b.add( + area, + draw::frame( + "Crystal", + 0.4 * (x1 - x0) - hs / 2.0, + 0.0 - hs / 2.0, + hs, + hs, + ), + ); + let c = hs / 2.0; + let mut ring = draw::circle("ring", c, c, 8.5, draw::solid("#00000000")); + ring.fills.clear(); + b.add(g, draw::stroked(ring, &ui::alpha(ICE, 0.55), 0.9)); + let gem = draw::path( + "gem", + c - 5.0, + c - 7.0, + 10.0, + 14.0, + "M 5 0 L 10 6 L 5 14 L 0 6 Z".into(), + draw::linear(160.0, &[(0.0, "#ffffff"), (0.5, "#d9ccff"), (1.0, LIGHT_0)]), + ); + b.add(g, ui::glowing(gem, LIGHT_0, 1.2, kit::BLOOM)); + ui::line( + b, + g, + "facet", + &[(c - 5.0, c - 1.0), (c + 5.0, c - 1.0)], + 0.6, + "#ffffffaa", + ); + let kind = ControlKind::Handle { + x: Some(ui::bind(&a("decay"))), + y: Some(ui::bind(&a("shim_shift1"))), + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { + min: -12.0, + max: 12.0, + }), + }; + b.controls + .insert(g, Control::new(kind).bound(ui::bind(&a("decay")))); +} + +/// The picture in (`x`, `y`, `w`, `h`) on `panel`, the cascade from `top` +/// down (below a nameplate over the picture). Returns its well. +fn picture( + b: &mut Builder<'_>, + panel: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + top: f64, + l: &Look, +) -> NodeId { + let mut well = draw::frame("Cascade", x, y, w, h); + well.fills = vec![draw::linear(180.0, &l.well)]; + well.clips_content = true; + well.corner_radii = frame::document::proto::CornerRadii::uniform(6.0); + well.effects = vec![draw::inner_shadow("#000000c0", 0.0, 2.0, 7.0)]; + let well = b.add(panel, well); + // A haze of the climb's light, high where the cascade ends. + b.add( + well, + draw::rect( + "haze", + 0.0, + 0.0, + w, + h, + draw::radial_at( + 0.85, + 0.15, + 0.9, + &[ + (0.0, &ui::alpha(LIGHT_0, 0.12)), + (1.0, &ui::alpha(LIGHT_0, 0.0)), + ], + ), + ), + ); + cascade(b, well, (w, h), top); + kit::glass(b, well); + well +} + +/// The panel, `w` × `h`: frosted, an iridescent edge along its top. +fn ground(b: &mut Builder<'_>, root: NodeId, (w, h): (f64, f64), l: &Look) -> NodeId { + let mut panel = draw::frame("Panel", 0.0, 0.0, w, h); + panel.fills = vec![draw::linear(180.0, &l.panel)]; + panel.corner_radii = frame::document::proto::CornerRadii::uniform(10.0); + let panel = b.add( + root, + draw::with_effects( + panel, + vec![ + draw::inner_shadow("#ffffff40", 0.0, 1.0, 0.0), + draw::inner_shadow("#00000040", 0.0, -2.0, 3.0), + ], + ), + ); + b.add( + panel, + draw::rounded( + draw::rect( + "iridescence", + 10.0, + 2.0, + w - 20.0, + 2.0, + draw::linear(90.0, &l.edge), + ), + 1.0, + ), + ); + b.add( + panel, + draw::rounded( + draw::rect( + "iridescence", + 10.0, + h - 4.0, + w - 20.0, + 2.0, + draw::linear(90.0, &l.edge), + ), + 1.0, + ), + ); + panel +} + +/// The nameplate's title: wide and light. +fn title(b: &mut Builder<'_>, t: NodeId, h: f64, size: f64, ink: &str) { + let wide = draw::Lettering::family(draw::family::MICHROMA).weight(400.0); + crate::suite_studio_blues::parts::set(b, t, NAME, 50.0, h / 2.0, size, ink, &wide, 0.0); +} + +/// The nameplate's title width. +const TITLE_W: f64 = 112.0; + +#[must_use] +pub fn face(dark: bool) -> Face { + face_at(dark, kit::Tier::Desktop) +} + +/// The face at `tier`: the cascade beside the controls. On a phone the +/// knobs are Shimmer, Shift and Level. +#[must_use] +pub fn face_at(dark: bool, tier: kit::Tier) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(NAME, tier, |b, root| { + let panel = ground(b, root, (w, kit::H), l); + let sw = if small_room { + kit::PHONE_PICTURE_R - 10.0 + } else { + 540.0 + }; + let well = picture(b, panel, (10.0, 10.0, sw, kit::H - 20.0), 0.0, l); + let lit = ui::alpha(kit::PURPLE_LAMP, 0.55); + if small_room { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (6.0, 30.0), + TITLE_W, + 200.0, + 12.0, + (l.accent, l.dim), + |b, t| title(b, t, 30.0, 13.0, l.ink), + ); + let (tx, ty, td) = kit::PHONE_TIME; + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 11.0, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + let (xs, ky, kd) = kit::PHONE_KNOBS; + for (i, (id, legend)) in [ + ("shim_amount", "Shimmer"), + ("shim_shift1", "Shift"), + ("level", "Level"), + ] + .iter() + .enumerate() + { + kit::knob3d( + b, + panel, + &a(id), + legend, + kd, + (xs[i], ky), + &l.knob, + l.ink, + l.dim, + ); + } + } else { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (10.0, 28.0), + TITLE_W, + 200.0, + 10.0, + (l.accent, l.dim), + |b, t| title(b, t, 28.0, 12.0, l.ink), + ); + let x0 = 10.0 + sw + 18.0; + let (tx, ty, td) = (x0 + 38.0, 78.0, 52.0); + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 8.5, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + for (i, (id, legend)) in FRONT.iter().enumerate() { + kit::knob3d( + b, + panel, + &a(id), + legend, + 32.0, + (x0 + 108.0 + 52.0 * i as f64, 90.0), + &l.knob, + l.ink, + l.dim, + ); + } + } + kit::face_bypass(b, panel, VERB, tier, "purple", "On"); + kit::flip_arrow(b, panel, VERB, tier, false, (l.ink, &ui::alpha(l.dim, 0.3))); + kit::responsive(b, panel, &[well]); + }); + let mut f = kit::reverb_face(kit::tier_file(FILE, tier, dark), scene); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::time_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its back at `tier`: the panel, no cascade — the advanced controls +/// across it, and (standing alone) the nameplate, the bypass and the +/// arrow. +#[must_use] +pub fn back_at(dark: bool, tier: kit::Tier) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(NAME, tier, |b, root| { + let panel = ground(b, root, (w, kit::H), l); + kit::back_knobs( + b, + panel, + tier, + &kit::VERB_MORE, + l.ink, + |b, p, id, legend, d, at| { + kit::knob3d(b, p, &a(id), legend, d, at, &l.knob, l.ink, l.dim); + }, + ); + let (np, size, ts) = if small_room { + ((6.0, 30.0), 12.0, 13.0) + } else { + ((10.0, 28.0), 10.0, 12.0) + }; + kit::head(b, panel, VERB, true, |b, head| { + kit::nameplate_top_right( + b, + head, + VERB, + tier, + np, + TITLE_W, + 200.0, + size, + (l.accent, l.dim), + |b, t| title(b, t, np.1, ts, l.ink), + ); + kit::face_bypass(b, head, VERB, tier, "purple", "On"); + }); + kit::flip_arrow(b, panel, VERB, tier, true, (l.ink, &ui::alpha(l.dim, 0.3))); + }); + let mut f = kit::reverb_face(kit::tier_file(BACK, tier, dark), scene); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its tall face: the cascade across the top, every control under it. +#[must_use] +pub fn tall(dark: bool) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let scene = kit::tall_lane(NAME, |b, root| { + let panel = ground(b, root, (kit::TALL_W, kit::TALL_H), l); + picture( + b, + panel, + (10.0, 10.0, kit::TALL_W - 20.0, ROWS.split - 16.0), + 44.0, + l, + ); + let t = kit::Tall { + ns: VERB, + main: &[ + ("shim_amount", "Shimmer"), + ("shim_shift1", "Shift"), + ("size", "Size"), + ("predelay", "Pre-Delay"), + ("level", "Level"), + ], + inks: (l.ink, l.dim, &ui::alpha(kit::PURPLE_LAMP, 0.55), l.accent), + scrim: l.scrim, + bypass: ("purple", "On"), + title_w: TITLE_W, + }; + kit::tall_chrome_rows( + b, + panel, + &t, + ROWS, + |b, p, addr, legend, d, at| { + kit::knob3d(b, p, addr, legend, d, at, &l.knob, l.ink, l.dim); + }, + |b, p, addr, (cx, cy, d)| { + kit::knob3d_bare(b, p, addr, d, (cx, cy), &l.wheel); + }, + |b, tn| title(b, tn, 30.0, 13.0, l.ink), + ); + }); + let mut f = kit::reverb_face(kit::tall_file(FILE, dark), scene); + f.params.extend(kit::tall_params(VERB)); + f +} diff --git a/examples/plugin-faces/src/rig_time/reverb/spring.rs b/examples/plugin-faces/src/rig_time/reverb/spring.rs new file mode 100644 index 00000000..e9c70009 --- /dev/null +++ b/examples/plugin-faces/src/rig_time/reverb/spring.rs @@ -0,0 +1,780 @@ +//! Spring: the tank itself, out of the amp and open on the bench — a +//! galvanised pan with its springs strung between two transducers, the +//! driver coil at the input end and the pickup at the output. Hit the +//! strings and the "drip" runs down the springs and back: here a bead of +//! light leaves the driver, runs to the pickup, bounces, and fades the +//! faster the shorter the decay. The springs quiver while it plays, harder +//! the longer it rings; a bigger tank is longer springs (the pickup end +//! moves out, the drip takes longer to cross). +//! +//! The decay is the felt damper clamped across the springs: slide it +//! toward the pickup and they ring longer. +//! +//! Two finishes, as the amp's reverb cabinet came: light, blonde tolex +//! with ivory knobs; dark, black tolex with silver-skirted black knobs. + +use frame::document::proto::NodeId; +use frame::document::proto::motion::{Dynamics, Motion, Target}; +use frame::surface::kit::{draw, motion}; +use frame::surface::proto::{Axis, Control, ControlKind, HandleAxis}; +use frame::surface::widgets::Builder; + +use crate::face::Face; +use crate::reverb::ui; +use crate::rig_time::kit::{self, VERB}; + +const NAME: &str = "Spring"; +const FILE: &str = "83-verb-spring"; +const BACK: &str = "83-verb-spring-back"; +/// The tall face's rows: the tank a little under half, big knobs below. +const ROWS: kit::TallRows = kit::TallRows { + split: 134.0, + main: (30.0, 50.0), + more: (126.0, 40.0), +}; +/// The drip's light: the section's purple. +const DRIP: &str = kit::PURPLE_LAMP; +/// Spring steel. +const WIRE: &str = "#e2e6ea"; + +/// One finish's colours. +struct Look { + ink: &'static str, + dim: &'static str, + tolex: [(f64, &'static str); 3], + weave: &'static str, + welt: &'static str, + knob: [&'static str; 6], + wheel: [&'static str; 6], + accent: &'static str, + scrim: &'static str, +} + +const LIGHT: Look = Look { + ink: "#2e2216", + dim: "#7a6648", + tolex: [(0.0, "#eadcbd"), (0.55, "#dccaa2"), (1.0, "#cbb58a")], + weave: "#6b4f2a12", + welt: "#7a1f22", + knob: [ + "color=#f2ead6", + "pointer=#3b1d6e", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#5a3d22", + ], + wheel: [ + "color=#f4ecd8", + "pointer=#3b1d6e", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#4a3220", + ], + accent: "#6a3fc4", + scrim: "#e4d4b2e6", +}; + +const DARK: Look = Look { + ink: "#ece8e0", + dim: "#9b968c", + tolex: [(0.0, "#2a292d"), (0.55, "#1f1e22"), (1.0, "#151417")], + weave: "#ffffff07", + welt: "#bdb8ac", + knob: [ + "color=#1b1b1e", + "pointer=#d9c8ff", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#b9bcc2", + ], + wheel: [ + "color=#18181b", + "pointer=#d9c8ff", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#c4c7cc", + ], + accent: kit::PURPLE_LAMP, + scrim: "#1f1e22e6", +}; + +fn a(id: &str) -> String { + format!("{VERB}/{id}") +} + +/// A coil seen from the side, `len` long round its axis at `cy`, radius +/// `r`, one turn every `pitch`: loops whose near side is the stroke. +fn coil(len: f64, cy: f64, r: f64, pitch: f64) -> Vec<(f64, f64)> { + let lead = 4.0; + let turns = ((len - 2.0 * lead) / pitch).floor().max(1.0); + let steps = (turns * 6.0) as i32; + let mut pts = vec![(0.0, cy)]; + for j in 0..=steps { + let u = f64::from(j) / f64::from(steps); + let th = u * turns * std::f64::consts::TAU; + pts.push(( + lead + (len - 2.0 * lead) * u + r * 0.42 * th.cos(), + cy + r * th.sin(), + )); + } + pts.push((len, cy)); + pts +} + +/// A transducer at (`x`, `y`), `w` × `h`: a black can with its coil and +/// magnet for each spring at `ys` (picture coordinates), and its leads. +fn transducer( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + ys: &[f64], + out: bool, +) -> NodeId { + let g = b.add( + parent, + draw::frame(if out { "pickup" } else { "driver" }, x, y, w, h), + ); + let can = draw::rounded( + draw::rect( + "can", + 0.0, + 0.0, + w, + h, + draw::linear( + 90.0, + &[(0.0, "#2c2e31"), (0.5, "#3c3f43"), (1.0, "#1a1b1d")], + ), + ), + 3.0, + ); + b.add( + g, + draw::with_effects( + can, + vec![ + draw::drop_shadow("#000000a0", 0.0, 2.0, 3.0), + draw::inner_shadow("#ffffff22", 0.0, 1.0, 0.0), + ], + ), + ); + let inner = if out { 3.0 } else { w - 3.0 }; + for cy in ys { + let cy = cy - y; + // The coil, copper, and the magnet the spring hangs from. + let cw = (w * 0.5).min(12.0); + let cx0 = if out { w - cw - 3.0 } else { 3.0 }; + b.add( + g, + draw::rounded( + draw::rect( + "coil", + cx0, + cy - 4.0, + cw, + 8.0, + draw::linear( + 180.0, + &[(0.0, "#e3a466"), (0.45, "#9a5a26"), (1.0, "#6e3c16")], + ), + ), + 2.0, + ), + ); + b.add( + g, + draw::circle( + "magnet", + inner, + cy, + 2.4, + draw::radial_at(0.35, 0.3, 0.8, &[(0.0, "#d5d8dc"), (1.0, "#55595e")]), + ), + ); + } + // The leads, out through the end of the pan. + let lx = if out { w } else { 0.0 }; + let ly = h * 0.5; + let ex = if out { w + 9.0 } else { -9.0 }; + ui::line( + b, + g, + "lead", + &[(lx, ly - 3.0), (ex, ly - 3.0)], + 1.1, + "#c23a2f", + ); + ui::line( + b, + g, + "lead", + &[(lx, ly + 3.0), (ex, ly + 3.0)], + 1.1, + "#1d1d1f", + ); + g +} + +/// The damper: a felt pad on a steel clamp across the springs, a handle +/// over the decay. Its centre travels `x0`..`x1` (picture coordinates); +/// the pad spans `top`..`bottom`. +fn damper( + b: &mut Builder<'_>, + parent: NodeId, + (x0, x1): (f64, f64), + h: f64, + (top, bottom): (f64, f64), +) { + let area = b.add(parent, draw::frame("damper travel", x0, 0.0, x1 - x0, h)); + let hw = 24.0; + let g = b.add( + area, + draw::frame("Damper", 0.4 * (x1 - x0) - hw / 2.0, 0.0, hw, h), + ); + let c = hw / 2.0; + // The pad and its clamp bar. + let pad = draw::rounded( + draw::rect( + "felt", + c - 6.0, + top, + 12.0, + bottom - top, + draw::linear( + 90.0, + &[(0.0, "#463c36"), (0.5, "#5a4e46"), (1.0, "#352d28")], + ), + ), + 3.0, + ); + b.add( + g, + draw::with_effects(pad, vec![draw::drop_shadow("#000000b0", 2.0, 2.0, 4.0)]), + ); + let ribs: Vec> = (0..((bottom - top - 4.0) / 4.0) as i32) + .map(|i| { + let y = top + 4.0 + f64::from(i) * 4.0; + vec![(c - 4.0, y), (c + 4.0, y)] + }) + .collect(); + b.add( + g, + ui::strokes("felt ribs", &ribs, 0.6, draw::solid("#00000040")), + ); + b.add( + g, + draw::with_effects( + draw::rounded( + draw::rect( + "clamp", + c - 8.0, + top - 4.0, + 16.0, + 5.0, + draw::linear(180.0, &[(0.0, "#e7eaed"), (1.0, "#8d939a")]), + ), + 1.5, + ), + vec![draw::drop_shadow("#00000080", 0.0, 1.0, 1.5)], + ), + ); + b.add( + g, + draw::circle("screw", c, top - 1.5, 1.4, draw::solid("#4a4e53")), + ); + // Its tab: a flag that says what it is. + let tab = draw::rounded( + draw::rect("tab", c - 12.0, 2.0, 24.0, 11.0, draw::solid(DRIP)), + 2.5, + ); + b.add(g, ui::glowing(tab, DRIP, 0.6, kit::GLOW)); + ui::plain(b, g, "Decay", c, 7.5, 5.0, "#140a24", 800.0); + let kind = ControlKind::Handle { + x: Some(ui::bind(&a("decay"))), + y: None, + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + }; + b.controls + .insert(g, Control::new(kind).bound(ui::bind(&a("decay")))); +} + +/// The tank in `well` (`w` × `h`), its springs centred in the band from +/// `top` down (below a nameplate over the picture). +#[allow(clippy::too_many_lines)] +fn tank(b: &mut Builder<'_>, well: NodeId, (w, h): (f64, f64), top: f64) { + let compact = w < 300.0; + // The pan: its lip catches the light; the floor sits in its shadow. + let pan = draw::rounded( + draw::rect( + "pan", + 3.0, + 3.0, + w - 6.0, + h - 6.0, + draw::linear( + 100.0, + &[ + (0.0, "#8f959c"), + (0.3, "#d2d6da"), + (0.55, "#9aa0a6"), + (0.8, "#c3c8cc"), + (1.0, "#a2a8ae"), + ], + ), + ), + 5.0, + ); + let pan = b.add(well, pan); + kit::steel(b, pan); + let mut floor = draw::rect( + "floor", + 8.0, + 8.0, + w - 16.0, + h - 16.0, + draw::linear( + 170.0, + &[(0.0, "#3b4046"), (0.5, "#2d3136"), (1.0, "#24272b")], + ), + ); + floor.corner_radii = frame::document::proto::CornerRadii::uniform(3.0); + floor.effects = vec![draw::inner_shadow("#000000c0", 0.0, 2.0, 6.0)]; + b.add(well, floor); + for (x, y) in [ + (13.0, 13.0), + (w - 13.0, 13.0), + (13.0, h - 13.0), + (w - 13.0, h - 13.0), + ] { + b.add( + well, + draw::circle( + "rivet", + x, + y, + 2.0, + draw::radial_at(0.35, 0.3, 0.8, &[(0.0, "#c9cdd1"), (1.0, "#5b6066")]), + ), + ); + } + + // The springs, between the transducers. + let n: i32 = if compact { 2 } else { 3 }; + let (band_t, band_b) = (top.max(16.0), h - 16.0); + let cy = (band_t + band_b) / 2.0; + let gap = ((band_b - band_t) / f64::from(n)).min(30.0); + let r = (gap * 0.26).min(6.5); + let ys: Vec = (0..n) + .map(|i| cy + (f64::from(i) - f64::from(n - 1) / 2.0) * gap) + .collect(); + let tw = if compact { 20.0 } else { 30.0 }; + // Under a nameplate the cans are short and their names go below. + let under = top > 0.0; + let can_pad = if under { 5.0 } else { 9.0 }; + let (th0, th1) = (ys[0] - r - can_pad, ys[ys.len() - 1] + r + can_pad); + let xa = 16.0 + tw; + let xb = w - 16.0 - tw; + let len = xb - xa; + // A bigger tank, longer springs: the pickup end moves out. + let shrink = if compact { 0.0 } else { len * 0.2 }; + let size = |b: &mut Builder<'_>, id: NodeId, m: Motion| { + motion::add( + b, + id, + m.input(format!("norm({VERB}/size)")) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + }; + let rate = format!("lerp(0.62, 0.34, norm({VERB}/size))"); + for (i, y) in ys.iter().enumerate() { + let f = i as f64; + let g = b.add( + well, + draw::frame(&format!("spring {i}"), xa, y - r - 3.0, len, 2.0 * r + 6.0), + ); + let pts = coil(len, r + 3.0, r, 3.6 + 0.6 * f); + // Its shadow on the floor: a soft band under it (cheaper than a + // second coil, and the floor is too dark to show the loops). + b.add( + g, + draw::rounded( + draw::rect( + "shadow", + 4.0, + r + 3.0, + len - 8.0, + r + 3.0, + draw::linear( + 180.0, + &[(0.0, "#00000000"), (0.5, "#00000060"), (1.0, "#00000000")], + ), + ), + r, + ), + ); + b.add(g, ui::line_node("wire", &pts, 0.85, WIRE)); + if shrink > 0.0 { + size( + b, + g, + Motion::new(Target::StretchX) + .range(1.0 - shrink / len, 1.0) + .pivot(0.0, 0.5), + ); + } + // It quivers while it plays, harder the longer it rings. + let quiver = format!( + "1 + (0.05 + 0.14 * norm({VERB}/decay)) * sin(time * {:.1} + {f}) * param({VERB}/on)", + 23.0 + 5.0 * f + ); + motion::add( + b, + g, + Motion::new(Target::StretchY) + .input(quiver) + .range(0.0, 1.0) + .pivot(0.5, 0.5), + ); + // The drip: a bead of light down the spring and back, fading as + // it goes — the slower, the longer the decay. + let bead = draw::rounded( + draw::rect( + "drip", + 0.0, + 0.0, + 22.0, + 2.0 * r + 6.0, + draw::radial_at( + 0.5, + 0.5, + 0.55, + &[(0.0, "#ffffff"), (0.35, DRIP), (1.0, &ui::alpha(DRIP, 0.0))], + ), + ), + r + 3.0, + ); + let bead = b.add(g, ui::glowing(bead, DRIP, 1.8, kit::BLOOM)); + let phase = format!("fract(time * {rate} + {:.2})", 0.37 * f); + let run = format!("{:.1} * (1 - abs(1 - 2 * {phase}))", len - 22.0); + motion::add(b, bead, Motion::new(Target::X).input(run).range(0.0, 1.0)); + let fade = + format!("param({VERB}/on) * pow(1 - {phase}, lerp(3.5, 0.5, norm({VERB}/decay)))"); + motion::add( + b, + bead, + Motion::new(Target::Opacity).input(fade).range(0.0, 1.0), + ); + } + // The transducers over the springs' ends. + transducer(b, well, (16.0, th0, tw, th1 - th0), &ys, false); + let out = transducer(b, well, (xb, th0, tw, th1 - th0), &ys, true); + if shrink > 0.0 { + size(b, out, Motion::new(Target::X).range(-shrink, 0.0)); + } + let lbl = if compact { 4.5 } else { 5.5 }; + let lbl_y = if under { + th1 + 6.0 + } else { + (th0 - 6.0).max(12.0) + }; + ui::silk(b, well, "In", 16.0 + tw / 2.0, lbl_y, lbl, "#c9ced3", 800.0); + let ol = ui::silk(b, well, "Out", xb + tw / 2.0, lbl_y, lbl, "#c9ced3", 800.0); + if !compact { + size(b, ol, Motion::new(Target::X).range(-shrink, 0.0)); + damper( + b, + well, + (xa + len * 0.3, xa + len * 0.66), + h, + (th0 + 2.0, th1 - 2.0), + ); + ui::silk_left( + b, + well, + "Reverberation tank · 3 springs", + xa + 8.0, + h - 13.0, + 5.5, + "#aab0b6", + 700.0, + ); + } +} + +/// The picture in (`x`, `y`, `w`, `h`) on `panel`, its springs from `top` +/// down: the open tank. Returns its well. +fn picture( + b: &mut Builder<'_>, + panel: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + top: f64, +) -> NodeId { + let mut well = draw::frame("Tank", x, y, w, h); + well.fills = vec![draw::linear(180.0, &[(0.0, "#121316"), (1.0, "#1c1e22")])]; + well.clips_content = true; + well.corner_radii = frame::document::proto::CornerRadii::uniform(6.0); + well.effects = vec![draw::inner_shadow("#000000c0", 0.0, 2.0, 7.0)]; + let well = b.add(panel, well); + tank(b, well, (w, h), top); + kit::scale_children(b, well); + well +} + +/// The cabinet, `w` × `h`: tolex with its weave, and the welt piping +/// round it. +fn ground(b: &mut Builder<'_>, root: NodeId, (w, h): (f64, f64), l: &Look, welt: bool) -> NodeId { + let mut panel = draw::frame("Panel", 0.0, 0.0, w, h); + panel.fills = vec![draw::linear(180.0, &l.tolex)]; + panel.corner_radii = frame::document::proto::CornerRadii::uniform(8.0); + let panel = b.add( + root, + draw::with_effects( + panel, + vec![ + draw::inner_shadow("#ffffff26", 0.0, 1.0, 0.0), + draw::inner_shadow("#00000050", 0.0, -2.0, 3.0), + ], + ), + ); + // The weave: a fine cross-hatch. + let mut weave = Vec::new(); + let mut i = -h; + while i < w { + weave.push(vec![(i, 0.0), (i + h, h)]); + weave.push(vec![(i + h, 0.0), (i, h)]); + i += 14.0; + } + b.add( + panel, + ui::strokes("weave", &weave, 0.6, draw::solid(l.weave)), + ); + // (Not on a phone: its knobs' values sit along the foot.) + if welt { + let mut piping = draw::rounded( + draw::rect("welt", 3.5, 3.5, w - 7.0, h - 7.0, draw::solid("#00000000")), + 6.0, + ); + piping.fills.clear(); + b.add(panel, draw::stroked(piping, &ui::alpha(l.welt, 0.7), 1.4)); + } + panel +} + +/// The nameplate's title: an amp's script. +fn title(b: &mut Builder<'_>, t: NodeId, h: f64, size: f64, ink: &str) { + let script = draw::Lettering::family(draw::family::PACIFICO).weight(400.0); + crate::suite_studio_blues::parts::set(b, t, NAME, 34.0, h / 2.0, size, ink, &script, 0.0); +} + +#[must_use] +pub fn face(dark: bool) -> Face { + face_at(dark, kit::Tier::Desktop) +} + +/// The face at `tier`: the open tank beside the cabinet's controls. On a +/// phone the tank has two springs and no damper, and the knobs are Size +/// and Level. +#[must_use] +pub fn face_at(dark: bool, tier: kit::Tier) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(NAME, tier, |b, root| { + let panel = ground(b, root, (w, kit::H), l, !small_room); + let sw = if small_room { + kit::PHONE_PICTURE_R - 10.0 + } else { + 560.0 + }; + let well = picture(b, panel, (10.0, 10.0, sw, kit::H - 20.0), 0.0); + let lit = ui::alpha(kit::PURPLE_LAMP, 0.55); + if small_room { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (6.0, 30.0), + 84.0, + 220.0, + 12.0, + (l.accent, l.dim), + |b, t| title(b, t, 30.0, 19.0, l.ink), + ); + let (tx, ty, td) = kit::PHONE_TIME; + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 11.0, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + let (xs, ky, kd) = kit::PHONE_KNOBS; + for (i, (id, legend)) in [("size", "Size"), ("level", "Level")].iter().enumerate() { + kit::knob3d( + b, + panel, + &a(id), + legend, + kd, + (xs[i], ky), + &l.knob, + l.ink, + l.dim, + ); + } + } else { + kit::nameplate_top_right( + b, + panel, + VERB, + tier, + (10.0, 28.0), + 84.0, + 200.0, + 10.0, + (l.accent, l.dim), + |b, t| title(b, t, 28.0, 17.0, l.ink), + ); + let x0 = 10.0 + sw + 20.0; + let (tx, ty, td) = (x0 + 40.0, 78.0, 52.0); + kit::time_knob( + b, + panel, + VERB, + (tx, ty, td), + 8.5, + (l.ink, l.dim, &lit), + |b, p, addr| { + kit::knob3d_bare(b, p, addr, td, (tx, ty), &l.wheel); + }, + ); + for (i, (id, legend)) in [("size", "Size"), ("tone", "Tone"), ("level", "Level")] + .iter() + .enumerate() + { + kit::knob3d( + b, + panel, + &a(id), + legend, + 34.0, + (x0 + 116.0 + 60.0 * i as f64, 90.0), + &l.knob, + l.ink, + l.dim, + ); + } + } + kit::face_bypass(b, panel, VERB, tier, "purple", "On"); + kit::flip_arrow(b, panel, VERB, tier, false, (l.ink, &ui::alpha(l.dim, 0.3))); + kit::responsive(b, panel, &[well]); + }); + let mut f = kit::reverb_face(kit::tier_file(FILE, tier, dark), scene); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::time_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its back at `tier`: the tolex, no tank — the advanced controls across +/// it, and (standing alone) the nameplate, the bypass and the arrow. +#[must_use] +pub fn back_at(dark: bool, tier: kit::Tier) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let small_room = matches!(tier, kit::Tier::Phone | kit::Tier::Strip); + let w = tier.lane_w(); + let scene = kit::tier_lane(NAME, tier, |b, root| { + let panel = ground(b, root, (w, kit::H), l, !small_room); + kit::back_knobs( + b, + panel, + tier, + &kit::VERB_MORE, + l.ink, + |b, p, id, legend, d, at| { + kit::knob3d(b, p, &a(id), legend, d, at, &l.knob, l.ink, l.dim); + }, + ); + let (np, preset_w, size, ts) = if small_room { + ((6.0, 30.0), 220.0, 12.0, 19.0) + } else { + ((10.0, 28.0), 200.0, 10.0, 17.0) + }; + kit::head(b, panel, VERB, true, |b, head| { + kit::nameplate_top_right( + b, + head, + VERB, + tier, + np, + 84.0, + preset_w, + size, + (l.accent, l.dim), + |b, t| title(b, t, np.1, ts, l.ink), + ); + kit::face_bypass(b, head, VERB, tier, "purple", "On"); + }); + kit::flip_arrow(b, panel, VERB, tier, true, (l.ink, &ui::alpha(l.dim, 0.3))); + }); + let mut f = kit::reverb_face(kit::tier_file(BACK, tier, dark), scene); + f.params.extend(kit::nameplate_params(VERB)); + f.params.extend(kit::flip_params(VERB)); + f +} + +/// Its tall face: the tank across the top, its springs below the +/// nameplate, every control under it. +#[must_use] +pub fn tall(dark: bool) -> Face { + let l = if dark { &DARK } else { &LIGHT }; + let scene = kit::tall_lane(NAME, |b, root| { + let panel = ground(b, root, (kit::TALL_W, kit::TALL_H), l, true); + picture( + b, + panel, + (10.0, 10.0, kit::TALL_W - 20.0, ROWS.split - 16.0), + 44.0, + ); + let t = kit::Tall { + ns: VERB, + main: &[ + ("size", "Size"), + ("tone", "Tone"), + ("damping", "HF Damp"), + ("level", "Level"), + ], + inks: (l.ink, l.dim, &ui::alpha(kit::PURPLE_LAMP, 0.55), l.accent), + scrim: l.scrim, + bypass: ("purple", "On"), + title_w: 84.0, + }; + kit::tall_chrome_rows( + b, + panel, + &t, + ROWS, + |b, p, addr, legend, d, at| { + kit::knob3d(b, p, addr, legend, d, at, &l.knob, l.ink, l.dim); + }, + |b, p, addr, (cx, cy, d)| { + kit::knob3d_bare(b, p, addr, d, (cx, cy), &l.wheel); + }, + |b, tn| title(b, tn, 30.0, 19.0, l.ink), + ); + }); + let mut f = kit::reverb_face(kit::tall_file(FILE, dark), scene); + f.params.extend(kit::tall_params(VERB)); + f +} diff --git a/examples/plugin-faces/src/rig_time/reverb/swell.rs b/examples/plugin-faces/src/rig_time/reverb/swell.rs new file mode 100644 index 00000000..0ab03a19 --- /dev/null +++ b/examples/plugin-faces/src/rig_time/reverb/swell.rs @@ -0,0 +1,670 @@ +//! Swell: the reverb that fades in instead of striking — drawn as the +//! machine that has always done that, an organ's swell box. Behind the +//! box's shutters a flat of pipes, lit from within; the shutters open as the +//! wet swells and close as it dies away, again and again, and the light +//! pours out between them. Beside the box, the envelope itself: the dry +//! note's quick spike, then the swell — rising over the Rise (size), dying +//! over the decay — and a bead of light riding it in step with the +//! shutters. The tail's end is a tab to drag: the decay. +//! +//! Two finishes: light, pale oak with ivory knobs; dark, walnut. + +use frame::document::proto::NodeId; +use frame::document::proto::motion::{Dynamics, Motion, Target}; +use frame::surface::kit::{draw, motion}; +use frame::surface::widgets::Builder; + +use super::shell::{self, Look, Room, Unit}; +use crate::face::Face; +use crate::reverb::ui; +use crate::rig_time::kit::{self, VERB}; + +const UNIT: Unit = Unit { + name: "Swell", + base: "90-verb-swell", + title_w: 84.0, + legend: "On", + lane: [ + ("size", "Rise"), + ("predelay", "Pre-Delay"), + ("level", "Level"), + ], + tall: [ + ("size", "Rise"), + ("predelay", "Pre-Delay"), + ("damping", "HF Damp"), + ("level", "Level"), + ], + look, + ground, + picture, + title, +}; + +/// The light behind the shutters: the section's purple. +const LIGHT_IN: &str = kit::PURPLE_LAMP; + +/// One finish: its inks and knobs, and its wood. +struct Finish { + look: Look, + wood: [(f64, &'static str); 3], + grain: &'static str, + case: (&'static str, &'static str), + slat: [(f64, &'static str); 3], + chamber: [(f64, &'static str); 2], + graph: [(f64, &'static str); 2], + rule: &'static str, +} + +const LIGHT: Finish = Finish { + look: Look { + ink: "#2e2216", + dim: "#7a6750", + accent: "#6a3fc4", + scrim: "#eadcc4e6", + knob: [ + "color=#f3ead8", + "pointer=#3b2814", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#9c7a52", + ], + wheel: [ + "color=#f6eedd", + "pointer=#6a3fc4", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#7a5a36", + ], + }, + wood: [(0.0, "#efe1c6"), (0.6, "#e2cfa9"), (1.0, "#d2ba8e")], + grain: "#7a552a18", + case: ("#9a6c3c", "#5e3e1e"), + slat: [(0.0, "#6b4524"), (0.5, "#b5834f"), (1.0, "#5a391b")], + chamber: [(0.0, "#160f1f"), (1.0, "#0b0810")], + graph: [(0.0, "#1a1622"), (1.0, "#100d16")], + rule: "#8d8299", +}; + +const DARK: Finish = Finish { + look: Look { + ink: "#efe3cf", + dim: "#a8957a", + accent: kit::PURPLE_LAMP, + scrim: "#2a1d14e6", + knob: [ + "color=#2b1d14", + "pointer=#e9d9b8", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#120b06", + ], + wheel: [ + "color=#efe3cf", + "pointer=#3b1d6e", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#3a2716", + ], + }, + wood: [(0.0, "#3a281b"), (0.6, "#2c1e14"), (1.0, "#1e140d")], + grain: "#ffffff08", + case: ("#5a3b22", "#2a1a0e"), + slat: [(0.0, "#2e1d10"), (0.5, "#7a5230"), (1.0, "#26170b")], + chamber: [(0.0, "#120c1a"), (1.0, "#07050b")], + graph: [(0.0, "#15121c"), (1.0, "#0c0a11")], + rule: "#7a7088", +}; + +fn finish(dark: bool) -> &'static Finish { + if dark { &DARK } else { &LIGHT } +} + +fn look(dark: bool) -> &'static Look { + &finish(dark).look +} + +/// The swell's phase: one cycle every 3⅓ s. +const PHASE: &str = "fract(time * 0.3)"; + +/// Where the rise ends (0..1 of the cycle): later with the Rise (size). +fn rise() -> String { + format!("lerp(0.06, 0.3, norm({VERB}/size))") +} + +/// Where the tail ends: later with the decay. +fn tail() -> String { + format!("lerp(0.4, 1.0, norm({VERB}/decay))") +} + +/// The envelope at phase `p` (an expression): up over the rise, down over +/// the tail. +fn env(p: &str) -> String { + let (r, t) = (rise(), tail()); + format!("smoothstep(0, {r}, {p}) * (1 - smoothstep({r}, {t}, {p}))") +} + +/// The panel: oak (or walnut) with its grain running across. +fn ground(b: &mut Builder<'_>, root: NodeId, (w, h): (f64, f64), dark: bool) -> NodeId { + let f = finish(dark); + let panel = shell::panel(b, root, (w, h), &f.wood, 8.0); + let mut grain = Vec::new(); + let mut i = 0.0; + while i * 2.6 < h { + let y = 1.0 + i * 2.6; + let wave = |x: f64| y + 1.6 * ((x / w) * 6.0 + i * 0.7).sin() * crate::reverb::jitter(i); + grain.push( + (0..=12) + .map(|k| f64::from(k) * w / 12.0) + .map(|x| (x, wave(x))) + .collect::>(), + ); + i += 1.0; + } + b.add( + panel, + ui::strokes("grain", &grain, 0.7, draw::solid(f.grain)), + ); + panel +} + +/// The swell box in (x, y, w, h) of the well: the chamber, its pipes lit +/// from behind, the shutters in front in their case. +fn swell_box(b: &mut Builder<'_>, well: NodeId, (x, y, w, h): (f64, f64, f64, f64), f: &Finish) { + let mut chamber = draw::frame("Swell box", x, y, w, h); + chamber.fills = vec![draw::linear(180.0, &f.chamber)]; + chamber.clips_content = true; + let bx = b.add(well, chamber); + let e = format!("{} * param({VERB}/on)", env(PHASE)); + // The light inside: a field, drawn, coming up as the shutters open. + let glow = b.add( + bx, + draw::rect( + "light", + 0.0, + 0.0, + w, + h, + draw::radial_at( + 0.5, + 0.42, + 0.75, + &[ + (0.0, &ui::alpha("#d9c4ff", 0.95)), + (0.5, &ui::alpha(LIGHT_IN, 0.6)), + (1.0, &ui::alpha(LIGHT_IN, 0.12)), + ], + ), + ), + ); + motion::add( + b, + glow, + Motion::new(Target::Opacity) + .input(format!("0.08 + 0.92 * {e}")) + .range(0.0, 1.0), + ); + // The lamp over the pipes, seen between the shutters. + let lamp = b.add( + bx, + draw::emissive( + draw::rounded( + draw::rect("lamp", w * 0.1, 3.0, w * 0.8, 4.0, draw::solid("#e9dcff")), + 2.0, + ), + draw::hex(LIGHT_IN), + 1.4, + kit::BLOOM, + ), + ); + motion::add( + b, + lamp, + Motion::new(Target::Opacity) + .input(format!("0.15 + 0.85 * {e}")) + .range(0.0, 1.0), + ); + // The flat of pipes: tallest at the centre, each with its mouth and + // its foot. + let n = 9; + let pw = (w - 20.0) / f64::from(n); + for i in 0..n { + let k = (f64::from(i) - f64::from(n - 1) / 2.0).abs() / (f64::from(n - 1) / 2.0); + let ph = h * (0.82 - 0.34 * k * k); + let px = 10.0 + f64::from(i) * pw + pw * 0.22; + let bw = pw * 0.56; + let foot = h * 0.16; + let top = h - foot - ph * 0.8; + let metal = draw::linear( + 90.0, + &[ + (0.0, "#3c3946"), + (0.35, "#c9c3d6"), + (0.55, "#f1edf7"), + (1.0, "#4a4656"), + ], + ); + b.add(bx, draw::rect("pipe", px, top, bw, h - foot - top, metal)); + let cone = format!( + "M 0 0 L {bw:.1} 0 L {:.1} {foot:.1} L {:.1} {foot:.1} Z", + bw * 0.62, + bw * 0.38 + ); + b.add( + bx, + draw::path( + "foot", + px, + h - foot, + bw, + foot, + cone, + draw::linear( + 90.0, + &[(0.0, "#2c2a33"), (0.5, "#9b95a8"), (1.0, "#2c2a33")], + ), + ), + ); + let my = h - foot - ph * 0.8 * 0.22; + let mouth = format!( + "M 0 0 L {bw:.1} 0 L {:.1} 5 L {:.1} 5 Z", + bw * 0.8, + bw * 0.2 + ); + b.add( + bx, + draw::path("mouth", px, my, bw, 5.0, mouth, draw::solid("#141118")), + ); + } + // The shutters: upright slats that turn edge-on as the swell opens, + // and between them the light, seen only as they part. + let slats = ((w / 16.0).round() as i32).max(6); + let sw = w / f64::from(slats); + for i in 1..slats { + let shaft = draw::rect( + "shaft", + f64::from(i) * sw - 1.5, + 0.0, + 3.0, + h, + draw::linear( + 180.0, + &[(0.0, "#f3ecff"), (0.5, "#d9c8ff"), (1.0, "#f3ecff")], + ), + ); + let shaft = b.add(bx, ui::glowing(shaft, LIGHT_IN, 1.0, kit::GLOW)); + motion::add( + b, + shaft, + Motion::new(Target::Opacity) + .input(e.clone()) + .range(0.0, 1.0), + ); + } + for i in 0..slats { + let s = b.add( + bx, + draw::frame("shutter", f64::from(i) * sw - 0.5, 0.0, sw + 1.0, h), + ); + let slat = draw::rect("slat", 0.0, 0.0, sw + 1.0, h, draw::linear(90.0, &f.slat)); + b.add( + s, + draw::with_effects(slat, vec![draw::drop_shadow("#00000080", 1.0, 0.0, 2.0)]), + ); + motion::add( + b, + s, + Motion::new(Target::StretchX) + .input(e.clone()) + .range(1.0, 0.12) + .pivot(0.5, 0.5), + ); + } + // The case: a frame round the shutters, its top rail lettered. + let case = draw::rect( + "case", + x - 3.0, + y - 3.0, + w + 6.0, + h + 6.0, + draw::solid("#00000000"), + ); + let mut case = draw::stroked(draw::rounded(case, 3.0), f.case.0, 4.0); + case.fills.clear(); + b.add( + well, + draw::with_effects(case, vec![draw::drop_shadow("#00000090", 0.0, 1.0, 3.0)]), + ); + let rail = b.add( + well, + draw::rounded( + draw::rect( + "rail", + x + w / 2.0 - 26.0, + y - 4.0, + 52.0, + 10.0, + draw::linear(180.0, &[(0.0, f.case.0), (1.0, f.case.1)]), + ), + 2.0, + ), + ); + ui::plain(b, rail, "SWELL", 26.0, 5.0, 5.5, "#e9d6a8", 800.0); +} + +/// The envelope in (x, y, w, h) of the well: its bars (the swell over one +/// cycle, shaped by the Rise and the decay), the dry spike, the bead riding +/// it, the Rise mark and the tail's tab. +fn envelope(b: &mut Builder<'_>, well: NodeId, (x, y, w, h): (f64, f64, f64, f64), f: &Finish) { + let g = b.add(well, draw::frame("Envelope", x, y, w, h)); + let base = h - 20.0; + let top = 12.0; + let gh = base - top; + ui::silk_right(b, g, "Swell · one cycle", w, 4.0, 5.5, f.rule, 800.0); + ui::line( + b, + g, + "axis", + &[(0.0, base), (w, base)], + 0.8, + &ui::alpha(f.rule, 0.8), + ); + // The bars: the envelope at each step, from the params alone. + let n = 40; + let bw = w / f64::from(n); + for i in 0..n { + let p = (f64::from(i) + 0.5) / f64::from(n); + let bar = b.add( + g, + draw::frame("step", f64::from(i) * bw + bw * 0.18, top, bw * 0.64, gh), + ); + b.add( + bar, + draw::rect( + "swell", + 0.0, + 0.0, + bw * 0.64, + gh, + draw::linear( + 180.0, + &[ + (0.0, &ui::alpha(LIGHT_IN, 0.95)), + (1.0, &ui::alpha(LIGHT_IN, 0.25)), + ], + ), + ), + ); + motion::add( + b, + bar, + Motion::new(Target::StretchY) + .input(env(&format!("{p:.4}"))) + .range(0.02, 1.0) + .pivot(0.5, 1.0) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + } + // The dry note: struck, gone. + let spike: Vec<(f64, f64)> = (0..=10) + .map(|k| { + let t = f64::from(k) / 10.0; + (1.0 + t * w * 0.05, base - gh * 0.95 * (1.0 - t).powi(3)) + }) + .collect(); + ui::line( + b, + g, + "dry", + &[(1.0, base), (1.0, base - gh * 0.95)], + 1.4, + "#f2eef8", + ); + ui::line(b, g, "dry tail", &spike, 1.0, &ui::alpha("#f2eef8", 0.7)); + // The Rise mark: where the swell peaks. + let mark = b.add(g, draw::frame("rise mark", 0.0, top - 2.0, 1.0, gh + 2.0)); + ui::line( + b, + mark, + "rise", + &[(0.0, 0.0), (0.0, gh + 2.0)], + 0.8, + &ui::alpha(f.rule, 0.9), + ); + ui::silk(b, mark, "Rise", 0.0, gh + 9.0, 5.0, f.rule, 800.0); + motion::add( + b, + mark, + Motion::new(Target::X) + .input(rise()) + .range(0.0, w) + .dynamics(Dynamics::spring(200.0, 26.0)), + ); + // The cursor and the bead: where the swell is now. + let cur = b.add(g, draw::frame("cursor", -4.0, top, 8.0, gh)); + ui::line( + b, + cur, + "now", + &[(4.0, 0.0), (4.0, gh)], + 0.7, + &ui::alpha("#f2eef8", 0.35), + ); + motion::add(b, cur, Motion::new(Target::X).input(PHASE).range(0.0, w)); + let bead = b.add( + cur, + ui::glowing( + draw::circle("bead", 4.0, gh, 3.2, draw::solid("#f6f1ff")), + LIGHT_IN, + 1.4, + kit::BLOOM, + ), + ); + motion::add( + b, + bead, + Motion::new(Target::Y) + .input(format!("{} * param({VERB}/on)", env(PHASE))) + .range(0.0, -gh), + ); + // The tail's end, a tab under the axis: drag it for the decay. + shell::decay_handle( + b, + g, + "Tail", + (w * 0.4, w, base + 2.0), + (26.0, 14.0), + |b, t| { + b.add( + t, + draw::path( + "point", + 9.0, + 0.0, + 8.0, + 4.0, + "M 4 0 L 8 4 L 0 4 Z".into(), + draw::solid(LIGHT_IN), + ), + ); + b.add( + t, + ui::glowing( + draw::rounded( + draw::rect("tab", 0.0, 4.0, 26.0, 10.0, draw::solid(LIGHT_IN)), + 2.5, + ), + LIGHT_IN, + 0.6, + kit::GLOW, + ), + ); + ui::plain(b, t, "Tail", 13.0, 9.0, 5.5, "#1a0f2c", 800.0); + }, + ); +} + +/// The tall face's spare room under its nameplate: the console's +/// drawstops, ivory faces lettered in their stems' names — the Swell's own +/// drawn out and lit while it plays. +fn stops(b: &mut Builder<'_>, well: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + let g = b.add(well, draw::frame("Stops", x, y, w, h)); + let names = [ + ("Diapason", "8'"), + ("Gamba", "8'"), + ("Celeste", "8'"), + ("Flute", "4'"), + ("Swell", "Tremulant"), + ]; + let n = names.len() as f64; + let r = (h / 2.0 - 4.0).min((w / n) / 2.0 - 6.0); + let serif = draw::Lettering::family(draw::family::PLAYFAIR_DISPLAY).weight(700.0); + for (i, (name, pitch)) in names.iter().enumerate() { + let cx = w * (i as f64 + 0.5) / n; + let cy = h / 2.0; + let on = i + 1 == names.len(); + // The stem's collar, then the face. + b.add( + g, + draw::with_effects( + draw::circle( + "collar", + cx, + cy, + r + 3.0, + draw::linear(180.0, &[(0.0, "#3a2414"), (1.0, "#140b05")]), + ), + vec![draw::drop_shadow("#000000a0", 0.0, 2.0, 4.0)], + ), + ); + if on { + let ring = draw::stroked( + draw::circle("lit ring", cx, cy, r + 2.0, draw::solid("#00000000")), + LIGHT_IN, + 2.0, + ); + let ring = b.add(g, ui::glowing(ring, LIGHT_IN, 1.0, kit::GLOW)); + motion::add( + b, + ring, + Motion::new(Target::Opacity) + .input(format!("param({VERB}/on)")) + .range(0.15, 1.0), + ); + } + b.add( + g, + draw::with_effects( + draw::circle( + "face", + cx, + cy, + r, + draw::radial_at( + 0.4, + 0.35, + 0.85, + &[(0.0, "#fffaf0"), (0.7, "#efe4cc"), (1.0, "#cdbf9f")], + ), + ), + vec![draw::inner_shadow("#00000040", 0.0, -1.5, 2.0)], + ), + ); + crate::suite_studio_blues::parts::set( + b, + g, + name, + cx, + cy - r * 0.18, + (r * 0.3).min(8.0), + if on { "#4c2a85" } else { "#2a1a0c" }, + &serif, + 0.0, + ); + ui::silk( + b, + g, + pitch, + cx, + cy + r * 0.32, + (r * 0.2).min(5.5), + "#6e5428", + 700.0, + ); + } +} + +/// The picture: the box, and beside it (where there is room) the +/// envelope, under glass. +fn picture( + b: &mut Builder<'_>, + panel: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + room: Room, + dark: bool, +) -> Vec { + let f = finish(dark); + let well = shell::well(b, panel, "Picture", (x, y, w, h), &f.graph, 6.0); + let bw = if room == Room::Phone { + w - 20.0 + } else { + (h * 1.55).min(w * 0.42) + }; + swell_box(b, well, (10.0, 12.0, bw, h - 22.0), f); + match room { + Room::Phone => {} + Room::Lane => envelope(b, well, (bw + 30.0, 8.0, w - bw - 44.0, h - 10.0), f), + // The tall face's nameplate covers the top right: the envelope + // keeps left of it, the stops sit under it. + Room::Tall => { + envelope(b, well, (bw + 30.0, 4.0, 400.0 - bw - 30.0, h - 6.0), f); + stops(b, well, (414.0, 44.0, w - 422.0, h - 46.0)); + } + } + kit::glass(b, well); + vec![well] +} + +/// The nameplate's title: "Swell" in an organ builder's blackletter. +fn title(b: &mut Builder<'_>, t: NodeId, h: f64, size: f64, dark: bool) { + let l = draw::Lettering::family(draw::family::UNIFRAKTUR_COOK).weight(700.0); + crate::suite_studio_blues::parts::set( + b, + t, + "Swell", + 32.0, + h / 2.0, + size, + finish(dark).look.ink, + &l, + 0.04, + ); +} + +#[must_use] +pub fn face(dark: bool) -> Face { + face_at(dark, kit::Tier::Desktop) +} + +/// The face at `tier`: the box and its envelope beside the controls; on a +/// phone, the box alone. +#[must_use] +pub fn face_at(dark: bool, tier: kit::Tier) -> Face { + shell::face_at(&UNIT, dark, tier) +} + +/// Its back at `tier`. +#[must_use] +pub fn back_at(dark: bool, tier: kit::Tier) -> Face { + shell::back_at(&UNIT, dark, tier) +} + +/// Its tall face. +#[must_use] +pub fn tall(dark: bool) -> Face { + shell::tall(&UNIT, dark) +} diff --git a/examples/plugin-faces/src/rig_time/reverb/velvet.rs b/examples/plugin-faces/src/rig_time/reverb/velvet.rs new file mode 100644 index 00000000..782b96d7 --- /dev/null +++ b/examples/plugin-faces/src/rig_time/reverb/velvet.rs @@ -0,0 +1,496 @@ +//! Velvet: the reverb built from velvet noise — a sparse train of single +//! impulses, each one up or down at random, its density setting how smooth +//! the tail sounds — drawn as what the name says: a jeweller's velvet tray +//! with the impulses pinned into it. Each pin stands up or down from the +//! silver thread at its moment, as tall as the tail is loud there; the +//! train runs out further as the decay grows (the pearl-headed pin at its +//! end is the decay, to drag), and more pins come in as the size (the +//! density) grows. Now and then a pin head catches the light. +//! +//! Two finishes: light, a dove-grey case lined in lilac velvet; dark, black +//! velvet in a plum case. + +use frame::document::proto::NodeId; +use frame::document::proto::motion::{Dynamics, Motion, Target}; +use frame::surface::kit::{draw, motion}; +use frame::surface::widgets::Builder; + +use super::shell::{self, Look, Room, Unit}; +use crate::face::Face; +use crate::reverb::ui; +use crate::rig_time::kit::{self, VERB}; + +const UNIT: Unit = Unit { + name: "Velvet", + base: "92-verb-velvet", + title_w: 92.0, + legend: "On", + lane: [ + ("size", "Density"), + ("predelay", "Pre-Delay"), + ("level", "Level"), + ], + tall: [ + ("size", "Density"), + ("predelay", "Pre-Delay"), + ("damping", "HF Damp"), + ("level", "Level"), + ], + look, + ground, + picture, + title, +}; + +/// The glints: the section's purple. +const GLINT: &str = kit::PURPLE_LAMP; +const SILVER: &str = "#e9e6f0"; + +struct Finish { + look: Look, + case: [(f64, &'static str); 3], + stitch: &'static str, + velvet: [(f64, &'static str); 3], + pile: &'static str, + print: &'static str, +} + +const LIGHT: Finish = Finish { + look: Look { + ink: "#352d42", + dim: "#756c86", + accent: "#6a3fc4", + scrim: "#e4e0e8e6", + knob: [ + "color=#ecebf0", + "pointer=#5b3aa8", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#a9a4b4", + ], + wheel: [ + "color=#f4f2f7", + "pointer=#5b3aa8", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#8b85a0", + ], + }, + case: [(0.0, "#efedf1"), (0.6, "#e1dee6"), (1.0, "#cfcad6")], + stitch: "#a59bb8", + velvet: [(0.0, "#4a2f6e"), (0.5, "#3a2259"), (1.0, "#2a1842")], + pile: "#ffffff0d", + print: "#c9bce0", +}; + +const DARK: Finish = Finish { + look: Look { + ink: "#efe7f7", + dim: "#a092b4", + accent: kit::PURPLE_LAMP, + scrim: "#2a1830e6", + knob: [ + "color=#1e1622", + "pointer=#e2d6f5", + "index=line", + "index_w=0.12", + "skirt=plain", + "skirt_color=#0c080e", + ], + wheel: [ + "color=#d9d5e0", + "pointer=#3b1d6e", + "index=line", + "index_w=0.1", + "skirt=plain", + "skirt_color=#4b3a56", + ], + }, + case: [(0.0, "#3a2440"), (0.6, "#2c1a31"), (1.0, "#1d1021")], + stitch: "#6e4f78", + velvet: [(0.0, "#1c1424"), (0.5, "#130d19"), (1.0, "#0b070f")], + pile: "#ffffff0a", + print: "#9a8cb0", +}; + +fn finish(dark: bool) -> &'static Finish { + if dark { &DARK } else { &LIGHT } +} + +fn look(dark: bool) -> &'static Look { + &finish(dark).look +} + +/// The impulses: (moment 0..1 of the window, up or down, height 0..1 of +/// the half, rank — the density at which it comes in). +fn pins(n: u32) -> Vec<(f64, f64, f64, f64)> { + (0..n) + .map(|i| { + let f = f64::from(i); + let j = |k: f64| crate::reverb::jitter(f * 2.13 + k); + // One per grid cell, anywhere in it: velvet noise. + let t = (f + 0.15 + 0.7 * j(1.0)) / f64::from(n); + let sign = if j(2.0) > 0.5 { 1.0 } else { -1.0 }; + let tail = (-3.2 * t).exp() * (0.7 + 0.3 * j(3.0)); + (t, sign, tail, j(4.0)) + }) + .collect() +} + +/// How far the train runs: the decay. +fn reach() -> String { + format!("lerp(0.2, 1, norm({VERB}/decay))") +} + +/// The case: lacquered, its lid's stitching round the edge. +fn ground(b: &mut Builder<'_>, root: NodeId, (w, h): (f64, f64), dark: bool) -> NodeId { + let f = finish(dark); + let panel = shell::panel(b, root, (w, h), &f.case, 10.0); + let mut s = draw::rect( + "stitching", + 4.0, + 4.0, + w - 8.0, + h - 8.0, + draw::solid("#00000000"), + ); + s.fills.clear(); + s.corner_radii = frame::document::proto::CornerRadii::uniform(7.0); + s.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(f.stitch)], + weight: 0.8, + dash_pattern: vec![3.0, 2.5], + ..frame::document::proto::Stroke::default() + }; + b.add(panel, s); + panel +} + +/// The tray in (x, y, w, h) on `panel`: the velvet, its pile, the silver +/// thread, the envelope and the pins. Returns the tray. +fn tray( + b: &mut Builder<'_>, + panel: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + room: Room, + f: &Finish, +) -> NodeId { + let mut t = draw::frame("Tray", x, y, w, h); + t.fills = vec![draw::linear(165.0, &f.velvet)]; + t.clips_content = true; + t.corner_radii = frame::document::proto::CornerRadii::uniform(6.0); + t.effects = vec![draw::inner_shadow("#000000c0", 0.0, 2.5, 8.0)]; + let t = b.add(panel, t); + // The pile: crushed, its sheen in soft diagonal bands. + b.add( + t, + draw::rect( + "sheen", + 0.0, + 0.0, + w, + h, + draw::linear( + 110.0, + &[ + (0.0, "#ffffff00"), + (0.3, "#ffffff10"), + (0.42, "#ffffff00"), + (0.7, "#ffffff0c"), + (0.85, "#ffffff00"), + ], + ), + ), + ); + let pile: Vec> = (0..(w / 3.0) as i32) + .map(|i| { + let px = f64::from(i) * 3.0 + crate::reverb::jitter(f64::from(i)) * 2.0; + vec![(px, 0.0), (px + 6.0, h)] + }) + .collect(); + b.add(t, ui::strokes("pile", &pile, 1.0, draw::solid(f.pile))); + let (x0, mid) = (12.0, h / 2.0); + let span = w - x0 - 16.0; + // In the tall face the nameplate covers the top right: the train keeps + // below it there (its pins are short by then anyway). + let half = h / 2.0 - 12.0; + ui::silk_left( + b, + t, + "Velvet noise · ±1 impulses", + 8.0, + 8.0, + 5.5, + f.print, + 800.0, + ); + // The thread. + b.add( + t, + ui::line_node( + "thread", + &[(x0, mid), (w - 6.0, mid)], + 0.8, + &ui::alpha(SILVER, 0.55), + ), + ); + // The envelope, top and bottom, out to the decay: stretched, it is + // lines alone. + let env = b.add(t, draw::frame("Envelope", x0, mid - half, span, 2.0 * half)); + let curve = |sign: f64| -> Vec<(f64, f64)> { + (0..=40) + .map(|i| { + let u = f64::from(i) / 40.0; + (u * span, half - sign * half * (-3.2 * u).exp()) + }) + .collect() + }; + // Under it, the tail the ear hears from the sparse train: smooth. + let mut d = format!("M 0 {half:.1} "); + for k in [1.0, -1.0] { + for (px, py) in if k > 0.0 { + curve(1.0) + } else { + curve(-1.0).into_iter().rev().collect() + } { + d += &format!("L {px:.1} {py:.1} "); + } + } + d.push('Z'); + b.add( + env, + draw::path( + "heard", + 0.0, + 0.0, + span, + 2.0 * half, + d, + draw::linear( + 90.0, + &[ + (0.0, &ui::alpha(GLINT, 0.32)), + (1.0, &ui::alpha(GLINT, 0.0)), + ], + ), + ), + ); + let mut e = ui::strokes( + "envelope", + &[curve(1.0), curve(-1.0)], + 0.7, + draw::solid(&ui::alpha(SILVER, 0.4)), + ); + e.stroke.dash_pattern = vec![2.0, 3.0]; + b.add(env, e); + motion::add( + b, + env, + Motion::new(Target::StretchX) + .input(reach()) + .range(0.0, 1.0) + .pivot(0.0, 0.5) + .dynamics(Dynamics::spring(200.0, 26.0)), + ); + // The pins: each at its moment (the train stretched by the decay), up + // or down, coming in with the density. + let n = if room == Room::Phone { 26 } else { 56 }; + for (i, (moment, sign, tall, rank)) in pins(n).iter().enumerate() { + let len = (half * tall).max(3.0); + let pin = b.add( + t, + draw::frame( + &format!("pin {i}"), + x0 - 3.0, + if *sign > 0.0 { + mid - len - 3.0 + } else { + mid - 3.0 + }, + 6.0, + len + 6.0, + ), + ); + let (stem, head) = if *sign > 0.0 { + ((3.0, len + 3.0), (3.0, 3.0)) + } else { + ((3.0, 3.0), (3.0, len + 3.0)) + }; + b.add(pin, ui::line_node("stem", &[stem, head], 0.9, "#c9c4d4")); + b.add( + pin, + draw::circle( + "head", + head.0, + head.1, + 2.0, + draw::radial_at( + 0.35, + 0.3, + 0.8, + &[(0.0, "#ffffff"), (0.6, SILVER), (1.0, "#8d879a")], + ), + ), + ); + motion::add( + b, + pin, + Motion::new(Target::X) + .input(format!("{moment:.4} * {}", reach())) + .range(0.0, span) + .dynamics(Dynamics::spring(200.0, 26.0)), + ); + motion::add( + b, + pin, + Motion::new(Target::Opacity) + .input(format!( + "clamp((lerp(0.25, 1.05, norm({VERB}/size)) - {rank:.3}) * 12, 0, 1)" + )) + .range(0.0, 1.0), + ); + // Every fifth pin's head catches the light now and then. + if i % 5 == 2 { + let glint = b.add( + pin, + ui::glowing( + draw::circle("glint", head.0, head.1, 1.6, draw::solid("#ffffff")), + GLINT, + 1.6, + kit::GLOW, + ), + ); + let when = format!( + "pow(0.5 + 0.5 * sin(time * {:.2} + {:.2}), 16) * param({VERB}/on)", + 0.9 + 0.8 * crate::reverb::jitter(f64::from(i as u32) * 5.1), + f64::from(i as u32) * 1.7 + ); + motion::add( + b, + glint, + Motion::new(Target::Opacity).input(when).range(0.0, 1.0), + ); + } + } + // The pearl-headed pin at the train's end: drag it for the decay. + let ph = 2.0 * half + 8.0; + shell::decay_handle( + b, + t, + "Tail pin", + (x0 + 0.2 * span, x0 + span, mid - half - 4.0), + (16.0, ph), + |b, p| { + b.add( + p, + ui::line_node( + "shaft", + &[(8.0, 4.0), (8.0, ph - 4.0)], + 1.0, + &ui::alpha(SILVER, 0.8), + ), + ); + b.add( + p, + ui::glowing( + draw::circle( + "pearl", + 8.0, + ph / 2.0, + 5.0, + draw::radial_at( + 0.35, + 0.3, + 0.9, + &[(0.0, "#ffffff"), (0.5, "#efe4ff"), (1.0, "#a58fd6")], + ), + ), + GLINT, + 0.7, + kit::BLOOM, + ), + ); + ui::plain(b, p, "Tail", 8.0, ph - 2.0, 5.0, f.print, 800.0); + }, + ); + t +} + +/// The picture: the tray, under glass, in a silver rim. +fn picture( + b: &mut Builder<'_>, + panel: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + room: Room, + dark: bool, +) -> Vec { + let f = finish(dark); + let t = tray(b, panel, (x, y, w, h), room, f); + kit::glass(b, t); + let mut rim = draw::rect( + "rim", + x - 2.0, + y - 2.0, + w + 4.0, + h + 4.0, + draw::solid("#00000000"), + ); + rim.fills.clear(); + rim.corner_radii = frame::document::proto::CornerRadii::uniform(7.0); + rim.stroke = frame::document::proto::Stroke { + paints: vec![draw::linear( + 100.0, + &[ + (0.0, "#f3f2f6"), + (0.45, "#8f8a9a"), + (0.6, "#e6e3ec"), + (1.0, "#7d7888"), + ], + )], + weight: 2.5, + ..frame::document::proto::Stroke::default() + }; + let rim = b.add(panel, rim); + vec![t, rim] +} + +/// The nameplate's title: "Velvet" in a script. +fn title(b: &mut Builder<'_>, t: NodeId, h: f64, size: f64, dark: bool) { + let l = draw::Lettering::family(draw::family::DANCING_SCRIPT).weight(700.0); + crate::suite_studio_blues::parts::set( + b, + t, + "Velvet", + 38.0, + h / 2.0, + size * 1.1, + finish(dark).look.ink, + &l, + 0.02, + ); +} + +#[must_use] +pub fn face(dark: bool) -> Face { + face_at(dark, kit::Tier::Desktop) +} + +/// The face at `tier`: the tray beside the controls. +#[must_use] +pub fn face_at(dark: bool, tier: kit::Tier) -> Face { + shell::face_at(&UNIT, dark, tier) +} + +/// Its back at `tier`. +#[must_use] +pub fn back_at(dark: bool, tier: kit::Tier) -> Face { + shell::back_at(&UNIT, dark, tier) +} + +/// Its tall face. +#[must_use] +pub fn tall(dark: bool) -> Face { + shell::tall(&UNIT, dark) +} diff --git a/examples/plugin-faces/src/rig_time/steel.wgsl b/examples/plugin-faces/src/rig_time/steel.wgsl new file mode 100644 index 00000000..23207ac2 --- /dev/null +++ b/examples/plugin-faces/src/rig_time/steel.wgsl @@ -0,0 +1,43 @@ +// steel — the Plate's sheet as brushed steel that catches the light. The +// sheet's own paint (its broad light and dark bands) stays the picture; +// over it, the brushing — fine streaks along the sheet, a row of pixels +// each, slowly varying along it — and the highlights stretched across the +// brushing the way brushed metal stretches them: where the paint is +// already light it brightens toward a cool white, in streaks. A light +// from the upper left: the top of the sheet a little brighter. Reads no +// clock: the sheet renders once. +// +// params: strength (0.8) — the highlights; amount (0.5) — the brushing. + +fn effect(uv: vec2) -> vec4 { + let strength = clamp(param_or(P_STRENGTH, 0.8), 0.0, 2.0); + let brushing = clamp(param_or(P_AMOUNT, 0.5), 0.0, 1.0); + let c = sample_src(uv); + if (c.a <= 0.0) { + return c; + } + let size = node_px(); + let px = uv * size; + // Brushing: each row its own grain, drifting in runs along the sheet. + let row = floor(px.y); + let run = floor(px.x / 23.0 + hash11(row * 0.71) * 7.0); + let n = 0.6 * hash11(row * 1.37 + 3.1) + 0.4 * hash11(row * 0.53 + run * 13.7); + let grain = (n - 0.5) * 0.14 * brushing; + // The paint's own light, straight colour. + let rgb = c.rgb / c.a; + let lum = luminance(rgb); + // Anisotropic highlight: the light bands flare, streaked by the grain. + let flare = pow(clamp((lum - 0.5) / 0.5, 0.0, 1.0), 2.0); + // And the window's light itself, reflected: two soft upright streaks + // (brushed metal stretches a highlight across its grain), broken up + // row by row. + let a1 = (uv.x - 0.2) / 0.05; + let a2 = (uv.x - 0.6) / 0.11; + let glint = exp(-a1 * a1) + 0.6 * exp(-a2 * a2); + let top = 0.75 + 0.25 * (1.0 - uv.y) * (1.0 - 0.5 * uv.x); + let spec = (flare * 0.16 + glint * 0.2) * top * (0.4 + 1.2 * n) * strength; + // The dark bands deepen a touch: more contrast, as polished metal has. + let deep = mix(rgb, rgb * rgb * 1.2, 0.3 * strength); + let out = clamp(deep * (1.0 + grain) + vec3(0.93, 0.96, 1.0) * spec, vec3(0.0), vec3(1.0)); + return vec4(out * c.a, c.a); +} diff --git a/examples/plugin-faces/src/rig_units.rs b/examples/plugin-faces/src/rig_units.rs new file mode 100644 index 00000000..95eeaac0 --- /dev/null +++ b/examples/plugin-faces/src/rig_units.rs @@ -0,0 +1,481 @@ +//! The rig's own units composed for a block of Signal's routing grid, +//! minimal: each only what makes it that unit — the Katana's one big knob, +//! the Klon's gold and its Centaur, the Bluesbreaker's plate, the 808's +//! green and its switch, the spring tank ringing, the Distressor's +//! gain-reduction lamps. +//! +//! The block's top and foot carry its preset and its name on bands of +//! their own: each unit sits in the middle, between them. + +use frame::document::proto::motion::{Motion, Target}; +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::draw::{self, Lettering, family}; +use frame::surface::kit::motion; +use frame::surface::widgets::Builder; +use serde_json::json; + +use crate::face::Face; +use crate::reverb::ui; +use crate::rig_cells::{BLEED, S, block, face}; +use crate::suite_studio_blues::parts::set; + +/// Each unit's params: on (its bypass). +const NS: &str = "unit"; + +fn on() -> Vec { + vec![ui::toggle(&format!("{NS}/on"), "Bypass", true)] +} + +/// A knob seen from above: its cap, a pointer at `deg` (0 up). +fn knob( + b: &mut Builder<'_>, + p: NodeId, + (cx, cy): (f64, f64), + r: f64, + cap: (&str, &str), + pointer: &str, + deg: f64, +) { + b.add( + p, + draw::with_effects( + draw::circle( + "knob", + cx, + cy, + r, + draw::radial_at(0.38, 0.32, 0.9, &[(0.0, cap.0), (1.0, cap.1)]), + ), + vec![draw::drop_shadow("#00000080", 0.0, 2.0, 3.0)], + ), + ); + let tip = draw::polar(cx, cy, r * 0.82, deg); + let root = draw::polar(cx, cy, r * 0.25, deg); + b.add( + p, + ui::strokes( + "pointer", + &[vec![root, tip]], + (r * 0.14).max(1.6), + draw::solid(pointer), + ), + ); +} + +/// A lamp, lit while the unit is on. +fn lamp(b: &mut Builder<'_>, p: NodeId, (cx, cy): (f64, f64), colour: &str) { + b.add( + p, + draw::circle("lamp ring", cx, cy, 5.5, draw::solid("#0b0b0c")), + ); + let l = b.add( + p, + draw::emissive( + draw::circle("lamp", cx, cy, 3.6, draw::solid(colour)), + draw::hex(colour), + 1.0, + 9.0, + ), + ); + motion::add( + b, + l, + Motion::new(Target::Opacity) + .input(format!("param({NS}/on)")) + .range(0.12, 1.0), + ); +} + +fn words(b: &mut Builder<'_>, p: NodeId, s: &str, cy: f64, size: f64, colour: &str, l: &Lettering) { + b.add( + p, + draw::styled_text( + "legend", + s, + 0.0, + cy - size * 0.7, + S, + size, + colour, + TextAlign::Center, + l, + ), + ); +} + +/// The Keeley Katana: its name, wide and white, in the inset line on the +/// black lid, the blade's K under it. +fn katana() -> Face { + let scene = block( + "Katana", + &[(0.0, "#1a1a1d"), (1.0, "#0a0a0b")], + |b, v, _| { + let ink = "#ececef"; + b.add( + v, + draw::stroked( + draw::rounded( + draw::rect( + "border", + 12.0, + 50.0, + S - 24.0, + 86.0, + draw::solid("#00000000"), + ), + 10.0, + ), + ink, + 1.6, + ), + ); + set( + b, + v, + "KATANA", + S / 2.0, + 80.0, + 30.0, + ink, + &Lettering::family(family::INTER).weight(500.0).width(92.0), + 0.04, + ); + b.add( + v, + draw::stroked( + draw::rounded( + draw::rect( + "mark", + S / 2.0 - 11.0, + 104.0, + 22.0, + 22.0, + draw::solid("#00000000"), + ), + 3.0, + ), + ink, + 1.5, + ), + ); + set( + b, + v, + "K", + S / 2.0, + 115.5, + 15.0, + ink, + &Lettering::family(family::INTER).weight(800.0).italic(), + 0.0, + ); + }, + ); + face("40-unit-katana-block", scene, on()) +} + +/// The Klon Centaur: its gold, and the centaur at a gallop across it. +fn klon() -> Face { + let scene = block( + "Klon", + &[(0.0, "#e2b25a"), (0.55, "#c99038"), (1.0, "#9c6a22")], + |b, v, _| { + b.add( + v, + draw::rect( + "sheen", + 0.0, + 0.0, + S, + S, + draw::linear( + 115.0, + &[ + (0.0, "#ffe2a03a"), + (0.4, "#ffffff00"), + (0.75, "#4a2c0628"), + (1.0, "#ffe2a01a"), + ], + ), + ), + ); + crate::rig_blues::drives::centaur(b, v, (S / 2.0, 94.0), 128.0, "#2b1c08"); + }, + ); + face("41-unit-klon-block", scene, on()) +} + +/// The Marshall Bluesbreaker: its charcoal, and the blue italic name +/// between two double rules, as on the original. +fn bluesbreaker() -> Face { + let scene = block( + "Bluesbreaker", + &[ + (0.0, "#34363b"), + (0.3, "#2c2e33"), + (0.32, "#1d1e21"), + (1.0, "#2a2c30"), + ], + |b, v, _| { + let blue = "#4fb8ea"; + let ly = 92.0; + for dy in [-21.0, -17.0, 17.0, 21.0] { + b.add( + v, + draw::rect("rule", 10.0, ly + dy, S - 20.0, 1.8, draw::solid(blue)), + ); + } + set( + b, + v, + "BLUES BREAKER", + S / 2.0, + ly, + 21.0, + blue, + &Lettering::family(family::OSWALD).weight(700.0).italic(), + 0.02, + ); + lamp(b, v, (S / 2.0, 126.0), "#e0281c"); + }, + ); + face("42-unit-bluesbreaker-block", scene, on()) +} + +/// The Ibanez TS808: its green, the grey name band, and the black treadle +/// ribbed in chevrons with the maker's script. +fn ts808() -> Face { + let scene = block("TS808", &[(0.0, "#6fd257"), (1.0, "#46a630")], |b, v, _| { + let (by, bh) = (50.0, 24.0); + b.add( + v, + draw::rect( + "band", + 0.0, + by, + S, + bh, + draw::linear(180.0, &[(0.0, "#46494c"), (1.0, "#2a2c2f")]), + ), + ); + set( + b, + v, + "TUBE SCREAMER", + S / 2.0, + by + bh / 2.0, + 15.0, + "#f1f1ef", + &Lettering::family(family::INTER).weight(700.0).width(88.0), + 0.02, + ); + let (tx, ty, tw, th) = (16.0, by + bh + 8.0, S - 32.0, 62.0); + let mut tread = draw::frame("treadle", tx, ty, tw, th); + tread.fills = vec![draw::linear( + 180.0, + &[(0.0, "#2c2c2e"), (0.08, "#18181a"), (1.0, "#0c0c0d")], + )]; + tread.corner_radii = frame::document::proto::CornerRadii::uniform(6.0); + tread.clips_content = true; + let tread = b.add(v, tread); + let (mut lit, mut dark) = (Vec::new(), Vec::new()); + for r in 0..6 { + let y = 6.0 + f64::from(r) * 9.0; + for k in 0..3 { + let (x0, x1) = ( + tw * (0.06 + 0.3 * f64::from(k)), + tw * (0.3 + 0.3 * f64::from(k)), + ); + let xm = (x0 + x1) / 2.0; + dark.push(vec![(x0, y + 4.0), (xm, y), (x1, y + 4.0)]); + lit.push(vec![(x0, y + 5.2), (xm, y + 1.2), (x1, y + 5.2)]); + } + } + b.add( + tread, + ui::strokes("rib shadow", &dark, 2.0, draw::solid("#050506")), + ); + b.add( + tread, + ui::strokes("rib light", &lit, 0.9, draw::solid("#3a3a3e")), + ); + set( + b, + tread, + "Ibanez", + tw / 2.0, + th * 0.55, + 20.0, + "#4a4a50", + &Lettering::family(family::DANCING_SCRIPT) + .weight(700.0) + .italic(), + 0.0, + ); + }); + face("43-unit-ts808-block", scene, on()) +} + +/// A spring tank: the open pan and its springs, ringing as the reverb's +/// decay, damping and modulation set — the full spring picture's tank, +/// across the block. +fn spring() -> Face { + let a = crate::reverb::spring::Springs { + dwell: "spring/decay", + damping: "spring/damping", + tension: "spring/tension", + drip: "spring/modulation", + on: "spring/on", + input: "spring/in", + output: "spring/out", + }; + let scene = block( + "Spring", + &[(0.0, "#2a2c30"), (1.0, "#16171a")], + |b, v, _| { + let (w, h) = (S - 16.0, 92.0); + let mut tank = draw::frame("Tank", 8.0, 46.0, w, h); + tank.clips_content = true; + tank.corner_radii = frame::document::proto::CornerRadii::uniform(6.0); + let tank = b.add(v, tank); + crate::reverb::spring::tank_art_with(b, tank, &a, w, h, 2, ""); + }, + ); + let range = |id: &str, name: &str, d: f64| { + ui::param( + &format!("spring/{id}"), + name, + frame::surface::proto::Range::linear(0.0, 1.0, frame::surface::proto::Unit::None), + d, + ) + }; + face( + "45-unit-spring-block", + scene, + vec![ + range("decay", "Decay", 0.35), + range("damping", "Damping", 0.3), + range("tension", "Tension", 0.5), + range("modulation", "Modulation", 0.2), + ui::toggle("spring/on", "Reverb", true), + ], + ) +} + +/// The Distressor: its black face, its name, and the gain-reduction lamps +/// working across it. +pub fn distressor() -> Face { + let scene = block( + "Distressor", + &[(0.0, "#1b1b1d"), (1.0, "#08080a")], + |b, v, _| { + words( + b, + v, + "DISTRESSOR", + 62.0, + 12.0, + "#e9e9ec", + &Lettering::family(family::MICHROMA).weight(400.0), + ); + // Ten lamps: green, amber, red — as much as it is taking off. + let gr = "(0.4 + 0.3 * sin(time * 2.1) + 0.15 * sin(time * 5.3))"; + for i in 0..10 { + let x = 30.0 + f64::from(i) * 14.0; + let colour = if i < 5 { + "#4ade80" + } else if i < 8 { + "#facc15" + } else { + "#ef4444" + }; + b.add( + v, + draw::rounded( + draw::rect("well", x - 5.0, 84.0, 10.0, 18.0, draw::solid("#050506")), + 2.0, + ), + ); + let l = b.add( + v, + draw::emissive( + draw::rounded( + draw::rect("lamp", x - 4.0, 85.0, 8.0, 16.0, draw::solid(colour)), + 2.0, + ), + draw::hex(colour), + 0.9, + 7.0, + ), + ); + motion::add( + b, + l, + Motion::new(Target::Opacity) + .input(format!("clamp(({gr} * 10 - {i}) * param(dist/on), 0, 1)")) + .range(0.08, 1.0), + ); + } + knob( + b, + v, + (44.0, 124.0), + 11.0, + ("#f1f2f4", "#8b8f95"), + "#111", + 240.0, + ); + knob( + b, + v, + (140.0, 124.0), + 11.0, + ("#f1f2f4", "#8b8f95"), + "#111", + 200.0, + ); + }, + ); + face( + "55-post-comp-distressor-block", + scene, + vec![ui::toggle("dist/on", "In", true)], + ) +} + +#[must_use] +pub fn faces() -> Vec { + vec![ + katana(), + klon(), + bluesbreaker(), + ts808(), + spring(), + distressor(), + ] +} + +/// `faces.json`'s `blocks` for them: a drive by its pedal, the pre +/// effects' spring and any spring reverb, the post compressor. +#[must_use] +pub fn manifest() -> Vec { + let bleed = BLEED / S; + let e = |m: &[&str], face: &str| json!({ "match": m, "face": face, "ns": NS, "bleed": bleed }); + vec![ + e( + &["drive:keeley katana", "drive:katana"], + "40-unit-katana-block", + ), + e(&["drive:klon centaur", "drive:klon"], "41-unit-klon-block"), + e( + &["drive:marshall bluesbreaker", "drive:bluesbreaker"], + "42-unit-bluesbreaker-block", + ), + e( + &["drive:ibanez ts808", "drive:ts808", "drive:tube screamer"], + "43-unit-ts808-block", + ), + json!({ "match": ["pre:reverb", "reverb:spring"], "face": "45-unit-spring-block", "ns": "spring", "bleed": bleed }), + json!({ "match": ["post comp"], "face": "55-post-comp-distressor-block", "ns": "dist", "bleed": bleed }), + ] +} diff --git a/examples/plugin-faces/src/rigkit/lcd.rs b/examples/plugin-faces/src/rigkit/lcd.rs new file mode 100644 index 00000000..2655471c --- /dev/null +++ b/examples/plugin-faces/src/rigkit/lcd.rs @@ -0,0 +1,98 @@ +//! Real displays: segment and dot-matrix readouts bound to params. +//! +//! A readout is a `Readout` control whose `Value` is set in a display +//! family — `DSEG14 Classic` (14-segment alphanumerics), `DSEG7 Classic` +//! (7-segment digits), `Doto` (dot matrix) — over the unlit segments' +//! ghosts, so it reads as a lit display at any value. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::draw::{self, family}; +use frame::surface::proto::{Binding, Control, ControlKind, SlotRole, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::lettering::{Letter, cap_middle, text_in}; +use crate::delay::parts; + +/// A 14-segment display face. +#[must_use] +pub const fn seg14(size: f64, color: &str) -> Letter<'_> { + Letter::new(size, 400.0, color) + .family(family::DSEG14) + .track(0.08) +} + +/// A 7-segment display face. +#[must_use] +pub const fn seg7(size: f64, color: &str) -> Letter<'_> { + Letter::new(size, 400.0, color) + .family(family::DSEG7) + .track(0.08) +} + +/// A dot-matrix display face. +#[must_use] +pub const fn dots(size: f64, color: &str) -> Letter<'_> { + Letter::new(size, 600.0, color) + .family(family::DOTO) + .track(0.04) +} + +/// Every segment of a DSEG family, lit: the unlit ghost of one cell. +#[must_use] +pub fn all_segments(l: &Letter<'_>) -> char { + if l.family == family::DSEG7 { '8' } else { '~' } +} + +/// A static line of display lettering in a box `w` wide at `x`, its caps +/// centred on `cy`, aligned in the box (the ghosts and fixed legends of a +/// display), lit when `glow` > 0. +pub fn line( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (x, cy, w): (f64, f64, f64), + l: Letter<'_>, + align: TextAlign, + glow: f64, +) -> NodeId { + let mut n = text_in(s, (x, cy - cap_middle(&l), w), &l, align); + n.name = format!("display {s}"); + if glow > 0.0 { + n = parts::emit(n, l.color, glow, l.size * 0.8); + } + b.add(parent, n) +} + +/// A readout of `binding` in `l`, in a box `w` wide at `x` with its caps +/// centred on `cy`, aligned in the box; `ghost` (the unlit segments' +/// colour) prints `cells` all-segment cells under it, aligned the same. +#[allow(clippy::too_many_arguments)] +pub fn readout( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + binding: Binding, + (x, cy, w): (f64, f64, f64), + l: Letter<'_>, + align: TextAlign, + ghost: Option<(&str, usize)>, + format: ValueFormat, +) -> NodeId { + let h = (l.size * 1.6).ceil(); + let root = b.add(parent, draw::frame(name, x, cy - h / 2.0, w, h)); + let top = h / 2.0 - cap_middle(&l); + if let Some((color, cells)) = ghost { + let s: String = std::iter::repeat_n(all_segments(&l), cells).collect(); + let mut g = text_in(&s, (0.0, top, w), &l.color(color), align); + g.name = "unlit segments".into(); + b.add(root, g); + } + let mut t = text_in("", (0.0, top, w), &l, align); + t.name = "value".into(); + let value = b.add(root, parts::emit(t, l.color, 0.8, l.size * 0.7)); + let mut c = Control::new(ControlKind::Readout).slot(SlotRole::Value, value.0); + c.binding = Some(binding); + c.format = Some(format); + b.controls.insert(root, c); + root +} diff --git a/examples/plugin-faces/src/rigkit/lettering.rs b/examples/plugin-faces/src/rigkit/lettering.rs new file mode 100644 index 00000000..824fd012 --- /dev/null +++ b/examples/plugin-faces/src/rigkit/lettering.rs @@ -0,0 +1,389 @@ +//! Display lettering, set as real text from the font book. +//! +//! Logos and model names are most of what makes a unit read as *that* +//! maker's: a wide engraved sans, geometric tech caps, a blackletter. Each +//! is a text node in a bundled family (`draw::family::*`) at its real +//! weight, italic and width; the book measures it so it can be centred on +//! its caps, fitted to a width, stretched or turned. + +use frame::document::proto::{NodeId, TextAlign, Transform}; +use frame::render::font_book::{Chain, FontBook, FontQuery}; +use frame::surface::kit::draw::{self, Lettering, family}; +use frame::surface::widgets::Builder; + +/// How a line of display lettering is set. +#[derive(Clone, Copy)] +pub struct Letter<'a> { + pub size: f64, + pub weight: f64, + pub italic: bool, + /// Letter spacing, em. + pub track: f64, + pub color: &'a str, + /// Horizontal scale: < 1 condenses, > 1 extends (a family with a + /// width axis sets it as its width; any other is scaled). + pub stretch: f64, + /// A bundled family (`draw::family::*`). + pub family: &'a str, +} + +impl<'a> Letter<'a> { + #[must_use] + pub const fn new(size: f64, weight: f64, color: &'a str) -> Self { + Self { + size, + weight, + italic: false, + track: 0.0, + color, + stretch: 1.0, + family: family::INTER, + } + } + #[must_use] + pub const fn italic(mut self) -> Self { + self.italic = true; + self + } + #[must_use] + pub const fn track(mut self, t: f64) -> Self { + self.track = t; + self + } + #[must_use] + pub const fn stretch(mut self, s: f64) -> Self { + self.stretch = s; + self + } + #[must_use] + pub const fn color(mut self, c: &'a str) -> Self { + self.color = c; + self + } + #[must_use] + pub const fn family(mut self, f: &'a str) -> Self { + self.family = f; + self + } + #[must_use] + pub const fn size(mut self, s: f64) -> Self { + self.size = s; + self + } + #[must_use] + pub const fn weight(mut self, w: f64) -> Self { + self.weight = w; + self + } + + /// Whether the family draws width on an axis (Saira): the stretch is + /// then its width, not a scale. + fn axis_width(&self) -> bool { + self.family == family::SAIRA + } + + fn width_pct(&self) -> f64 { + if self.axis_width() { + (self.stretch * 100.0).clamp(50.0, 125.0) + } else { + 100.0 + } + } + + /// The horizontal scale left to the node's transform. + fn scale(&self) -> f64 { + if self.axis_width() { 1.0 } else { self.stretch } + } + + fn chain(&self) -> Chain { + FontBook::bundled().chain( + &FontQuery::family(self.family) + .weight(self.weight as f32) + .italic(self.italic) + .width(self.width_pct() as f32), + ) + } + + fn lettering(&self) -> Lettering { + let mut l = Lettering::family(self.family).weight(self.weight); + if self.italic { + l = l.italic(); + } + if self.axis_width() { + l = l.width(self.width_pct()); + } + l + } +} + +/// What the book says of a line: its set width (no trailing space, before +/// any scale), the face's ascent and cap height at the size. +struct Measure { + width: f64, + ascent: f64, + cap: f64, +} + +fn measure(s: &str, l: &Letter<'_>) -> Measure { + let chain = l.chain(); + let spacing = l.track * l.size; + let n = s.chars().filter(|c| *c != '\n').count(); + let width = chain.advance(s, l.size, spacing) - if n > 0 { spacing } else { 0.0 }; + let (ascent, cap) = chain.primary().map_or((l.size * 0.8, l.size * 0.7), |f| { + let cap = f.font().and_then(|font| { + use skrifa::MetadataProvider; + font.metrics(skrifa::instance::Size::new(l.size as f32), f.location()) + .cap_height + }); + (f.ascent(l.size), cap.map_or(l.size * 0.7, f64::from)) + }); + Measure { width, ascent, cap } +} + +/// The renderer's text anchor: glyphs start 1 px in and 1 px down. +const ANCHOR: f64 = 1.0; + +/// The text node for `s` (its box at the local origin), and the local +/// point its caps' middle-left sits at. +fn node(s: &str, l: &Letter<'_>, m: &Measure) -> (frame::document::proto::Node, (f64, f64)) { + let spacing = l.track * l.size; + let w = m.width + spacing + 2.0 * ANCHOR + 1.0; + let mut n = draw::styled_text( + "lettering", + s, + 0.0, + 0.0, + w, + l.size, + l.color, + TextAlign::Left, + &l.lettering(), + ); + if let frame::document::proto::NodeKind::Text(t) = &mut n.kind { + t.letter_spacing = spacing; + } + n.name = format!("lettering {s}"); + n.size.height = (m.ascent + l.size * 0.3 + 2.0 * ANCHOR).ceil(); + (n, (ANCHOR, ANCHOR + m.ascent - m.cap / 2.0)) +} + +/// How far under a text node's top the middle of its caps falls, set in `l`. +#[must_use] +pub fn cap_middle(l: &Letter<'_>) -> f64 { + let m = measure("0", l); + ANCHOR + m.ascent - m.cap / 2.0 +} + +/// A text node in `l`'s family, weight and spacing (no transform): what a +/// readout's `Value` slot or a static line of lettering is made of. +#[must_use] +pub fn text_in( + s: &str, + (x, y, w): (f64, f64, f64), + l: &Letter<'_>, + align: TextAlign, +) -> frame::document::proto::Node { + let m = measure("0", l); + let mut n = draw::styled_text("text", s, x, y, w, l.size, l.color, align, &l.lettering()); + if let frame::document::proto::NodeKind::Text(t) = &mut n.kind { + t.letter_spacing = l.track * l.size; + } + n.size.height = (m.ascent + l.size * 0.3 + 2.0 * ANCHOR).ceil(); + n +} + +/// The set width of `s` in `l` (scaled by its stretch). +#[must_use] +pub fn set_width(s: &str, l: &Letter<'_>) -> f64 { + measure(s, l).width * l.scale() +} + +/// Display lettering centred on (`cx`, `cy`) — `cy` the middle of the caps. +pub fn letter( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + l: Letter<'_>, +) -> NodeId { + letter_aligned(b, parent, s, (cx, cy), l, TextAlign::Center) +} + +/// Display lettering turned by `deg` (clockwise) about its own centre, +/// which lands on (`cx`, `cy`) — a legend printed along a knob's slot, +/// a vertical label. +pub fn letter_turned( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + deg: f64, + l: Letter<'_>, +) -> NodeId { + // Renderers set a text run level, so a turned line is drawn as the + // family's own glyph outlines, turned (the node keeps its characters). + let m = measure(s, &l); + let (d, w) = outline(s, &l); + let r = w.max(l.size) / 2.0 + 2.0; + let (sn, cs) = deg.to_radians().sin_cos(); + // The caps' middle in the outline's space: baseline 0.78·size, cap up. + let (x0, y0) = (w / 2.0, l.size * 0.78 - m.cap / 2.0); + let turn = |x: f64, y: f64| { + let (dx, dy) = (x - x0, y - y0); + (r + cs * dx - sn * dy, r + sn * dx + cs * dy) + }; + let d = map_path(&d, turn); + let mut n = draw::text( + "lettering", + s, + cx - r, + cy - r, + 2.0 * r, + l.size, + l.color, + TextAlign::Center, + ); + l.lettering().apply(&mut n); + n.name = format!("lettering {s}"); + n.size.height = 2.0 * r; + n.fill_geometry = vec![d]; + b.add(parent, n) +} + +/// Every coordinate pair of an absolute path mapped through `f`. +fn map_path(d: &str, f: impl Fn(f64, f64) -> (f64, f64)) -> String { + let mut out = String::new(); + let mut nums: Vec = Vec::new(); + let flush = |nums: &mut Vec, out: &mut String| { + for pair in nums.chunks(2) { + if let [x, y] = pair { + let (u, v) = f(*x, *y); + out.push_str(&format!("{u:.2} {v:.2} ")); + } + } + nums.clear(); + }; + for t in d.split_whitespace() { + if let Ok(v) = t.parse::() { + nums.push(v); + } else { + flush(&mut nums, &mut out); + out.push_str(t); + out.push(' '); + } + } + flush(&mut nums, &mut out); + out +} + +/// Display lettering sized so it sets `width` wide, centred on (`cx`, `cy`); +/// returns the node and the size it was set at. +pub fn letter_fit( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + width: f64, + l: Letter<'_>, +) -> (NodeId, f64) { + let probe = Letter { size: 100.0, ..l }; + let w = set_width(s, &probe); + let size = if w > 0.0 { 100.0 * width / w } else { l.size }; + (letter(b, parent, s, (cx, cy), Letter { size, ..l }), size) +} + +/// Display lettering anchored at `x` by `align`, the caps centred on `cy`. +pub fn letter_aligned( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (x, cy): (f64, f64), + l: Letter<'_>, + align: TextAlign, +) -> NodeId { + let m = measure(s, &l); + let k = l.scale(); + let w = m.width * k; + let x0 = match align { + TextAlign::Center => x - w / 2.0, + TextAlign::Right => x - w, + _ => x, + }; + if (k - 1.0).abs() > 1e-6 { + // Renderers don't scale a text run: a stretched line is drawn as + // the family's own glyph outlines, stretched (characters kept). + let (d, ow) = outline(s, &l); + let top = cy - (l.size * 0.78 - m.cap / 2.0); + let mut n = draw::text( + "lettering", + s, + x0, + top, + ow.max(1.0), + l.size, + l.color, + TextAlign::Left, + ); + l.lettering().apply(&mut n); + n.name = format!("lettering {s}"); + n.size.height = l.size; + n.fill_geometry = vec![d]; + return b.add(parent, n); + } + let (mut n, (lx, ly)) = node(s, &l, &m); + n.transform = Transform::translate(x0 - lx, cy - ly); + b.add(parent, n) +} + +/// `s` as outlines (for lettering drawn as a shape — cut hollow, stroked): +/// the path in a box whose top-left is the origin, the baseline at +/// `0.78 × size`, stretched by the letter's scale; and the set width. +#[must_use] +pub fn outline(s: &str, l: &Letter<'_>) -> (String, f64) { + let spacing = l.track * l.size; + let (path, adv) = l.chain().line(s, l.size, spacing); + let (k, base) = (l.scale(), l.size * 0.78); + // Tokenise kurbo's SVG (command letters may touch their numbers). + let svg = path.to_svg(); + let mut toks: Vec = Vec::new(); + let mut tok = String::new(); + for ch in svg.chars() { + if ch.is_ascii_alphabetic() && ch != 'e' { + if !tok.is_empty() { + toks.push(std::mem::take(&mut tok)); + } + toks.push(ch.to_string()); + } else if ch == ' ' || ch == ',' { + if !tok.is_empty() { + toks.push(std::mem::take(&mut tok)); + } + } else { + tok.push(ch); + } + } + if !tok.is_empty() { + toks.push(tok); + } + let mut out = String::new(); + let mut nums: Vec = Vec::new(); + let flush = |nums: &mut Vec, out: &mut String| { + for pair in nums.chunks(2) { + if let [x, y] = pair { + out.push_str(&format!("{:.2} {:.2} ", x * k, y + base)); + } + } + nums.clear(); + }; + for t in toks { + if let Ok(v) = t.parse::() { + nums.push(v); + } else { + flush(&mut nums, &mut out); + out.push_str(&t); + out.push(' '); + } + } + flush(&mut nums, &mut out); + let n = s.chars().count(); + (out, (adv - if n > 0 { spacing } else { 0.0 }) * k) +} diff --git a/examples/plugin-faces/src/rigkit/mod.rs b/examples/plugin-faces/src/rigkit/mod.rs new file mode 100644 index 00000000..51646cf4 --- /dev/null +++ b/examples/plugin-faces/src/rigkit/mod.rs @@ -0,0 +1,637 @@ +//! What the amp-suite recreations share: the pedal, amp-head, cab and +//! tabletop scaffolds, placing kit parts by their centre, the silkscreen +//! and the catalog entries a unit binds. +//! +//! Forms follow `docs/form-factors.md`: pedals are one height (286 design +//! px) and a class width (`panel.pedal`); treadles 241 × 600; amp heads +//! 1200 × 510-ish (the plugin's faceplate aspect); cabs 900 × 900; +//! tabletop EQ units at their render's aspect. The view system isn't built +//! yet, so every unit is one page of its own. + +use frame::document::proto::{Node, NodeId, NodeKind, TextAlign, TextCase, TextData}; +use frame::surface::kit::{Ink as KitInk, Place, TOUCH_MIN, draw}; +use frame::surface::proto::{ParamInfo, Range, SlotRole, Unit}; +use frame::surface::widgets::Builder; + +use crate::delay::parts::{self, Ink}; + +pub mod lcd; +pub mod lettering; +pub use lettering::{Letter, letter, letter_aligned, letter_fit}; + +pub use crate::delay::parts::{bind, choice, hexa, mixed, percent, toggle}; + +/// The pedal height every class shares. +pub const PEDAL_H: f64 = 286.0; + +/// The top of a pedal's footswitch band (the bottom 30 %). +pub const BAND: f64 = PEDAL_H * 0.7; + +/// How a pedal's enclosure looks. +#[derive(Clone, Copy)] +pub struct Enclosure<'a> { + /// `panel.pedal` class: `micro`, `nano`, `standard`, `compact`, `wide`, + /// `medium`, `large`, `bigbox`, `treadle`. + pub class: &'a str, + pub color: &'a str, + /// `powder`, `brushed`, `satin`, `gloss`. + pub finish: &'a str, + /// The base under the lid (`None` = the paint, darker). + pub base: Option<&'a str>, + /// `side`, `top`, `both`, `none`. + pub jacks: &'a str, + /// Corner radius (`None` = the kit's). + pub radius: Option, +} + +impl<'a> Enclosure<'a> { + #[must_use] + pub const fn new(class: &'a str, color: &'a str, finish: &'a str) -> Self { + Self { + class, + color, + finish, + base: None, + jacks: "side", + radius: None, + } + } + #[must_use] + pub const fn base(mut self, c: &'a str) -> Self { + self.base = Some(c); + self + } + #[must_use] + pub const fn jacks(mut self, j: &'a str) -> Self { + self.jacks = j; + self + } + #[must_use] + pub const fn radius(mut self, r: f64) -> Self { + self.radius = Some(r); + self + } + /// The class's footprint. + #[must_use] + pub fn size(&self) -> (f64, f64) { + frame::surface::kit::panels::pedal::class_size(self.class) + } +} + +/// Size `root` to the class and place the enclosure; returns the panel +/// frame (the whole footprint — place parts in its coordinates). +pub fn pedal_body(b: &mut Builder<'_>, root: NodeId, e: &Enclosure<'_>) -> NodeId { + let (w, h) = e.size(); + parts::fit_root(b, root, w, h); + let mut opts = vec![ + format!("color={}", e.color), + format!("finish={}", e.finish), + format!("jacks={}", e.jacks), + ]; + if let Some(base) = e.base { + opts.push(format!("base={base}")); + } + if let Some(r) = e.radius { + opts.push(format!("radius={r}")); + } + let p = Place { + options: Some(opts), + ..Place::new("Enclosure").variant(e.class) + }; + parts::put_at(b, root, "panel.pedal_3d", &p, 0.0, 0.0) +} + +/// A knob style as a unit uses it. +#[derive(Clone, Copy)] +pub struct KnobStyle<'a> { + pub id: &'a str, + pub variant: &'a str, + pub d: f64, + /// Extra `Place::options` (`dots=21`). + pub opts: &'a [&'a str], + /// Panel ink for anything the kit prints round it. + pub ink: &'a str, + /// The hit box's least edge (default the lint's touch minimum). + pub touch: f64, +} + +impl<'a> KnobStyle<'a> { + #[must_use] + pub const fn new(id: &'a str, variant: &'a str, d: f64, ink: &'a str) -> Self { + Self { + id, + variant, + d, + opts: &[], + ink, + touch: TOUCH_MIN, + } + } + #[must_use] + pub const fn opts(mut self, o: &'a [&'a str]) -> Self { + self.opts = o; + self + } + #[must_use] + pub const fn touch(mut self, t: f64) -> Self { + self.touch = t; + self + } + #[must_use] + pub const fn size(mut self, d: f64) -> Self { + self.d = d; + self + } +} + +/// A knob with its shaft at (`cx`, `cy`), a tight box, the kit's legend +/// off and the unit's own legend printed centred at `legend_y` (as the +/// control's `Label`). An empty `legend` prints nothing (the control is +/// still named). +#[allow(clippy::too_many_arguments)] +pub fn knob( + b: &mut Builder<'_>, + parent: NodeId, + s: &KnobStyle<'_>, + addr: &str, + legend: &str, + (cx, cy): (f64, f64), + legend_y: f64, + ink: Ink<'_>, +) -> NodeId { + let mut p = Place::new(if legend.is_empty() { addr } else { legend }) + .size(s.d) + .bind(bind(addr)) + .touch(s.touch) + .ink(KitInk::Color(s.ink.into())); + if !s.variant.is_empty() { + p = p.variant(s.variant); + } + if !s.opts.is_empty() { + p.options = Some(s.opts.iter().map(ToString::to_string).collect()); + } + let k = parts::put(b, parent, s.id, &p, cx, cy); + parts::strip_legend(b, k); + if !legend.is_empty() { + parts::label_at(b, k, legend, (cx, legend_y), ink); + } + k +} + +/// [`knob`] with its legend cut as lettering at the ink's real weight. +#[allow(clippy::too_many_arguments)] +pub fn knob_lt( + b: &mut Builder<'_>, + parent: NodeId, + s: &KnobStyle<'_>, + addr: &str, + legend: &str, + at: (f64, f64), + legend_y: f64, + ink: Ink<'_>, +) -> NodeId { + let k = knob(b, parent, s, addr, "", at, legend_y, ink); + if !legend.is_empty() { + label(b, k, legend, (at.0, legend_y), ink); + } + k +} + +/// A control's printed legend, cut as lettering at the ink's real weight, +/// centred on panel (`cx`, `cy`) (the control's parent's coordinates), as +/// its `Label` slot. +pub fn label( + b: &mut Builder<'_>, + control: NodeId, + s: &str, + (cx, cy): (f64, f64), + ink: Ink<'_>, +) -> NodeId { + let (tx, ty) = b + .scene + .node(control) + .map_or((0.0, 0.0), |n| (n.transform.tx, n.transform.ty)); + let text = if ink.upper { + s.to_uppercase() + } else { + s.to_string() + }; + let id = letter( + b, + control, + &text, + (cx - tx, cy - ty), + Letter::new(ink.size, ink.weight, ink.color).track(ink.track), + ); + if let Some(c) = b.controls.get_mut(&control) { + c.slots.retain(|s| s.role != SlotRole::Label); + c.slots.push(frame::surface::proto::Slot { + role: SlotRole::Label, + node: id.0, + }); + } + id +} + +/// [`knob`] with its legend set in display lettering `l` (capitals). +#[allow(clippy::too_many_arguments)] +pub fn knob_l( + b: &mut Builder<'_>, + parent: NodeId, + s: &KnobStyle<'_>, + addr: &str, + legend: &str, + at: (f64, f64), + legend_y: f64, + l: Letter<'_>, +) -> NodeId { + let k = knob( + b, + parent, + s, + addr, + "", + at, + legend_y, + Ink::new(l.size, l.color), + ); + if !legend.is_empty() { + label_l(b, k, &legend.to_uppercase(), (at.0, legend_y), l); + } + k +} + +/// A control's printed legend in display lettering `l`, centred on panel +/// (`cx`, `cy`), as its `Label` slot. +pub fn label_l( + b: &mut Builder<'_>, + control: NodeId, + s: &str, + (cx, cy): (f64, f64), + l: Letter<'_>, +) -> NodeId { + let (tx, ty) = b + .scene + .node(control) + .map_or((0.0, 0.0), |n| (n.transform.tx, n.transform.ty)); + let id = letter(b, control, s, (cx - tx, cy - ty), l); + if let Some(c) = b.controls.get_mut(&control) { + c.slots.retain(|s| s.role != SlotRole::Label); + c.slots.push(frame::surface::proto::Slot { + role: SlotRole::Label, + node: id.0, + }); + } + id +} + +/// Lettering from a silkscreen `Ink` (its size, weight, tracking, case), +/// centred on (`cx`, `cy`). +pub fn print( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + ink: Ink<'_>, +) -> NodeId { + let text = if ink.upper { + s.to_uppercase() + } else { + s.to_string() + }; + letter( + b, + parent, + &text, + (cx, cy), + Letter::new(ink.size, ink.weight, ink.color).track(ink.track), + ) +} + +/// The stomp switch, its actuator centred on (`cx`, `cy`), its LED `led` +/// (dx, dy) from it. `style` is the LED's `bezel` or `dot`. +#[allow(clippy::too_many_arguments)] +pub fn stomp( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + d: f64, + led_color: &str, + (dx, dy): (f64, f64), + led_size: f64, + style: &str, + momentary: bool, +) -> NodeId { + let mut opts = vec![ + format!("color={led_color}"), + format!("led_dx={dx}"), + format!("led_dy={dy}"), + format!("led_size={led_size}"), + format!("led_style={style}"), + ]; + if momentary { + opts.push("momentary".into()); + } + let p = Place { + options: Some(opts), + ..Place::new(name).size(d).bind(bind(addr)).touch(TOUCH_MIN) + }; + let root = + frame::surface::kit::place(b, parent, "switch.stomp", &p).unwrap_or_else(|e| panic!("{e}")); + // Centre the actuator (the `cap`) on the point. + let cap = b.scene.node(root).and_then(|n| { + n.children.iter().find_map(|c| { + b.scene.node(*c).filter(|k| k.name == "cap").map(|k| { + ( + k.transform.tx + k.size.width / 2.0, + k.transform.ty + k.size.height / 2.0, + ) + }) + }) + }); + let (ax, ay) = cap.unwrap_or((0.0, 0.0)); + parts::move_to(b, root, cx - ax, cy - ay); + root +} + +/// A mini toggle (`switch.toggle_nut`, chrome bat) centred at (`cx`, +/// `cy`), size = its nut; the unit prints its positions. +pub fn mini_toggle( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + size: f64, + variant: &str, +) -> NodeId { + let p = Place::new(name) + .size(size) + .variant(variant) + .bind(bind(addr)) + .legend("") + .touch(TOUCH_MIN); + let t = parts::put(b, parent, "switch.toggle_nut", &p, cx, cy); + parts::strip_legend(b, t); + t +} + +/// A text node set in `ink`, `w` wide from `x`, centred on `cy`, with a +/// custom font weight and case; `italic` slants it (a synthetic oblique +/// via the text's own skew is not available — Inter Italic is used). +#[must_use] +pub fn text(s: &str, x: f64, cy: f64, w: f64, ink: &Ink<'_>, align: TextAlign) -> Node { + parts::text_node(s, x, cy, w, ink, align) +} + +/// Silkscreen centred on (`cx`, `cy`). +pub fn silk( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + cx: f64, + cy: f64, + ink: Ink<'_>, +) -> NodeId { + parts::silk(b, parent, s, cx, cy, ink) +} + +/// Lettering that lights (an LED readout, a lit logo). +pub fn lit_text( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + ink: Ink<'_>, + glow: f64, +) -> NodeId { + let w = s.chars().count() as f64 * ink.size * (0.72 + ink.track) + ink.size * 2.0; + let t = parts::text_node(s, cx - w / 2.0, cy, w, &ink, TextAlign::Center); + b.add(parent, parts::emit(t, ink.color, glow, ink.size)) +} + +/// A text node's `TextData`, for restyling after placing. +pub fn restyle(b: &mut Builder<'_>, id: NodeId, f: impl FnOnce(&mut TextData)) { + if let Some(n) = b.scene.node_mut(id) + && let NodeKind::Text(t) = &mut n.kind + { + f(t); + } +} + +/// Set a text node in capitals or as written. +pub fn case(b: &mut Builder<'_>, id: NodeId, upper: bool) { + restyle(b, id, |t| { + t.text_case = if upper { + TextCase::Upper + } else { + TextCase::Original + } + }); +} + +/// A filled vector path in `parent`'s coordinates over its `(w, h)`. +pub fn shape( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (w, h): (f64, f64), + d: String, + color: &str, +) -> NodeId { + b.add( + parent, + draw::path(name, 0.0, 0.0, w, h, d, draw::solid(color)), + ) +} + +/// A stroked open polyline. +pub fn line( + b: &mut Builder<'_>, + parent: NodeId, + pts: &[(f64, f64)], + weight: f64, + color: &str, +) -> NodeId { + parts::line(b, parent, "line", pts, weight, color) +} + +/// A smooth stroked curve through `pts` (Catmull-Rom as cubic Béziers), +/// in `parent`'s `(w, h)` box. +pub fn curve( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (w, h): (f64, f64), + pts: &[(f64, f64)], + weight: f64, + color: &str, +) -> NodeId { + let mut d = String::new(); + if let Some((x, y)) = pts.first() { + d.push_str(&format!("M {x:.2} {y:.2} ")); + } + for i in 0..pts.len().saturating_sub(1) { + let p0 = pts[i.saturating_sub(1)]; + let p1 = pts[i]; + let p2 = pts[i + 1]; + let p3 = pts[(i + 2).min(pts.len() - 1)]; + let c1 = (p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0); + let c2 = (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0); + d.push_str(&format!( + "C {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} ", + c1.0, c1.1, c2.0, c2.1, p2.0, p2.1 + )); + } + let mut n = draw::path(name, 0.0, 0.0, w, h, d, draw::solid(color)); + n.fills = Vec::new(); + n.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(color)], + weight, + cap: frame::document::proto::StrokeCap::Round, + join: frame::document::proto::StrokeJoin::Round, + align: frame::document::proto::StrokeAlign::Center, + ..frame::document::proto::Stroke::default() + }; + b.add(parent, n) +} + +/// An LED with no control of its own, lit (decoration: a meter's lamp, a +/// status dot on a display). +pub fn led( + b: &mut Builder<'_>, + parent: NodeId, + (cx, cy): (f64, f64), + d: f64, + color: &str, + lit: bool, +) -> NodeId { + if lit { + b.add(parent, parts::lens(cx, cy, d / 2.0, color)) + } else { + b.add(parent, parts::dark_lens(cx, cy, d / 2.0, color)) + } +} + +/// Give a placed control's `Needle`/`Label` less say: the node the +/// control's `Label` slot names. +#[must_use] +pub fn label_of(b: &Builder<'_>, control: NodeId) -> Option { + b.controls + .get(&control) + .and_then(|c| c.slots_of(SlotRole::Label).next()) + .map(NodeId) +} + +// ── catalog entries ────────────────────────────────────────────────────── + +/// An amp's 0–10 knob: 0 … 10 in tenths, `default` on that scale. +#[must_use] +pub fn dial10(addr: &str, name: &str, default: f64) -> ParamInfo { + parts::param( + addr, + name, + Range { + steps: Some(101), + ..Range::linear(0.0, 10.0, Unit::None) + }, + default, + ) +} + +/// A dB param. +#[must_use] +pub fn db(addr: &str, name: &str, min: f64, max: f64, default: f64) -> ParamInfo { + parts::db(addr, name, min, max, default) +} + +/// A time in ms, log. +#[must_use] +pub fn ms(addr: &str, name: &str, min: f64, max: f64, default: f64) -> ParamInfo { + parts::time_ms(addr, name, min, max, default) +} + +/// A frequency in Hz, log. +#[must_use] +pub fn hz(addr: &str, name: &str, min: f64, max: f64, default: f64) -> ParamInfo { + parts::param(addr, name, Range::log(min, max, Unit::Hz), default) +} + +/// A rate in Hz, log. +#[must_use] +pub fn rate(addr: &str, name: &str, min: f64, max: f64, default: f64) -> ParamInfo { + hz(addr, name, min, max, default) +} + +/// Musical divisions. +pub use crate::delay::parts::{DEFAULT_DIV, DIVISIONS}; + +/// A gain-reduction stream. +#[must_use] +pub fn gr_stream(id: &str, name: &str) -> frame::surface::proto::StreamInfo { + frame::surface::proto::StreamInfo { + id: frame::surface::proto::EntryId::new(id), + name: name.into(), + kind: frame::surface::proto::StreamKind::GainReduction, + rate_hz: 30.0, + } +} + +/// A level stream. +#[must_use] +pub fn level_stream(id: &str, name: &str, channels: u8) -> frame::surface::proto::StreamInfo { + parts::level_stream(id, name, channels) +} + +/// A row of round LEDs lit as gain reduction deepens, one per threshold +/// (dB of reduction, shallowest first), laid out left to right over +/// `x0..x1` at `cy`; `rtl` lights them from the right (the shallowest +/// lamp at the right end). Unbound decoration round them is the face's. +#[allow(clippy::too_many_arguments)] +pub fn gr_dots( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + stream: &str, + (x0, x1, cy): (f64, f64, f64), + d: f64, + thresholds: &[f64], + color: &str, + rtl: bool, +) -> NodeId { + use frame::surface::proto::{Control, ControlKind, Curve, CurvePoint}; + let n = thresholds.len().max(1); + let pitch = (x1 - x0) / n as f64; + let h = d + 4.0; + let root = b.add(parent, draw::frame(name, x0, cy - h / 2.0, x1 - x0, h)); + let deepest = thresholds.iter().copied().fold(1.0, f64::max); + let (floor, ceiling) = (0.0, -deepest); + let mut pts = vec![CurvePoint { x: 0.0, y: 0.0 }]; + for (i, t) in thresholds.iter().enumerate() { + pts.push(CurvePoint { + x: (t / deepest).clamp(0.0, 1.0), + y: (i as f64 + 1.0) / n as f64, + }); + } + pts.sort_by(|a, b| a.x.total_cmp(&b.x)); + pts.dedup_by(|a, b| (a.x - b.x).abs() < 1e-9); + let mut binding = frame::surface::proto::Binding::stream(stream); + binding.curve = Curve::Points { points: pts }; + let mut c = Control::new(ControlKind::Gauge { + sweep_degrees: 0.0, + floor_db: floor, + ceiling_db: ceiling, + }); + c.binding = Some(binding); + for i in 0..n { + let slot = if rtl { n - 1 - i } else { i }; + let cx = pitch * (slot as f64 + 0.5); + b.add(root, parts::dark_lens(cx, h / 2.0, d / 2.0, color)); + let lit = b.add(root, parts::lens(cx, h / 2.0, d / 2.0, color)); + c = c.slot(SlotRole::Led, lit.0); + } + b.controls.insert(root, c); + root +} diff --git a/examples/plugin-faces/src/saturate.rs b/examples/plugin-faces/src/saturate.rs new file mode 100644 index 00000000..36dfddc7 --- /dev/null +++ b/examples/plugin-faces/src/saturate.rs @@ -0,0 +1,250 @@ +//! Faces: saturation — five circuits, one per family of the FTS +//! saturator's profiles (`saturate-ui/src/faces.rs`'s `SatDesign`s), each +//! drawn as the box that circuit lives in. +//! +//! - **Tube** — a valve preamp: the valves lit behind a window, the stage +//! selector (triode / pentode), output VU, brown bakelite. +//! - **Tape** — a studio tape machine's electronics: a meter bridge with +//! two VUs, tape speed and formulation selectors, record level and bias. +//! - **Transformer** — iron and copper: the core and its winding drawn in +//! an open chassis, warming as it is driven; wing knobs, LED meters. +//! - **Transistor** — a fuzz pedal: hammertone enclosure, a stomp switch, +//! the circuit's own symbol printed large. +//! - **Digital** — a clipper / bit-crusher: an LCD drawing the staircase +//! the word length makes, bits and rate on the glass. +//! +//! Every control but drive, mix and output is a *trim* around the circuit +//! as designed (noon = the profile's voicing), so those print − 0 + and read in % off the voicing. +//! Drive, mix and output sit in the same places on every face. + +mod digital; +mod hw; +mod tape; +mod transformer; +mod transistor; +mod tube; + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::{Place, draw}; +use frame::surface::proto::{Condition, Control, ControlKind, ParamInfo, Range, SlotRole, Unit}; +use frame::surface::widgets::Builder; + +use crate::delay::parts; +use crate::face::Face; + +pub fn faces() -> Vec { + vec![ + tube::face(), + tape::face(), + transformer::face(), + transistor::face(), + digital::face(), + tube::tall_face(), + tape::tall_face(), + transformer::tall_face(), + transistor::tall_face(), + digital::tall_face(), + ] +} + +/// What a trim prints: its ends and noon (the circuit as designed). +pub(crate) const TRIM_MARKS: &[(f64, &str)] = &[(0.0, "-"), (0.5, "0"), (1.0, "+")]; +/// Output's printed scale over −24 … +12 dB. +pub(crate) const OUT_MARKS: &[(f64, &str)] = &[ + (0.0, "-24"), + (12.0 / 36.0, "-12"), + (24.0 / 36.0, "0"), + (1.0, "+12"), +]; + +/// Output: −24 … +12 dB in whole dB, bipolar about unity. +pub(crate) fn output(addr: &str) -> frame::surface::proto::ParamInfo { + let r = frame::surface::proto::Range { + steps: Some(37), + ..frame::surface::proto::Range::linear(-24.0, 12.0, frame::surface::proto::Unit::Db) + }; + crate::delay::parts::param(addr, "Output", r, 0.0) + .bipolar() + .featured() +} + +// ── Emphasis ────────────────────────────────────────────────────────────── +// +// Every saturator drives its stage through an emphasis EQ (and undoes it +// after with the mirror, never edited): six fixed bands, each a Bell, Low +// Shelf or High Shelf (a cut has no inverse) over 20 Hz–20 kHz, ±12 dB, +// Q 0.1–18. An EMPHASIS key on each face (`/emphasis_view`) swaps the +// face's picture for the curve — `viz.eq_graph`, its six bands fixed, in +// the face's own colour — while the knobs stay. The bands are +// `/emph/b{n}_{shape,freq,gain,q}`, n = 1..=6. + +/// Emphasis bands per saturator. +pub(crate) const EMPH_BANDS: usize = 6; +/// The bands' default frequencies: a useful spread, all idle at 0 dB. +pub(crate) const EMPH_FREQS: [f64; EMPH_BANDS] = [80.0, 250.0, 700.0, 1800.0, 4500.0, 10_000.0]; +/// The shapes a band takes, by `b{n}_shape` index (the first three of the +/// EQ graph's). +pub(crate) const EMPH_SHAPES: &[&str] = &["Bell", "Low Shelf", "High Shelf"]; + +/// `/emphasis_view`: on, the face shows its emphasis EQ. +fn view(ns: &str) -> String { + format!("{ns}/emphasis_view") +} + +/// The emphasis params of saturator `ns`: the view toggle and six bands. +pub(crate) fn emphasis_params(ns: &str) -> Vec { + let mut v = vec![parts::toggle(&view(ns), "Emphasis View", false).short("Emphasis")]; + for (i, f) in EMPH_FREQS.iter().enumerate() { + let n = i + 1; + let addr = |k: &str| format!("{ns}/emph/b{n}_{k}"); + let group = format!("Emphasis {n}"); + v.push( + parts::choice(&addr("shape"), &format!("Emph {n} Shape"), EMPH_SHAPES, 0) + .in_group(group.clone()) + .short("Shape"), + ); + v.push( + parts::param( + &addr("freq"), + &format!("Emph {n} Freq"), + Range::log(20.0, 20_000.0, Unit::Hz), + *f, + ) + .in_group(group.clone()) + .short("Freq"), + ); + v.push( + parts::param( + &addr("gain"), + &format!("Emph {n} Gain"), + Range::linear(-12.0, 12.0, Unit::Db), + 0.0, + ) + .bipolar() + .in_group(group.clone()) + .short("Gain"), + ); + v.push( + parts::param( + &addr("q"), + &format!("Emph {n} Q"), + Range::log(0.1, 18.0, Unit::None), + 0.707, + ) + .in_group(group) + .short("Q"), + ); + } + v +} + +/// A layer the size of `parent`, drawn only while `when` holds: a frame +/// carrying the condition on a readout of `addr` whose text is never seen. +fn layer( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + addr: &str, + when: Condition, +) -> NodeId { + let layer = b.add(parent, draw::frame(name, x, y, w, h)); + let mut seen = draw::text("seen", "", 0.0, 0.0, 1.0, 1.0, "#00000000", TextAlign::Left); + seen.opacity = 0.0; + let seen = b.add(layer, seen); + b.controls.insert( + layer, + Control::new(ControlKind::Readout) + .slot(SlotRole::Value, seen.0) + .bound(parts::bind(addr)) + .shown_if(when), + ); + layer +} + +/// The layer a face draws its picture (and the meters beside it) into: +/// the whole panel `(w, h)`, hidden while the emphasis EQ shows. +pub(crate) fn picture(b: &mut Builder<'_>, panel: NodeId, ns: &str, (w, h): (f64, f64)) -> NodeId { + layer( + b, + panel, + "Picture", + (0.0, 0.0, w, h), + &view(ns), + Condition::on(view(ns)).negated(), + ) +} + +/// The emphasis EQ in `(x, y, w, h)` of `panel`, shown while the view is +/// on: a recessed well holding the six-band graph in `tint`. +pub(crate) fn emphasis_eq( + b: &mut Builder<'_>, + panel: NodeId, + ns: &str, + (x, y, w, h): (f64, f64, f64, f64), + tint: &str, +) { + let shown = layer( + b, + panel, + "Emphasis EQ", + (x, y, w, h), + &view(ns), + Condition::on(view(ns)), + ); + let well = parts::well( + b, + shown, + "Emphasis well", + (0.0, 0.0, w, h), + draw::solid("#050505"), + 6.0, + ); + let inset = 3.0; + let opts = [ + format!("ns={ns}/emph"), + format!("bands={EMPH_BANDS}"), + "fixed".to_string(), + "no_analyser".to_string(), + format!("shapes={}", EMPH_SHAPES.len()), + "db_range=12".to_string(), + format!("tint={tint}"), + format!("h={}", h - 2.0 * inset), + ]; + let p = Place { + options: Some(opts.to_vec()), + ..Place::new("Emphasis").size(w - 2.0 * inset) + }; + parts::put_at(b, well, "viz.eq_graph", &p, inset, inset); + parts::silk_left( + b, + well, + "Drive emphasis", + 10.0, + 12.0, + parts::Ink::new(6.0, tint).weight(800.0).track(0.2), + ); +} + +/// The EMPHASIS key at (`cx`, `cy`), `w` wide: a latching console key +/// (`variant`) whose lamp lights in `tint` while the EQ shows. +pub(crate) fn emphasis_key( + b: &mut Builder<'_>, + panel: NodeId, + ns: &str, + (cx, cy): (f64, f64), + w: f64, + variant: &str, + tint: &str, +) -> NodeId { + let opts = [format!("h={}", (w * 0.36).round()), format!("color={tint}")]; + let p = Place { + options: Some(opts.to_vec()), + ..Place::new("Emphasis") + .size(w) + .variant(variant) + .legend("Emphasis") + .bind(parts::bind(&view(ns))) + }; + parts::put(b, panel, "button.key", &p, cx, cy) +} diff --git a/examples/plugin-faces/src/saturate/digital.rs b/examples/plugin-faces/src/saturate/digital.rs new file mode 100644 index 00000000..0a8b4e88 --- /dev/null +++ b/examples/plugin-faces/src/saturate/digital.rs @@ -0,0 +1,523 @@ +//! The digital stage: a clipper and bit-crusher that doesn't pretend to be +//! anything else. +//! +//! Matte black, one colour. The LCD draws the transfer curve honestly — a +//! staircase, because a short word *is* a staircase, flattened at the +//! ceiling where the clipper stops it — with the word length, sample rate +//! and ceiling read out beside it. Two keys pick the family's profiles +//! (clip or crush). Colour-capped knobs: DRIVE, then the clipper's CEILING, +//! the crusher's BITS and RATE, TONE and DITHER, and MIX and OUTPUT last. +//! Output level on two LED columns. + +use frame::document::proto::{NodeId, StrokeCap, TextAlign}; +use frame::surface::kit::{Place, draw}; +use frame::surface::proto::{Range, Unit, ValueFormat}; +use frame::surface::widgets::Builder; + +use crate::delay::art; +use crate::delay::parts::{self, Ink, Screen, bind}; +use crate::face::{Face, canvas}; +use crate::lane::Shape; + +const W: f64 = 960.0; +const H: f64 = 300.0; +const INK: &str = "#e9edf6"; +const DIM: &str = "#8a92a4"; +const LCD: &str = "#ff6f9a"; +const LCD_DIM: &str = "#7a2f47"; +const MODES: &[&str] = &["Clip", "Crush"]; + +fn a(s: &str) -> String { + format!("digital_sat/{s}") +} + +/// The transfer curve: a 4-bit staircase under a -3 dB ceiling. +fn curve(b: &mut Builder<'_>, win: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + art::graticule(b, win, (x, y, w, h), (8, 4), "#ff6f9a14"); + let (cx, cy) = (x + w / 2.0, y + h / 2.0); + // Unity, dashed: what the curve bends away from. + let mut unity = parts::lines_node("unity", &[vec![(x, y + h), (x + w, y)]], 0.8, LCD_DIM); + unity.stroke.cap = StrokeCap::None; + unity.stroke.dash_pattern = vec![w.hypot(h) / 48.0; 2]; + b.add(win, unity); + let mut axes = parts::lines_node( + "axes", + &[vec![(x, cy), (x + w, cy)], vec![(cx, y), (cx, y + h)]], + 0.7, + LCD_DIM, + ); + axes.stroke.cap = StrokeCap::None; + b.add(win, axes); + // Drive 2×, a soft ceiling at 0.7, quantised to 4 bits (±7 steps). + let ceiling = 0.7_f64; + let levels = 7.0; + let mut pts = Vec::new(); + let n = 240; + let mut last: Option = None; + for i in 0..=n { + let u = -1.0 + 2.0 * f64::from(i) / f64::from(n); + let driven = (u * 2.2).tanh() / 2.2_f64.tanh(); + let clipped = driven.clamp(-ceiling, ceiling); + let q = (clipped * levels).round() / levels; + let px = cx + u * w / 2.0; + let py = cy - q * h / 2.0; + if let Some(prev) = last + && (prev - py).abs() > 0.01 + { + pts.push((px, prev)); + } + pts.push((px, py)); + last = Some(py); + } + art::trace(b, win, "transfer", &pts, 1.8, LCD); + art::tag(b, win, "In →", (x + w - 26.0, cy + 8.0), 6.0, LCD_DIM); + art::tag(b, win, "Out", (cx + 4.0, y + 6.0), 6.0, LCD_DIM); +} + +/// The LCD at `(x, y, w, h)`: the curve `cw` wide at its left, the bits, +/// rate and ceiling read out right of it. +fn lcd(b: &mut Builder<'_>, panel: NodeId, (x, y, w, h): (f64, f64, f64, f64), cw: f64) -> NodeId { + let win = parts::screen( + b, + panel, + "LCD", + (x, y, w, h), + Screen { + glass: ("#12070b", "#1b0a10"), + ink: LCD, + mesh: false, + }, + ); + curve(b, win, (12.0, 10.0, cw, h - 20.0)); + let step = (h - 22.0) / 3.0; + for (i, (tag, addr)) in [("Bits", "bits"), ("Rate", "rate"), ("Ceiling", "ceiling")] + .into_iter() + .enumerate() + { + let ry = 24.0 + step * i as f64; + art::tag(b, win, tag, (cw + 32.0, ry), 7.0, LCD_DIM); + let r = parts::readout( + b, + win, + tag, + bind(&a(addr)), + (cw + 70.0, ry, w - 12.0 - (cw + 70.0)), + 17.0, + LCD, + TextAlign::Right, + ); + if addr == "rate" { + // 8.0 kHz, 44.1 kHz — one decimal whatever the magnitude. + parts::format(b, r, ValueFormat::decimals(1)); + } + } + win +} + +pub fn face() -> Face { + let scene = canvas("Digital Saturation", |b, root| { + parts::fit_root(b, root, W, H); + let panel = parts::panel(b, root, "panel.painted", "black", W, H, "ears"); + parts::repaint( + b, + panel, + &[(0.0, "#1e2129"), (0.5, "#171a20"), (1.0, "#0f1116")], + ); + + // ── Nameplate, mode ──────────────────────────────────────────── + parts::silk_left( + b, + panel, + "Digital", + 50.0, + 34.0, + Ink::new(15.0, INK).weight(800.0).track(0.1), + ); + b.add( + panel, + draw::rect("accent", 50.0, 46.0, 30.0, 3.0, draw::solid(LCD)), + ); + parts::silk_left( + b, + panel, + "Clipper · bit-crusher", + 50.0, + 62.0, + Ink::new(6.8, DIM).weight(700.0).track(0.18), + ); + let modes = Place::new("Mode") + .size(46.0) + .variant("red") + .bind(bind(&a("mode"))) + .options(MODES); + parts::put(b, panel, "button.square_bank", &modes, 124.0, 108.0); + parts::silk( + b, + panel, + "Stage", + 124.0, + 141.0, + Ink::new(6.5, DIM).weight(800.0).track(0.22), + ); + + // ── LCD ──────────────────────────────────────────────────────── + let pic = super::picture(b, panel, "digital_sat", (W, H)); + lcd(b, pic, (250.0, 24.0, 450.0, 118.0), 250.0); + + // ── Output meters ────────────────────────────────────────────── + let (mx, my) = (744.0, 28.0); + for (i, x) in [mx, mx + 20.0].into_iter().enumerate() { + parts::led_column( + b, + pic, + if i == 0 { "Out L" } else { "Out R" }, + &a("out"), + i as u8, + (x, my, 13.0, 110.0), + 18, + (-36.0, 3.0), + ); + } + for (db, frac) in [ + ("0", 3.0 / 39.0), + ("-12", 15.0 / 39.0), + ("-24", 27.0 / 39.0), + ("-36", 1.0), + ] { + parts::silk_left( + b, + pic, + db, + mx + 38.0, + my + 3.0 + frac * 104.0, + Ink::new(6.0, DIM).weight(700.0).track(0.0), + ); + } + parts::silk( + b, + pic, + "Output", + mx + 16.0, + 154.0, + Ink::new(6.5, DIM).weight(800.0).track(0.2), + ); + super::emphasis_eq(b, panel, "digital_sat", (246.0, 18.0, 562.0, 144.0), LCD); + super::emphasis_key(b, panel, "digital_sat", (866.0, 134.0), 60.0, "black", LCD); + let clip = Place::new("Hard knee") + .size(38.0) + .variant("red") + .legend("Hard") + .bind(bind(&a("hard"))); + parts::put(b, panel, "button.square", &clip, 866.0, 74.0); + parts::silk( + b, + panel, + "Knee", + 866.0, + 102.0, + Ink::new(6.5, DIM).weight(800.0).track(0.2), + ); + + // ── Knob row ─────────────────────────────────────────────────── + parts::groove(b, panel, 40.0, W - 40.0, 170.0); + let row = 222.0; + let legend = Ink::new(8.0, INK).weight(800.0).track(0.14); + let value = Ink::new(7.5, DIM).weight(600.0).track(0.02).plain(); + let scale = Ink::new(6.5, DIM).weight(700.0).track(0.0).plain(); + let rate_marks = [ + (0.0, "1k"), + (parts::log_pos(8000.0, 1000.0, 48000.0), "8k"), + (1.0, "48k"), + ]; + let knobs: [(&str, &str, &str, f64, f64, &[(f64, &str)]); 8] = [ + ("drive", "Drive", "red", 96.0, 54.0, parts::PCT_MARKS), + ( + "ceiling", + "Ceiling", + "magenta", + 206.0, + 50.0, + &[(0.0, "-24"), (0.5, "-12"), (1.0, "0")], + ), + ( + "bits", + "Bits", + "blue", + 316.0, + 50.0, + &[ + (0.0, "1"), + (7.0 / 23.0, "8"), + (15.0 / 23.0, "16"), + (1.0, "24"), + ], + ), + ("rate", "Rate", "blue", 426.0, 50.0, &rate_marks), + ("tone", "Tone", "grey", 536.0, 44.0, super::TRIM_MARKS), + ("dither", "Dither", "grey", 636.0, 44.0, parts::PCT_MARKS), + ("mix", "Mix", "black", 746.0, 46.0, parts::PCT_MARKS), + ("output", "Output", "red", 858.0, 50.0, super::OUT_MARKS), + ]; + for (addr, name, variant, x, d, marks) in knobs { + let k = parts::knob( + b, + panel, + "knob.collet", + variant, + &a(addr), + name, + (x, row), + d, + ); + let sweep = parts::sweep_of(b, k); + parts::marks(b, panel, (x, row), d / 2.0 + 6.0, sweep, marks, 11, scale); + parts::legend_at(b, k, name, 274.0, legend); + if !matches!(addr, "bits" | "rate" | "ceiling") { + parts::value_at(b, k, 288.0, value); + } + } + }); + + Face { + file: "05-digital", + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} + +fn params() -> Vec { + vec![ + parts::percent(&a("drive"), "Drive", 40.0).featured(), + parts::param( + &a("ceiling"), + "Ceiling", + Range { + steps: Some(49), + ..Range::linear(-24.0, 0.0, Unit::Db) + }, + -3.0, + ) + .featured(), + parts::param( + &a("bits"), + "Bits", + Range { + steps: Some(24), + ..Range::linear( + 1.0, + 24.0, + Unit::Custom { + suffix: "bit".into(), + }, + ) + }, + 12.0, + ) + .featured(), + parts::param( + &a("rate"), + "Rate", + Range::log(1000.0, 48000.0, Unit::Hz), + 48000.0, + ), + parts::trim(&a("tone"), "Tone"), + parts::percent(&a("dither"), "Dither", 0.0), + parts::percent(&a("mix"), "Mix", 100.0), + super::output(&a("output")), + parts::choice(&a("mode"), "Mode", MODES, 0).featured(), + parts::toggle(&a("hard"), "Hard Knee", false), + ] + .into_iter() + .chain(super::emphasis_params("digital_sat")) + .collect() +} + +fn streams() -> Vec { + vec![parts::level_stream(&a("out"), "Output Level", 2)] +} + +/// The stage alone in a lane ([`Shape::Tall`], 760 × 328): nameplate and +/// mode keys, the LCD, the LED meters and the knee key across the top, the +/// eight knobs below, bigger. +pub fn tall_face() -> Face { + let (w, h) = Shape::Tall.size(); + let scene = canvas("Digital Saturation (tall)", |b, root| { + parts::fit_root(b, root, w, h); + let panel = parts::panel(b, root, "panel.painted", "black", w, h, "none"); + parts::repaint( + b, + panel, + &[(0.0, "#1e2129"), (0.5, "#171a20"), (1.0, "#0f1116")], + ); + for (x, y) in [ + (12.0, 12.0), + (12.0, h - 12.0), + (w - 12.0, 12.0), + (w - 12.0, h - 12.0), + ] { + art::screw(b, panel, (x, y), 4.0, 30.0); + } + + // ── Nameplate, mode ──────────────────────────────────────────── + parts::silk_left( + b, + panel, + "Digital", + 30.0, + 34.0, + Ink::new(15.0, INK).weight(800.0).track(0.1), + ); + b.add( + panel, + draw::rect("accent", 30.0, 46.0, 30.0, 3.0, draw::solid(LCD)), + ); + parts::silk_left( + b, + panel, + "Clipper · bit-crusher", + 30.0, + 62.0, + Ink::new(6.8, DIM).weight(700.0).track(0.18), + ); + let modes = Place::new("Mode") + .size(46.0) + .variant("red") + .bind(bind(&a("mode"))) + .options(MODES); + parts::put(b, panel, "button.square_bank", &modes, 96.0, 112.0); + parts::silk( + b, + panel, + "Stage", + 96.0, + 145.0, + Ink::new(6.5, DIM).weight(800.0).track(0.22), + ); + + // ── LCD ──────────────────────────────────────────────────────── + let pic = super::picture(b, panel, "digital_sat", (w, h)); + lcd(b, pic, (184.0, 24.0, 420.0, 132.0), 224.0); + + // ── Output meters ────────────────────────────────────────────── + let (mx, my) = (626.0, 28.0); + for (i, x) in [mx, mx + 20.0].into_iter().enumerate() { + parts::led_column( + b, + pic, + if i == 0 { "Out L" } else { "Out R" }, + &a("out"), + i as u8, + (x, my, 13.0, 118.0), + 18, + (-36.0, 3.0), + ); + } + for (db, frac) in [ + ("0", 3.0 / 39.0), + ("-12", 15.0 / 39.0), + ("-24", 27.0 / 39.0), + ("-36", 1.0), + ] { + parts::silk_left( + b, + pic, + db, + mx + 38.0, + my + 3.0 + frac * 112.0, + Ink::new(6.0, DIM).weight(700.0).track(0.0), + ); + } + parts::silk( + b, + pic, + "Output", + mx + 16.0, + 162.0, + Ink::new(6.5, DIM).weight(800.0).track(0.2), + ); + super::emphasis_eq(b, panel, "digital_sat", (180.0, 18.0, 500.0, 152.0), LCD); + super::emphasis_key(b, panel, "digital_sat", (718.0, 136.0), 56.0, "black", LCD); + let clip = Place::new("Hard knee") + .size(38.0) + .variant("red") + .legend("Hard") + .bind(bind(&a("hard"))); + parts::put(b, panel, "button.square", &clip, 718.0, 74.0); + parts::silk( + b, + panel, + "Knee", + 718.0, + 102.0, + Ink::new(6.5, DIM).weight(800.0).track(0.2), + ); + + // ── Knob row ─────────────────────────────────────────────────── + parts::groove(b, panel, 24.0, w - 24.0, 182.0); + let row = 242.0; + let legend = Ink::new(8.0, INK).weight(800.0).track(0.14); + let value = Ink::new(7.5, DIM).weight(600.0).track(0.02).plain(); + let scale = Ink::new(6.5, DIM).weight(700.0).track(0.0).plain(); + let rate_marks = [ + (0.0, "1k"), + (parts::log_pos(8000.0, 1000.0, 48000.0), "8k"), + (1.0, "48k"), + ]; + let knobs: [(&str, &str, &str, f64, f64, &[(f64, &str)]); 8] = [ + ("drive", "Drive", "red", 64.0, 58.0, parts::PCT_MARKS), + ( + "ceiling", + "Ceiling", + "magenta", + 154.0, + 54.0, + &[(0.0, "-24"), (0.5, "-12"), (1.0, "0")], + ), + ( + "bits", + "Bits", + "blue", + 244.0, + 54.0, + &[ + (0.0, "1"), + (7.0 / 23.0, "8"), + (15.0 / 23.0, "16"), + (1.0, "24"), + ], + ), + ("rate", "Rate", "blue", 334.0, 54.0, &rate_marks), + ("tone", "Tone", "grey", 424.0, 50.0, super::TRIM_MARKS), + ("dither", "Dither", "grey", 512.0, 50.0, parts::PCT_MARKS), + ("mix", "Mix", "black", 600.0, 50.0, parts::PCT_MARKS), + ("output", "Output", "red", 694.0, 54.0, super::OUT_MARKS), + ]; + for (addr, name, variant, x, d, marks) in knobs { + let k = parts::knob( + b, + panel, + "knob.collet", + variant, + &a(addr), + name, + (x, row), + d, + ); + let sweep = parts::sweep_of(b, k); + parts::marks(b, panel, (x, row), d / 2.0 + 6.0, sweep, marks, 11, scale); + parts::legend_at(b, k, name, 298.0, legend); + if !matches!(addr, "bits" | "rate" | "ceiling") { + parts::value_at(b, k, 313.0, value); + } + } + }); + Face { + file: Shape::Tall.file("05-digital"), + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} diff --git a/examples/plugin-faces/src/saturate/hw.rs b/examples/plugin-faces/src/saturate/hw.rs new file mode 100644 index 00000000..be069531 --- /dev/null +++ b/examples/plugin-faces/src/saturate/hw.rs @@ -0,0 +1,273 @@ +//! Hardware the redrawn saturator faces (tape, transformer) share: an +//! engraved nameplate badge, a glazed window into the unit, and the +//! knob family — `knob.3d` caps (lit per pixel on the GPU stage, drawn by +//! `knob.render` without it) with the face printing its own scale, legend +//! and value. + +use frame::document::proto::NodeId; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::widgets::Builder; + +use crate::delay::art; +use crate::delay::parts::{self, Ink}; + +/// A row of the family's knobs: the cap (`knob.3d` preset and options), +/// the inks (legend, scale and value), the shafts' height and the +/// legends' and values' baselines. +pub(crate) struct Row<'a> { + pub look: (&'a str, &'a [&'a str]), + pub ink: (&'a str, &'a str), + pub y: f64, + pub legend_y: f64, + pub value_y: f64, +} + +/// One knob of a row: its address, legend, shaft x, diameter and the +/// scale printed round it. +pub(crate) type Spec<'a> = (&'a str, &'a str, f64, f64, &'a [(f64, &'a str)]); + +/// A knob of the family on `row`: kit `knob.3d`, bound to `addr`, the +/// face printing its scale (21 ticks), legend and live value. +pub(crate) fn knob( + b: &mut Builder<'_>, + panel: NodeId, + row: &Row<'_>, + (addr, name, cx, d, marks): Spec<'_>, +) -> NodeId { + let (variant, opts) = row.look; + let opts: Vec<&str> = opts.iter().copied().chain(["ticks=none"]).collect(); + let p = Place::new(name) + .size(d) + .variant(variant) + .options(&opts) + .bind(parts::bind(addr)) + .legend(name) + .touch(TOUCH_MIN); + let k = parts::put(b, panel, "knob.3d", &p, cx, row.y); + parts::strip_legend(b, k); + parts::strip_scale(b, k); + let sweep = parts::sweep_of(b, k); + let (main, dim) = row.ink; + parts::marks( + b, + panel, + (cx, row.y), + d / 2.0 + 9.0, + sweep, + marks, + 21, + Ink::new(6.8, dim).weight(700.0).track(0.0).plain(), + ); + parts::legend_at( + b, + k, + name, + row.legend_y, + Ink::new(8.2, main).weight(800.0).track(0.16), + ); + parts::value_at( + b, + k, + row.value_y, + Ink::new(7.5, dim).weight(600.0).track(0.02).plain(), + ); + k +} + +/// A detented selector of the family: a black bakelite chicken-head +/// (`knob.3d`) for `addr`, `d` across on (`cx`, `cy`), its positions `labels` printed round it in +/// `ink`. The face prints the legend. +pub(crate) fn selector( + b: &mut Builder<'_>, + panel: NodeId, + (name, addr): (&str, &str), + (cx, cy, d): (f64, f64, f64), + labels: &[&str], + ink: &str, +) -> NodeId { + let opts: Vec<&str> = labels + .iter() + .copied() + .chain(["color=#121214", "pointer=#ece6da", "spec=1.1", "shadow=1.3"]) + .collect(); + let p = Place::new(name) + .size(d) + .variant("bakelite_chicken") + .options(&opts) + .bind(parts::bind(addr)) + .legend(name) + .ink(frame::surface::kit::Ink::Color(ink.into())) + .keep_case() + .touch(TOUCH_MIN); + let k = parts::put(b, panel, "knob.3d", &p, cx, cy); + parts::strip_legend(b, k); + k +} + +/// An engraved nameplate: a brushed aluminium badge `(x, y, w, h)` held by +/// two screws, the name in black infill and a line under it. +pub(crate) fn badge( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + (name, size): (&str, f64), + line: &str, +) -> NodeId { + let plate = draw::rounded( + draw::rect( + "badge", + x, + y, + w, + h, + draw::linear( + 180.0, + &[ + (0.0, "#e9eaec"), + (0.45, "#c3c6ca"), + (0.55, "#b4b8bd"), + (1.0, "#9a9ea4"), + ], + ), + ), + 3.0, + ); + let plate = draw::with_effects( + draw::stroked(plate, "#00000099", 0.8), + vec![ + draw::inner_shadow("#ffffffa0", 0.0, 1.0, 0.0), + draw::inner_shadow("#00000040", 0.0, -1.0, 1.0), + draw::drop_shadow("#000000a0", 0.0, 1.5, 3.0), + ], + ); + let badge = b.add(parent, plate); + // The brushing: fine horizontal streaks. + let streaks: Vec> = (0..) + .map(|i| 1.6 + 1.9 * f64::from(i)) + .take_while(|yy| *yy < h - 1.0) + .map(|yy| vec![(2.0, yy), (w - 2.0, yy)]) + .collect(); + b.add( + badge, + draw::with_opacity( + parts::stroke_path( + "brushing", + (0.0, 0.0, w, h), + &streaks, + 0.5, + "#ffffff", + false, + ), + 0.18, + ), + ); + // Engraved: a light lip under each black-filled letter. + let cy = h * 0.42; + parts::silk( + b, + badge, + name, + w / 2.0, + cy + 0.8, + Ink::new(size, "#ffffff90").weight(900.0).track(0.12), + ); + parts::silk( + b, + badge, + name, + w / 2.0, + cy, + Ink::new(size, "#141517").weight(900.0).track(0.12), + ); + parts::rule(b, badge, 14.0, w - 14.0, h * 0.64, 0.8, "#14151780"); + parts::silk( + b, + badge, + line, + w / 2.0, + h * 0.8, + Ink::new(5.6, "#2b2d30").weight(800.0).track(0.26), + ); + for sx in [7.0, w - 7.0] { + art::screw(b, badge, (sx, h / 2.0), 2.6, 40.0); + } + badge +} + +/// A glazed window into the unit at `(x, y, w, h)`: a machined bezel and, +/// sunk in it, the dark interior `name` (returned) — the subtree the GPU +/// stage lays its glass over. +pub(crate) fn window( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + interior: &[(f64, &str)], +) -> NodeId { + let bezel = draw::rounded( + draw::rect( + "bezel", + x - 5.0, + y - 5.0, + w + 10.0, + h + 10.0, + draw::linear( + 180.0, + &[ + (0.0, "#6c7075"), + (0.12, "#3a3d41"), + (0.88, "#1d1f22"), + (1.0, "#0e0f10"), + ], + ), + ), + 9.0, + ); + b.add( + parent, + draw::with_effects( + draw::stroked(bezel, "#000000b0", 1.0), + vec![ + draw::inner_shadow("#ffffff30", 0.0, 1.0, 0.0), + draw::drop_shadow("#00000080", 0.0, 2.0, 4.0), + ], + ), + ); + let mut win = draw::frame(name, x, y, w, h); + win.fills = vec![draw::linear(180.0, interior)]; + let win = draw::with_effects( + draw::stroked(draw::rounded(win, 5.0), "#000000", 1.0), + vec![draw::inner_shadow("#000000e0", 0.0, 3.0, 9.0)], + ); + let mut win = win; + win.clips_content = true; + b.add(parent, win) +} + +/// The window's depth: a shade inside its top edge, cast by the bezel, +/// drawn last over what is in it. +pub(crate) fn window_lip(b: &mut Builder<'_>, win: NodeId) { + let (w, h) = parts::size_of(b, win); + b.add( + win, + draw::rect( + "bezel shade", + 0.0, + 0.0, + w, + 14.0, + draw::linear(180.0, &[(0.0, "#000000a0"), (1.0, "#00000000")]), + ), + ); + b.add( + win, + draw::rect( + "bezel shade left", + 0.0, + 0.0, + 8.0, + h, + draw::linear(90.0, &[(0.0, "#00000080"), (1.0, "#00000000")]), + ), + ); +} diff --git a/examples/plugin-faces/src/saturate/tape.rs b/examples/plugin-faces/src/saturate/tape.rs new file mode 100644 index 00000000..3eb209ec --- /dev/null +++ b/examples/plugin-faces/src/saturate/tape.rs @@ -0,0 +1,613 @@ +//! The tape machine: a two-track mastering deck in warm charcoal enamel — +//! what you drive tape saturation with. +//! +//! Across the top, left to right: the transport behind glass — two NAB +//! aluminium reels (the light stays put on their spun flanges while they +//! turn at the rate SPEED sets; the emptier take-up turns faster), the tape +//! running down past the guides, the erase / record / play heads and the +//! capstan with its pinch roller; the meter bridge — two lamp-lit VUs +//! under glass reading the level going to tape, the RECORD lamp between +//! them; and the engraved badge over the EMPHASIS key and the SPEED switch +//! (7½, 15, 30 in/s; slower is more head bump and less top). +//! +//! Below, the record electronics in one family of knobs — spun aluminium +//! caps on black skirts, a black index: the tape FORMULATION switch +//! (standard or hot, the family's two profiles), RECORD LEVEL (drive into +//! the tape), BIAS (under- or over-biased from the calibrated point), +//! EMPHASIS, COMPRESSION, and MIX and OUTPUT last. + +use frame::document::proto::NodeId; +use frame::surface::kit::{Place, draw, motion}; +use frame::surface::proto::{Binding, EntryId, Source}; +use frame::surface::widgets::Builder; + +use super::hw; +use crate::delay::art::{self, circle_d, ring_d, sector_d}; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::lane::Shape; + +const W: f64 = 960.0; +const H: f64 = 330.0; +const INK: &str = "#f0ebe2"; +const DIM: &str = "#c4b9a8"; +const SPEEDS: &[&str] = &["7½", "15", "30"]; +const SPEED_NAMES: &[&str] = &["7.5 in/s", "15 in/s", "30 in/s"]; +const FORMULATIONS: &[&str] = &["Standard", "Hot"]; +/// The emphasis EQ's colour: the VUs' lit ivory. +const LAMP: &str = "#f2c46a"; +/// The panel: warm charcoal enamel. +const PAINT: &[(f64, &str)] = &[(0.0, "#4d4944"), (0.5, "#3d3a36"), (1.0, "#2c2a27")]; + +/// The knob family: a spun aluminium cap in a black skirt, black index. +const KNOB: (&str, &[&str]) = ( + "spun_metal", + &[ + "cap_r=0.8", + "height=0.5", + "skirt_r=1.0", + "skirt_h=0.22", + "pointer=#0a0a0a", + "index_w=0.085", + "index_in=0.08", + "index_out=0.96", + "spec=1.0", + "shadow=1.3", + ], +); + +fn a(s: &str) -> String { + format!("tape_sat/{s}") +} + +/// An NAB reel face-on, `r` in radius centred on (`cx`, `cy`) of `deck`, its +/// flange's windows at `rot`: +/// the oxide pack wound to `fill`, a spun aluminium flange with three +/// windows, the big NAB hub. The flange and hub turn (`spin`, turns a +/// second); the light on them — the stage's `reel lit` frame — does not. +fn reel( + b: &mut Builder<'_>, + deck: NodeId, + (cx, cy, r): (f64, f64, f64), + (fill, rot): (f64, f64), + (spin, name): (&str, &str), +) { + let side = 2.0 * r + 10.0; + let c = side / 2.0; + let root = b.add(deck, draw::frame("reel", cx - c, cy - c, side, side)); + b.add( + root, + draw::with_effects( + draw::circle("reel shadow", c + 2.0, c + 4.0, r, draw::solid("#00000080")), + vec![draw::drop_shadow("#000000a0", 0.0, 4.0, 9.0)], + ), + ); + let lit = b.add(root, draw::frame("reel lit", 0.0, 0.0, side, side)); + // The pack: brown oxide, its wound edge faintly banded. + let rp = r * fill; + let pack = draw::circle( + "tape pack", + c, + c, + rp, + draw::radial_at( + 0.5, + 0.5, + 0.5, + &[ + (0.0, "#1e120a"), + (0.4, "#3a2416"), + (0.93, "#5a3a24"), + (1.0, "#24160c"), + ], + ), + ); + b.add(lit, pack); + let bands: String = (1..7) + .map(|k| circle_d(c, c, r * 0.36 + (rp - r * 0.36) * f64::from(k) / 7.0, false)) + .collect(); + let mut bands = draw::stroked( + draw::path( + "pack bands", + 0.0, + 0.0, + side, + side, + bands, + draw::solid("#000000"), + ), + "#000000", + 0.6, + ); + bands.fills = Vec::new(); + bands.stroke.align = frame::document::proto::StrokeAlign::Center; + b.add(lit, draw::with_opacity(bands, 0.3)); + let turning = b.add(lit, draw::frame("reel turning", 0.0, 0.0, side, side)); + // The flange: aluminium, spun (the conic sheen), three windows. + let mut d = circle_d(c, c, r, false); + for i in 0..3 { + let w0 = rot + 120.0 * f64::from(i); + d.push_str(§or_d( + c, + c, + r * 0.42, + r * 0.88, + w0 + 14.0, + w0 + 106.0, + true, + )); + } + d.push_str(&circle_d(c, c, r * 0.31, true)); + let spun = draw::conic( + rot + 20.0, + &[ + (0.0, "#eef0f2"), + (0.12, "#a6aaaf"), + (0.25, "#dfe2e5"), + (0.38, "#8c9096"), + (0.5, "#eef0f2"), + (0.62, "#a6aaaf"), + (0.75, "#dfe2e5"), + (0.88, "#8c9096"), + (1.0, "#eef0f2"), + ], + ); + b.add( + turning, + draw::with_effects( + draw::path("flange", 0.0, 0.0, side, side, d, spun), + vec![draw::drop_shadow("#00000070", 0.0, 1.0, 2.0)], + ), + ); + // Turned rings on the flange, and the lit edges of rim and windows. + let mut rings = String::new(); + for k in [0.93, 0.36] { + rings.push_str(&circle_d(c, c, r * k, false)); + } + let mut rings = draw::stroked( + draw::path( + "turned rings", + 0.0, + 0.0, + side, + side, + rings, + draw::solid("#000000"), + ), + "#00000060", + 0.7, + ); + rings.fills = Vec::new(); + rings.stroke.align = frame::document::proto::StrokeAlign::Center; + b.add(turning, rings); + let mut rim = ring_d(c, c, r - 1.1, r); + for i in 0..3 { + let w0 = rot + 120.0 * f64::from(i); + rim.push_str(§or_d( + c, + c, + r * 0.88, + r * 0.88 + 0.9, + w0 + 14.0, + w0 + 106.0, + false, + )); + } + b.add( + turning, + draw::with_opacity( + draw::path( + "flange rim", + 0.0, + 0.0, + side, + side, + rim, + draw::solid("#ffffff"), + ), + 0.55, + ), + ); + // The NAB hub: a black centre with three keyways, the spindle cap. + b.add( + turning, + draw::circle( + "hub", + c, + c, + r * 0.31, + draw::radial_at( + 0.4, + 0.35, + 0.9, + &[(0.0, "#4a4c50"), (0.7, "#1c1d1f"), (1.0, "#0b0b0c")], + ), + ), + ); + let keys: Vec> = (0..3) + .map(|i| { + let k = rot + 60.0 + 120.0 * f64::from(i); + vec![ + draw::polar(c, c, r * 0.13, k), + draw::polar(c, c, r * 0.27, k), + ] + }) + .collect(); + b.add( + turning, + parts::lines_node("keyways", &keys, r * 0.06, "#050505"), + ); + b.add( + turning, + draw::circle( + "spindle", + c, + c, + r * 0.1, + draw::radial_at( + 0.35, + 0.3, + 0.8, + &[(0.0, "#ffffff"), (0.5, "#b8babd"), (1.0, "#4c4e52")], + ), + ), + ); + motion::add(b, turning, motion::spin(spin).named(name)); +} + +/// The transport in the window `(x, y, w, h)` of `pic`: two reels, the +/// tape path, guides, heads, capstan and pinch roller. +fn transport(b: &mut Builder<'_>, pic: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + let deck = hw::window( + b, + pic, + "Deck window", + (x, y, w, h), + &[(0.0, "#36383b"), (1.0, "#1d1e20")], + ); + // Brushed deck plate. + let streaks: Vec> = (0..) + .map(|i| 1.5 + 2.1 * f64::from(i)) + .take_while(|yy| *yy < h) + .map(|yy| vec![(0.0, yy), (w, yy)]) + .collect(); + b.add( + deck, + draw::with_opacity( + parts::stroke_path( + "brushing", + (0.0, 0.0, w, h), + &streaks, + 0.5, + "#ffffff", + false, + ), + 0.05, + ), + ); + + let r = (w * 0.19).min(52.0); + let (ly, gy) = (r + 12.0, h - 30.0); + let (lx, rx) = (w * 0.25, w * 0.75); + // 7½ / 15 / 30 in/s: each step doubles the turn rate. + let speed = "0.3 * pow(2, 2*norm(tape_sat/speed))"; + let (fs, ft) = (0.86, 0.58); + // The tape: off each pack's outer edge, down round the guides, over + // the heads and through the capstan. + let (g1, g2) = ((14.0, gy), (w - 14.0, gy)); + let left = draw::polar(lx, ly, r * fs, 248.0); + let right = draw::polar(rx, ly, r * ft, 112.0); + art::tape( + b, + deck, + &[ + left, + (g1.0 - 4.0, g1.1 - 6.0), + (g1.0 + 4.0, gy + 5.0), + (g2.0 - 4.0, gy + 5.0), + (g2.0 + 4.0, g2.1 - 6.0), + right, + ], + 2.6, + "#4a2c18", + ); + reel( + b, + deck, + (lx, ly, r), + (fs, 10.0), + (&format!("{speed} / {fs}"), "supply"), + ); + reel( + b, + deck, + (rx, ly, r), + (ft, 70.0), + (&format!("{speed} / {ft}"), "take-up"), + ); + // Guides at the corners, the head block between, capstan at the right. + for g in [g1, g2] { + art::post(b, deck, g, 4.2); + } + let (bx, bw) = (w * 0.3, w * 0.4); + let block = draw::rounded( + draw::rect( + "head block", + bx, + gy + 4.0, + bw, + 16.0, + draw::linear(180.0, &[(0.0, "#55585c"), (1.0, "#202123")]), + ), + 3.0, + ); + b.add( + deck, + draw::with_effects( + draw::stroked(block, "#000000a0", 0.6), + vec![ + draw::inner_shadow("#ffffff30", 0.0, 1.0, 0.0), + draw::drop_shadow("#000000a0", 0.0, 2.0, 3.0), + ], + ), + ); + for (i, label) in ["E", "R", "P"].iter().enumerate() { + let hx = bx + bw * (0.2 + 0.3 * i as f64); + art::head(b, deck, (hx, gy + 11.0), 13.0, 12.0); + parts::silk( + b, + deck, + label, + hx, + gy + 22.0, + Ink::new(5.0, "#b9b4aa").weight(800.0).track(0.0), + ); + } + let cap = w * 0.8; + art::post(b, deck, (cap, gy + 5.0), 3.2); + art::roller(b, deck, (cap + 2.0, gy + 14.0), 7.0); + hw::window_lip(b, deck); +} + +/// One VU on the bridge, reading channel `ch` of the output. +fn vu( + b: &mut Builder<'_>, + bridge: NodeId, + ch: u8, + (cx, cy, size): (f64, f64, f64), + legend: &str, +) -> f64 { + let binding = Binding { + source: Source::Stream { + id: EntryId::new(a("out")), + channel: Some(ch), + }, + ..Binding::stream(a("out")) + }; + let p = Place::new(format!("VU {legend}")) + .size(size) + .variant("black_rack") + .legend("VU") + .bind(binding); + let vu = parts::put(b, bridge, "meter.vu_amber", &p, cx, cy); + parts::glass(b, vu); + let vh = parts::size_of(b, vu).1; + parts::silk( + b, + bridge, + legend, + cx, + cy + vh / 2.0 + 9.0, + Ink::new(6.6, DIM).weight(800.0).track(0.3), + ); + vh +} + +/// The front, `w × h`, its parts between `x0` and `x1`. +fn front(b: &mut Builder<'_>, panel: NodeId, (w, h): (f64, f64), (x0, x1): (f64, f64)) { + let wide = x1 - x0 > 800.0; + let (deck_w, col) = if wide { (330.0, 150.0) } else { (262.0, 128.0) }; + let (top, band) = (16.0, 160.0); + + // ── Transport and meter bridge; or the emphasis EQ ──────────────── + let pic = super::picture(b, panel, "tape_sat", (w, h)); + transport(b, pic, (x0 + 5.0, top, deck_w, band)); + let (bx0, bx1) = (x0 + deck_w + 22.0, x1 - col - 16.0); + let size = ((bx1 - bx0) / 2.0 - 8.0).min(176.0); + let cy = top + 62.0; + let mut vh = 0.0; + for (i, legend) in ["Left", "Right"].into_iter().enumerate() { + let cx = bx0 + (bx1 - bx0) * (0.25 + 0.5 * i as f64); + vh = vu(b, pic, i as u8, (cx, cy, size), legend); + } + // The bridge: a black housing round both movements. + let housing = draw::rounded( + draw::rect( + "meter bridge", + bx0 - 8.0, + top - 4.0, + bx1 - bx0 + 16.0, + band + 8.0, + draw::linear(180.0, &[(0.0, "#2a2826"), (1.0, "#141312")]), + ), + 7.0, + ); + let housing = draw::with_effects( + draw::stroked(housing, "#000000c0", 1.0), + vec![ + draw::inner_shadow("#ffffff22", 0.0, 1.0, 0.0), + draw::drop_shadow("#00000090", 0.0, 2.0, 5.0), + ], + ); + let housing = b.add(pic, housing); + if let Some(p) = b.scene.node_mut(pic) { + p.children.retain(|c| *c != housing); + p.children.insert(1, housing); + } + let mid = (bx0 + bx1) / 2.0; + let ry = (cy + vh / 2.0 + 30.0).min(top + band - 6.0); + parts::lamp(b, pic, (mid - 40.0, ry), 8.0, "#ff4a36", true); + parts::silk_left( + b, + pic, + "Record", + mid - 30.0, + ry, + Ink::new(6.4, DIM).weight(800.0).track(0.24), + ); + parts::silk_left( + b, + pic, + "· Level to tape", + mid + 6.0, + ry, + Ink::new(6.4, "#9a9184").weight(700.0).track(0.18), + ); + super::emphasis_eq( + b, + panel, + "tape_sat", + (x0, top - 5.0, bx1 - x0, band + 10.0), + LAMP, + ); + + // ── Badge, EMPHASIS, speed ──────────────────────────────────────── + let cx = x1 - col / 2.0; + hw::badge( + b, + panel, + (x1 - col, 18.0, col, 38.0), + ("Tape Machine", 10.5), + "Record electronics", + ); + super::emphasis_key(b, panel, "tape_sat", (cx, 78.0), 62.0, "black", LAMP); + let speed = hw_selector(b, panel, ("Speed", &a("speed")), (cx, 148.0), SPEEDS); + parts::legend_at( + b, + speed, + "Speed in/s", + 180.0, + Ink::new(6.6, DIM).weight(800.0).track(0.2), + ); + + // ── Record electronics ──────────────────────────────────────────── + parts::groove(b, panel, x0, x1, 190.0); + let row = hw::Row { + look: KNOB, + ink: (INK, DIM), + y: h - 86.0, + legend_y: h - 24.0, + value_y: h - 11.0, + }; + let span = x1 - x0; + let at = |f: f64| x0 + span * f; + let tape = hw_selector( + b, + panel, + ("Tape", &a("formulation")), + (at(0.07), row.y), + FORMULATIONS, + ); + parts::legend_at( + b, + tape, + "Formulation", + row.legend_y, + Ink::new(8.2, INK).weight(800.0).track(0.16), + ); + let knobs: [hw::Spec<'_>; 6] = [ + ("drive", "Record Level", at(0.215), 76.0, parts::PCT_MARKS), + ("bias", "Bias", at(0.36), 62.0, super::TRIM_MARKS), + ("emphasis", "Emphasis", at(0.48), 60.0, super::TRIM_MARKS), + ( + "compression", + "Compression", + at(0.6), + 60.0, + super::TRIM_MARKS, + ), + ("mix", "Mix", at(0.775), 62.0, parts::PCT_MARKS), + ("output", "Output", at(0.93), 72.0, super::OUT_MARKS), + ]; + for (addr, name, x, d, marks) in knobs { + hw::knob(b, panel, &row, (&a(addr), name, x, d, marks)); + } + parts::vgroove(b, panel, at(0.69), 204.0, h - 14.0); +} + +/// A rotary selector of the family's moulding: a black chicken-head, its +/// positions printed round it. +fn hw_selector( + b: &mut Builder<'_>, + panel: NodeId, + (name, addr): (&str, &str), + (cx, cy): (f64, f64), + labels: &[&str], +) -> NodeId { + hw::selector(b, panel, (name, addr), (cx, cy, 50.0), labels, INK) +} + +pub fn face() -> Face { + let scene = canvas("Tape Machine", |b, root| { + parts::fit_root(b, root, W, H); + let panel = parts::panel(b, root, "panel.painted", "charcoal", W, H, "ears"); + parts::repaint(b, panel, PAINT); + front(b, panel, (W, H), (52.0, W - 52.0)); + }); + + Face { + file: "02-tape", + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} + +fn params() -> Vec { + vec![ + parts::percent(&a("drive"), "Record Level", 30.0).featured(), + parts::trim(&a("bias"), "Bias").featured(), + parts::trim(&a("emphasis"), "Emphasis"), + parts::trim(&a("compression"), "Compression"), + parts::percent(&a("mix"), "Mix", 100.0).featured(), + super::output(&a("output")), + parts::choice(&a("speed"), "Speed", SPEED_NAMES, 1).featured(), + parts::choice(&a("formulation"), "Formulation", FORMULATIONS, 0), + ] + .into_iter() + .chain(super::emphasis_params("tape_sat")) + .collect() +} + +fn streams() -> Vec { + vec![parts::level_stream(&a("out"), "Output Level", 2)] +} + +/// The machine alone in a lane ([`Shape::Tall`], 760 × 328): the same +/// front, narrower — transport, meter bridge and badge across the top, +/// the record electronics below. +pub fn tall_face() -> Face { + let (w, h) = Shape::Tall.size(); + let scene = canvas("Tape Machine (tall)", |b, root| { + parts::fit_root(b, root, w, h); + let panel = parts::panel(b, root, "panel.painted", "charcoal", w, h, "none"); + parts::repaint(b, panel, PAINT); + for (x, y) in [ + (10.0, 10.0), + (10.0, h - 10.0), + (w - 10.0, 10.0), + (w - 10.0, h - 10.0), + ] { + art::screw(b, panel, (x, y), 4.0, 30.0); + } + front(b, panel, (w, h), (24.0, w - 24.0)); + }); + Face { + file: Shape::Tall.file("02-tape"), + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} diff --git a/examples/plugin-faces/src/saturate/transformer.rs b/examples/plugin-faces/src/saturate/transformer.rs new file mode 100644 index 00000000..f848ed7e --- /dev/null +++ b/examples/plugin-faces/src/saturate/transformer.rs @@ -0,0 +1,901 @@ +//! The transformer: a line-output iron unit in Pultec blue-grey enamel. +//! +//! A glazed window into the chassis shows the part doing the work — a +//! frame-type output transformer, its E-I lamination stack seen three +//! quarters on (the sheets' edges striping its top and side), clamped +//! between two painted steel brackets bolted through the stack, and the +//! enamelled copper winding standing proud of it under a paper wrap that +//! carries its rating. The copper warms with DRIVE: the winding glows and +//! throws light on the chassis floor (bloom on the GPU stage). Its leads +//! run to a phenolic tag board. Beside it the output VU under glass, +//! lamp-lit; left of it the engraved badge, the pilot lamp and the +//! EMPHASIS key. +//! +//! One family of knobs — black skirted caps on knurled skirts, a white +//! index — DRIVE and OUTPUT the largest, in the same places as on every +//! saturation face. + +use frame::document::proto::NodeId; +use frame::document::proto::motion::{Dynamics, Motion, Num, Target}; +use frame::surface::kit::{Place, draw, motion}; +use frame::surface::widgets::Builder; + +use super::hw; +use crate::delay::art; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::lane::Shape; + +const W: f64 = 960.0; +const H: f64 = 320.0; +const INK: &str = "#f1f4f6"; +const DIM: &str = "#c9d5de"; +/// The emphasis EQ's colour: the winding's copper, warm. +const COPPER: &str = "#e8804a"; +/// The panel: Pultec blue-grey enamel, a shade deeper. +const PAINT: &[(f64, &str)] = &[(0.0, "#56768b"), (0.5, "#4a687c"), (1.0, "#3c5668")]; + +/// The knob family: a black moulded cap on a knurled skirt, white index. +const KNOB: (&str, &[&str]) = ( + "flat", + &[ + "color=#17181a", + "rough=0.24", + "coat=0.85", + "cap_r=0.74", + "height=0.58", + "dome=0.1", + "edge=0.12", + "skirt=knurled", + "skirt_color=#1b1c1e", + "skirt_r=1.0", + "skirt_h=0.2", + "skirt_teeth=44", + "index=line", + "pointer=#f3f1ea", + "index_w=0.08", + "index_in=0.18", + "index_out=0.98", + "spec=1.2", + "shadow=1.3", + ], +); + +fn a(s: &str) -> String { + format!("transformer/{s}") +} + +/// How hot the copper runs: DRIVE, warming and cooling as a winding does. +fn heat(b: &mut Builder<'_>, id: NodeId, lo: f64, hi: f64) { + let m = Motion::new(Target::Opacity) + .input(format!("norm({})", a("drive"))) + .range(lo, hi) + .dynamics(Dynamics::Ballistic { + attack: Num::Value(0.4), + release: Num::Value(1.2), + }); + motion::add(b, id, m); +} + +/// A filled polygon in the coordinates of a frame `w × h`. +fn poly( + name: &str, + (w, h): (f64, f64), + pts: &[(f64, f64)], + fill: frame::document::proto::Paint, +) -> frame::document::proto::Node { + draw::path(name, 0.0, 0.0, w, h, art::poly_d(pts, false), fill) +} + +/// Lines from `a + t·d` to `b + t·d` for `n` steps of `t` in 0..1: the +/// edges of a stack of sheets (or turns of wire) along `d`. +fn stack_lines( + (ax, ay): (f64, f64), + (bx, by): (f64, f64), + (dx, dy): (f64, f64), + n: u32, +) -> Vec> { + (1..n) + .map(|i| { + let t = f64::from(i) / f64::from(n); + vec![(ax + dx * t, ay + dy * t), (bx + dx * t, by + dy * t)] + }) + .collect() +} + +/// A hex bolt head seen from the front. +fn bolt(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64), r: f64, (w, h): (f64, f64)) { + let hex: Vec<(f64, f64)> = (0..6) + .map(|i| draw::polar(cx, cy, r, 30.0 + 60.0 * f64::from(i))) + .collect(); + let n = poly( + "bolt", + (w, h), + &hex, + draw::linear( + 160.0, + &[(0.0, "#f4f5f6"), (0.45, "#a9adb2"), (1.0, "#4b4e52")], + ), + ); + b.add( + parent, + draw::with_effects( + draw::stroked(n, "#1a1b1d", 0.5), + vec![draw::drop_shadow("#000000a0", 0.4, 1.0, 1.4)], + ), + ); + b.add( + parent, + draw::circle( + "bolt end", + cx, + cy, + r * 0.45, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#d8dadd"), (1.0, "#6b6e72")]), + ), + ); +} + +/// The output transformer, its art `214 × 140` from (`x`, `y`) of `win`. +/// Returns where its four leads leave the winding (window coordinates). +fn iron(b: &mut Builder<'_>, win: NodeId, (x, y): (f64, f64)) -> [(f64, f64); 4] { + let (tw, th) = (214.0, 140.0); + let root = b.add(win, draw::frame("transformer", x, y, tw, th)); + let sz = (tw, th); + // Depth runs up and to the right. + let (dx, dy) = (24.0, -17.0); + let (fx, fy, fw, fh) = (10.0, 30.0, 150.0, 92.0); + + // Its shadow on the chassis floor. + let shadow = draw::rounded( + draw::rect( + "shadow", + fx - 6.0, + fy + fh - 6.0, + fw + dx + 18.0, + 16.0, + draw::solid("#000000a0"), + ), + 8.0, + ); + b.add( + root, + draw::with_effects(shadow, vec![draw::drop_shadow("#000000c0", 4.0, 2.0, 10.0)]), + ); + + // The stack: the top and right faces show every sheet's edge. + let top = [ + (fx, fy), + (fx + fw, fy), + (fx + fw + dx, fy + dy), + (fx + dx, fy + dy), + ]; + b.add( + root, + poly( + "stack top", + sz, + &top, + draw::linear(180.0, &[(0.0, "#8e959c"), (1.0, "#b7bdc3")]), + ), + ); + let side = [ + (fx + fw, fy), + (fx + fw + dx, fy + dy), + (fx + fw + dx, fy + fh + dy), + (fx + fw, fy + fh), + ]; + b.add( + root, + poly( + "stack side", + sz, + &side, + draw::linear(180.0, &[(0.0, "#4d535a"), (1.0, "#2a2e33")]), + ), + ); + let n = 22; + let sheets = stack_lines((fx, fy), (fx + fw, fy), (dx, dy), n); + b.add( + root, + draw::with_opacity( + parts::stroke_path( + "sheet edges", + (0.0, 0.0, tw, th), + &sheets, + 0.6, + "#24282c", + false, + ), + 0.8, + ), + ); + let lit = stack_lines( + (fx + 0.5, fy - 0.45), + (fx + fw + 0.5, fy - 0.45), + (dx, dy), + n, + ); + b.add( + root, + draw::with_opacity( + parts::stroke_path( + "sheet lips", + (0.0, 0.0, tw, th), + &lit, + 0.4, + "#ffffff", + false, + ), + 0.5, + ), + ); + let sheets = stack_lines((fx + fw, fy), (fx + fw, fy + fh), (dx, dy), n); + b.add( + root, + draw::with_opacity( + parts::stroke_path( + "sheet edges side", + (0.0, 0.0, tw, th), + &sheets, + 0.6, + "#0c0d0f", + false, + ), + 0.85, + ), + ); + let lit = stack_lines((fx + fw + 0.45, fy), (fx + fw + 0.45, fy + fh), (dx, dy), n); + b.add( + root, + draw::with_opacity( + parts::stroke_path( + "sheet lips side", + (0.0, 0.0, tw, th), + &lit, + 0.35, + "#c8d0d8", + false, + ), + 0.35, + ), + ); + + // The front sheet: silicon steel, a faint blue temper. + let face = draw::rect( + "stack face", + fx, + fy, + fw, + fh, + draw::linear( + 165.0, + &[(0.0, "#7c858e"), (0.45, "#5d656e"), (1.0, "#3d434a")], + ), + ); + b.add(root, draw::stroked(face, "#16181b", 0.8)); + b.add( + root, + draw::rect( + "temper", + fx, + fy, + fw, + fh, + draw::linear( + 120.0, + &[(0.0, "#6a7ca000"), (0.5, "#6a7ca026"), (1.0, "#a0786a1a")], + ), + ), + ); + // The key light across the sheet: a soft diagonal sheen. + b.add( + root, + draw::rect( + "sheen", + fx, + fy, + fw, + fh, + draw::linear( + 115.0, + &[ + (0.0, "#ffffff00"), + (0.28, "#ffffff2e"), + (0.42, "#ffffff00"), + (1.0, "#00000030"), + ], + ), + ), + ); + // The E's back and the I's seam. + parts::rule(b, root, fx, fx + fw, fy + fh * 0.8, 0.8, "#0e1012c0"); + parts::rule(b, root, fx, fx + fw, fy + fh * 0.8 + 0.9, 0.6, "#ffffff2a"); + + // The winding, standing proud of the stack toward us. + let (pdx, pdy) = (-dx * 0.42, -dy * 0.42); + let (cw, ch) = (fw * 0.56, fh * 0.6); + let (cx0, cy0) = ( + fx + fw / 2.0 - cw / 2.0 + pdx, + fy + fh * 0.46 - ch / 2.0 + pdy, + ); + let coil_top = [ + (cx0 + 2.0, cy0), + (cx0 + cw - 2.0, cy0), + (cx0 + cw - 2.0 - pdx, cy0 - pdy), + (cx0 + 2.0 - pdx, cy0 - pdy), + ]; + let coil_side = [ + (cx0 + cw, cy0 + 2.0), + (cx0 + cw - pdx, cy0 + 2.0 - pdy), + (cx0 + cw - pdx, cy0 + ch - 2.0 - pdy), + (cx0 + cw, cy0 + ch - 2.0), + ]; + b.add( + root, + poly( + "coil top", + sz, + &coil_top, + draw::linear(180.0, &[(0.0, "#b8683a"), (1.0, "#f0b07a")]), + ), + ); + b.add( + root, + poly( + "coil side", + sz, + &coil_side, + draw::linear(90.0, &[(0.0, "#7a3a18"), (1.0, "#3e1c0a")]), + ), + ); + let turns = stack_lines((cx0 + 2.0, cy0), (cx0 + cw - 2.0, cy0), (-pdx, -pdy), 7); + b.add( + root, + draw::with_opacity( + parts::stroke_path( + "turns top", + (0.0, 0.0, tw, th), + &turns, + 0.5, + "#4a2008", + false, + ), + 0.6, + ), + ); + let front = draw::rounded( + draw::rect( + "coil", + cx0, + cy0, + cw, + ch, + draw::linear( + 180.0, + &[ + (0.0, "#f6c08a"), + (0.18, "#d98b4e"), + (0.5, "#a85a28"), + (0.82, "#7a3c18"), + (1.0, "#5a2a10"), + ], + ), + ), + 4.0, + ); + b.add( + root, + draw::with_effects( + draw::stroked(front, "#2a1206", 0.7), + vec![draw::drop_shadow("#00000080", -2.0, 2.0, 3.0)], + ), + ); + // The turns: round wire, each catching a line of light. + let wire: Vec> = (0..) + .map(|i| cy0 + 1.6 + 1.75 * f64::from(i)) + .take_while(|yy| *yy < cy0 + ch - 1.0) + .map(|yy| vec![(cx0 + 1.0, yy), (cx0 + cw - 1.0, yy)]) + .collect(); + b.add( + root, + draw::with_opacity( + parts::stroke_path("turns", (0.0, 0.0, tw, th), &wire, 0.6, "#3a1806", false), + 0.55, + ), + ); + let glints: Vec> = wire + .iter() + .map(|l| l.iter().map(|(px, py)| (*px, py - 0.75)).collect()) + .collect(); + b.add( + root, + draw::with_opacity( + parts::stroke_path( + "turn glints", + (0.0, 0.0, tw, th), + &glints, + 0.35, + "#ffe2c0", + false, + ), + 0.4, + ), + ); + // The curve of the winding round the leg: lit on the left, falling + // away into the window at the right. + b.add( + root, + draw::rounded( + draw::rect( + "coil curve", + cx0, + cy0, + cw, + ch, + draw::linear( + 90.0, + &[ + (0.0, "#ffffff30"), + (0.2, "#ffffff00"), + (0.75, "#00000000"), + (1.0, "#00000070"), + ], + ), + ), + 4.0, + ), + ); + + // Heat: the copper glows with DRIVE, and throws light round it. + let glow_d = art::poly_d(&coil_top, false) + + &art::poly_d(&coil_side, false) + + &draw::polygon(&[ + (cx0, cy0), + (cx0 + cw, cy0), + (cx0 + cw, cy0 + ch), + (cx0, cy0 + ch), + ]); + let hot = draw::path( + "hot copper", + 0.0, + 0.0, + tw, + th, + glow_d, + draw::radial_at( + 0.45, + 0.4, + 0.75, + &[(0.0, "#ffc070b0"), (0.55, "#ff702878"), (1.0, "#d8381860")], + ), + ); + let hot = b.add(root, parts::emit(hot, "#ff7a2a", 1.8, 26.0)); + heat(b, hot, 0.04, 1.0); + + // The paper wrap and its rating. + let (py0, ph) = (cy0 + ch * 0.32, ch * 0.4); + let paper = draw::rect( + "paper", + cx0 - 0.5, + py0, + cw + 1.0, + ph, + draw::linear( + 180.0, + &[(0.0, "#efe4c8"), (0.5, "#e2d4b0"), (1.0, "#c9b890")], + ), + ); + b.add( + root, + draw::with_effects(paper, vec![draw::drop_shadow("#00000060", 0.0, 1.0, 1.5)]), + ); + parts::rule(b, root, cx0, cx0 + cw, py0 + 0.6, 0.5, "#ffffff80"); + parts::silk( + b, + root, + "Out · 1 : 2", + cx0 + cw / 2.0, + py0 + ph * 0.36, + Ink::new(6.4, "#3a2a18").weight(900.0).track(0.12), + ); + parts::silk( + b, + root, + "600 Ω · Ni-Fe", + cx0 + cw / 2.0, + py0 + ph * 0.74, + Ink::new(4.8, "#5a4630").weight(800.0).track(0.14).plain(), + ); + + // The clamp: two painted steel brackets bolted through the stack, the + // lower one turned out into mounting feet. + let bracket = |name: &str, yy: f64| { + let n = draw::rect( + name, + fx - 5.0, + yy, + fw + 10.0, + 13.0, + draw::linear( + 180.0, + &[ + (0.0, "#55595f"), + (0.12, "#2c2f33"), + (0.85, "#1b1d20"), + (1.0, "#0c0d0e"), + ], + ), + ); + draw::with_effects( + draw::stroked(n, "#000000", 0.6), + vec![ + draw::inner_shadow("#ffffff40", 0.0, 1.0, 0.0), + draw::drop_shadow("#000000a0", 0.0, 1.5, 2.5), + ], + ) + }; + b.add(root, bracket("upper bracket", fy - 3.0)); + let ytop = [ + (fx - 5.0, fy - 3.0), + (fx + fw + 5.0, fy - 3.0), + (fx + fw + 5.0 + dx * 0.22, fy - 3.0 + dy * 0.22), + (fx - 5.0 + dx * 0.22, fy - 3.0 + dy * 0.22), + ]; + b.add( + root, + poly( + "upper bracket top", + sz, + &ytop, + draw::linear(180.0, &[(0.0, "#2a2d31"), (1.0, "#6a6e74")]), + ), + ); + let feet = draw::rounded( + draw::rect( + "feet", + fx - 20.0, + fy + fh - 1.0, + fw + 40.0, + 6.0, + draw::linear(180.0, &[(0.0, "#4a4e53"), (1.0, "#111214")]), + ), + 1.5, + ); + b.add( + root, + draw::with_effects(feet, vec![draw::drop_shadow("#000000c0", 0.0, 2.0, 3.0)]), + ); + b.add(root, bracket("lower bracket", fy + fh - 11.0)); + for (bx, by) in [ + (fx + 6.0, fy + 3.5), + (fx + fw - 6.0, fy + 3.5), + (fx + 6.0, fy + fh - 4.5), + (fx + fw - 6.0, fy + fh - 4.5), + ] { + bolt(b, root, (bx, by), 3.6, sz); + } + for sx in [fx - 13.0, fx + fw + 13.0] { + art::screw(b, root, (sx, fy + fh + 2.0), 2.6, 25.0); + } + + // Where the leads leave the winding (its top, toward the back). + let (lx, ly) = (cx0 + cw * 0.5 - pdx * 0.5, cy0 - pdy * 0.5); + [0.0, 1.0, 2.0, 3.0].map(|i| (x + lx - 12.0 + i * 8.0, y + ly)) +} + +/// The tag board the leads land on: phenolic, four turrets, their numbers. +fn tag_board(b: &mut Builder<'_>, win: NodeId, (x, y): (f64, f64), leads: [(f64, f64); 4]) { + let (w, h) = (34.0, 112.0); + let board = draw::rounded( + draw::rect( + "tag board", + x, + y, + w, + h, + draw::linear( + 90.0, + &[(0.0, "#3e2413"), (0.5, "#6b4125"), (1.0, "#4a2c18")], + ), + ), + 2.5, + ); + b.add( + win, + draw::with_effects( + draw::stroked(board, "#1a0e06", 0.6), + vec![draw::drop_shadow("#000000b0", 1.0, 2.0, 4.0)], + ), + ); + let tags = [ + ("#c8392b", "1"), + ("#e9e6de", "2"), + ("#2f6fd0", "5"), + ("#1d1d1f", "6"), + ]; + for (i, ((col, label), (x0, y0))) in tags.iter().zip(leads).enumerate() { + let ty = y + 18.0 + i as f64 * 25.0; + let (x1, y1) = (x + 9.0, ty); + // Each lead: an arc up off the winding and down to its turret. + let lift = 18.0 + i as f64 * 5.0; + let pts: Vec<(f64, f64)> = (0..=20) + .map(|k| { + let t = f64::from(k) / 20.0; + let u = 1.0 - t; + let (c1, c2) = ((x0 + 4.0, y0 - lift), (x1 - 30.0, y1)); + ( + u * u * u * x0 + + 3.0 * u * u * t * c1.0 + + 3.0 * u * t * t * c2.0 + + t * t * t * x1, + u * u * u * y0 + + 3.0 * u * u * t * c1.1 + + 3.0 * u * t * t * c2.1 + + t * t * t * y1, + ) + }) + .collect(); + parts::line( + b, + win, + "lead shadow", + &pts.iter() + .map(|(px, py)| (px + 1.0, py + 2.0)) + .collect::>(), + 2.6, + "#00000070", + ); + parts::line(b, win, "lead", &pts, 2.4, col); + parts::line( + b, + win, + "lead sheen", + &pts.iter() + .map(|(px, py)| (*px, py - 0.6)) + .collect::>(), + 0.6, + "#ffffff55", + ); + b.add( + win, + draw::circle( + "turret", + x1, + y1, + 3.6, + draw::radial_at( + 0.4, + 0.35, + 0.8, + &[(0.0, "#fff4d0"), (0.45, "#c9a24a"), (1.0, "#6a4e1a")], + ), + ), + ); + b.add( + win, + draw::circle( + "solder", + x1, + y1, + 2.0, + draw::radial_at( + 0.35, + 0.3, + 0.8, + &[(0.0, "#ffffff"), (0.5, "#b9bcc0"), (1.0, "#6a6d72")], + ), + ), + ); + parts::silk( + b, + win, + label, + x + 24.0, + ty, + Ink::new(6.0, "#ead8b8").weight(800.0).track(0.0), + ); + } + for ry in [y + 5.0, y + h - 5.0] { + art::screw(b, win, (x + w / 2.0, ry), 2.4, 60.0); + } +} + +/// The chassis window `(x, y, w, h)` on `pic`: the floor, the light the +/// winding throws on it, the transformer and its tag board. +fn chassis(b: &mut Builder<'_>, pic: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + let win = hw::window( + b, + pic, + "Chassis window", + (x, y, w, h), + &[(0.0, "#2c3036"), (1.0, "#15171a")], + ); + // Pressed ribs in the floor. + for i in 0..4 { + let yy = 18.0 + f64::from(i) * 40.0; + parts::rule(b, win, 0.0, w, yy, 1.0, "#0000004a"); + parts::rule(b, win, 0.0, w, yy + 1.0, 1.0, "#ffffff0c"); + } + let art_w = 214.0 + 50.0; + let ax = ((w - art_w) / 2.0).max(6.0); + let ay = (h - 140.0) / 2.0 + 4.0; + // The warm pool on the floor round the iron. + let pool = draw::circle( + "heat pool", + ax + 95.0, + ay + 74.0, + 120.0, + draw::radial(&[(0.0, "#ff7a2a70"), (0.45, "#ff5a1a28"), (1.0, "#ff5a1a00")]), + ); + let pool = b.add(win, pool); + heat(b, pool, 0.0, 1.0); + let leads = iron(b, win, (ax, ay)); + tag_board(b, win, (ax + 228.0, ay + 18.0), leads); + art::screw(b, win, (12.0, h - 12.0), 3.4, 30.0); + art::screw(b, win, (w - 12.0, 12.0), 3.4, 70.0); + hw::window_lip(b, win); +} + +/// The front, `w × h`, its parts between `x0` and `x1`. +fn front(b: &mut Builder<'_>, panel: NodeId, (w, h): (f64, f64), (x0, x1): (f64, f64)) { + // ── Badge, pilot, EMPHASIS ──────────────────────────────────────── + let wide = x1 - x0 > 800.0; + let col = if wide { 196.0 } else { 168.0 }; + hw::badge( + b, + panel, + (x0, 20.0, col, 46.0), + ("Transformer", 13.5), + "Line output stage", + ); + parts::silk_left( + b, + panel, + "Nickel-iron · class A", + x0 + 2.0, + 82.0, + Ink::new(6.4, DIM).weight(700.0).track(0.2), + ); + super::emphasis_key( + b, + panel, + "transformer", + (x0 + 33.0, 132.0), + 66.0, + "black", + COPPER, + ); + parts::lamp(b, panel, (x0 + 100.0, 132.0), 10.0, "#ff4a36", true); + parts::silk_left( + b, + panel, + "Power", + x0 + 113.0, + 132.0, + Ink::new(6.6, DIM).weight(800.0).track(0.22), + ); + + // ── The chassis and the output VU; or the emphasis EQ ───────────── + let pic = super::picture(b, panel, "transformer", (w, h)); + let (wx, vw) = (x0 + col + 16.0, if wide { 228.0 } else { 214.0 }); + let wr = x1 - vw - 14.0; + chassis(b, pic, (wx, 16.0, wr - wx, 160.0)); + let vu = Place::new("Output") + .size(vw - 8.0) + .variant("black_rack") + .legend("VU") + .bind(parts::stream(&a("out"))); + let vu = parts::put(b, pic, "meter.vu_amber", &vu, x1 - vw / 2.0, 86.0); + parts::glass(b, vu); + let vh = parts::size_of(b, vu).1; + parts::silk( + b, + pic, + "Output", + x1 - vw / 2.0, + 86.0 + vh / 2.0 + 9.0, + Ink::new(6.8, DIM).weight(800.0).track(0.24), + ); + super::emphasis_eq( + b, + panel, + "transformer", + (wx - 5.0, 11.0, x1 - wx + 5.0, 170.0), + COPPER, + ); + + // ── Knob row ────────────────────────────────────────────────────── + parts::groove(b, panel, x0, x1, 190.0); + let row = hw::Row { + look: KNOB, + ink: (INK, DIM), + y: h - 86.0, + legend_y: h - 24.0, + value_y: h - 11.0, + }; + let span = x1 - x0; + let at = |f: f64| x0 + span * f; + let knobs: [hw::Spec<'_>; 7] = [ + ("drive", "Drive", at(0.065), 76.0, parts::PCT_MARKS), + ("core", "Core", at(0.215), 62.0, super::TRIM_MARKS), + ("bloom", "Low Bloom", at(0.35), 60.0, super::TRIM_MARKS), + ( + "hysteresis", + "Hysteresis", + at(0.48), + 60.0, + super::TRIM_MARKS, + ), + ("sag", "Sag", at(0.61), 60.0, super::TRIM_MARKS), + ("mix", "Mix", at(0.775), 62.0, parts::PCT_MARKS), + ("output", "Output", at(0.93), 72.0, super::OUT_MARKS), + ]; + for (addr, name, x, d, marks) in knobs { + hw::knob(b, panel, &row, (&a(addr), name, x, d, marks)); + } + parts::vgroove(b, panel, at(0.69), 204.0, h - 14.0); +} + +pub fn face() -> Face { + let scene = canvas("Transformer", |b, root| { + parts::fit_root(b, root, W, H); + let panel = parts::panel(b, root, "panel.painted", "pultec", W, H, "ears"); + parts::repaint(b, panel, PAINT); + front(b, panel, (W, H), (52.0, W - 52.0)); + }); + Face { + file: "03-transformer", + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} + +fn params() -> Vec { + vec![ + parts::percent(&a("drive"), "Drive", 30.0).featured(), + parts::trim(&a("core"), "Core").featured(), + parts::trim(&a("bloom"), "Low Bloom"), + parts::trim(&a("hysteresis"), "Hysteresis"), + parts::trim(&a("sag"), "Sag"), + parts::percent(&a("mix"), "Mix", 100.0).featured(), + super::output(&a("output")), + ] + .into_iter() + .chain(super::emphasis_params("transformer")) + .collect() +} + +fn streams() -> Vec { + vec![parts::level_stream(&a("out"), "Output Level", 2)] +} + +/// The transformer alone in a lane ([`Shape::Tall`], 760 × 328): the same +/// front, narrower — badge, chassis window and VU across the top, the +/// seven knobs below. +pub fn tall_face() -> Face { + let (w, h) = Shape::Tall.size(); + let scene = canvas("Transformer (tall)", |b, root| { + parts::fit_root(b, root, w, h); + let panel = parts::panel(b, root, "panel.painted", "pultec", w, h, "none"); + parts::repaint(b, panel, PAINT); + for (x, y) in [ + (10.0, 10.0), + (10.0, h - 10.0), + (w - 10.0, 10.0), + (w - 10.0, h - 10.0), + ] { + art::screw(b, panel, (x, y), 4.0, 30.0); + } + front(b, panel, (w, h), (24.0, w - 24.0)); + }); + Face { + file: Shape::Tall.file("03-transformer"), + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} diff --git a/examples/plugin-faces/src/saturate/transistor.rs b/examples/plugin-faces/src/saturate/transistor.rs new file mode 100644 index 00000000..5af4d9bf --- /dev/null +++ b/examples/plugin-faces/src/saturate/transistor.rs @@ -0,0 +1,714 @@ +//! The transistor stage, as the box it's best known in: a fuzz pedal. +//! +//! Hammered silver enclosure, black print. Under the name, a scope window +//! with what the circuit is doing, live: at the left its transfer curve — +//! the straight line of the transistor's gain bending into the flat of +//! the rails, steeper with GAIN, its corner harder with EDGE, pushed off +//! centre (one side clipping first) with SAG, the starved dead band round +//! zero widening with GATE; at the right a sine going through it, the +//! ghost of the clean wave behind the clipped one, the tops squaring off +//! as the gain comes up, fizzing with TONE, breaking up at the zero +//! crossings with GATE. Then the three knobs every fuzz has — GAIN, TONE, +//! LEVEL (output) — and the circuit's trims: EDGE, GATE, SAG and MIX. The +//! mode toggle picks the family's two profiles (a clean transistor drive, +//! or the fuzz). One stomp switch with its LED, and the part itself +//! printed beside it — a PNP transistor's symbol. + +use frame::document::proto::NodeId; +use frame::document::proto::motion::{Dynamics, Motion, Target}; +use frame::surface::kit::{Ink as KitInk, Place, draw, motion}; +use frame::surface::widgets::Builder; + +use crate::delay::art; +use crate::delay::parts::{self, Ink, bind}; +use crate::face::{Face, canvas}; +use crate::lane::Shape; + +const W: f64 = 760.0; +const H: f64 = 520.0; +const INK: &str = "#17181a"; +const DIM: &str = "#3d4045"; + +fn a(s: &str) -> String { + format!("transistor/{s}") +} + +/// A PNP transistor's schematic symbol, printed at (`cx`, `cy`), radius `r`. +fn symbol(b: &mut Builder<'_>, lid: NodeId, (cx, cy): (f64, f64), r: f64, ink: &str) { + let wt = 2.2; + // The envelope: a circle, stroked. + let ring: Vec<(f64, f64)> = (0..=72) + .map(|i| draw::polar(cx, cy, r, f64::from(i) * 5.0)) + .collect(); + b.add( + lid, + parts::stroke_path( + "envelope", + (cx - r - wt, cy - r - wt, 2.0 * (r + wt), 2.0 * (r + wt)), + &[ring], + wt, + ink, + true, + ), + ); + // Base: a lead in from the left to the bar. + let bar_x = cx - r * 0.28; + let (ex, ey) = (cx + r * 0.42, cy - r * 0.62); + let (kx, ky) = (cx + r * 0.42, cy + r * 0.62); + let leads = [ + vec![(cx - r - 16.0, cy), (bar_x, cy)], + // Collector and emitter: diagonals out to the right, then up and down. + vec![(bar_x, cy - r * 0.22), (ex, ey), (ex, cy - r - 16.0)], + vec![(bar_x, cy + r * 0.22), (kx, ky), (kx, cy + r + 16.0)], + ]; + let mut leads = parts::lines_node("leads", &leads, wt, ink); + leads.stroke.cap = frame::document::proto::StrokeCap::None; + b.add(lid, leads); + let mut bar = parts::lines_node( + "base bar", + &[vec![(bar_x, cy - r * 0.5), (bar_x, cy + r * 0.5)]], + wt * 2.0, + ink, + ); + bar.stroke.cap = frame::document::proto::StrokeCap::None; + b.add(lid, bar); + // PNP: the emitter's arrow points in, toward the bar. + let (mx, my) = ((bar_x + ex) / 2.0 - 1.0, (cy - r * 0.22 + ey) / 2.0 + 0.5); + let (ux, uy) = ((bar_x - ex), (cy - r * 0.22 - ey)); + let len = ux.hypot(uy); + let (ux, uy) = (ux / len, uy / len); + let (px, py) = (-uy, ux); + let s = 7.0; + let arrow = [ + (mx + ux * s, my + uy * s), + ( + mx - ux * s * 0.3 + px * s * 0.6, + my - uy * s * 0.3 + py * s * 0.6, + ), + ( + mx - ux * s * 0.3 - px * s * 0.6, + my - uy * s * 0.3 - py * s * 0.6, + ), + ]; + let (ax, ay) = (mx - s, my - s); + let local: Vec<(f64, f64)> = arrow.iter().map(|(x, y)| (x - ax, y - ay)).collect(); + b.add( + lid, + draw::path( + "emitter arrow", + ax, + ay, + 2.0 * s, + 2.0 * s, + draw::polygon(&local), + draw::solid(ink), + ), + ); + let t = Ink::new(8.0, ink).weight(800.0).track(0.0); + parts::silk(b, lid, "E", ex + 10.0, cy - r - 10.0, t); + parts::silk(b, lid, "C", kx + 10.0, cy + r + 10.0, t); + parts::silk(b, lid, "B", cx - r - 22.0, cy - 10.0, t); +} + +/// A knob on the lid: its param, legend, centre x and printed marks. +type KnobAt = ( + &'static str, + &'static str, + f64, + &'static [(f64, &'static str)], +); + +/// The scope's phosphor. +const TRACE: &str = "#ffb347"; + +/// A transfer curve over input −`span`..`span` (output −1..1), drawn in a +/// `w` × `h` box: the clean line of gain `g` bent into the rails — softly +/// (tanh) or with a hard corner (`hard`). +fn transfer(w: f64, h: f64, span: f64, g: f64, hard: bool) -> String { + let n = 120; + let mut d = String::new(); + for k in 0..=n { + let x = -span + 2.0 * span * f64::from(k) / f64::from(n); + let y = if hard { + (g * x).clamp(-1.0, 1.0) + } else { + (g * x).tanh() + }; + let (px, py) = (w * (x + span) / (2.0 * span), h / 2.0 - y * h * 0.4); + d += &format!("{} {px:.2} {py:.2} ", if k == 0 { "M" } else { "L" }); + } + d +} + +/// A sine through a clipper of gain `g` over `cycles` cycles, `w` × `h`. +fn clipped(w: f64, h: f64, cycles: f64, g: f64) -> String { + let n = 360; + let mut d = String::new(); + let norm = g.tanh(); + for k in 0..=n { + let u = f64::from(k) / f64::from(n); + let x = (u * cycles * std::f64::consts::TAU).sin(); + let y = (g * x).tanh() / norm; + d += &format!( + "{} {:.2} {:.2} ", + if k == 0 { "M" } else { "L" }, + w * u, + h / 2.0 - y * h * 0.36 + ); + } + d +} + +/// A stroked path node of `d` in a `w` × `h` box. +fn trace( + name: &str, + (w, h): (f64, f64), + d: String, + color: &str, + weight: f64, +) -> frame::document::proto::Node { + draw::stroked( + draw::path(name, 0.0, 0.0, w, h, d, draw::solid("#00000000")), + color, + weight, + ) +} + +/// The transfer curve panel in (`x`, `y`, `w`, `h`) of the scope. +fn curve_panel(b: &mut Builder<'_>, scope: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + let mut p = draw::frame("Transfer", x, y, w, h); + p.clips_content = true; + let p = b.add(scope, p); + let faint = "#ffb34724"; + b.add( + p, + parts::lines_node( + "axes", + &[ + vec![(w / 2.0, 4.0), (w / 2.0, h - 4.0)], + vec![(4.0, h / 2.0), (w - 4.0, h / 2.0)], + ], + 0.8, + faint, + ), + ); + b.add( + p, + parts::lines_node( + "rails", + &[ + vec![(0.0, h * 0.1), (w, h * 0.1)], + vec![(0.0, h * 0.9), (w, h * 0.9)], + ], + 0.6, + faint, + ), + ); + // The starved band round zero: wider with the gate. + let band = b.add( + p, + draw::rect( + "gate band", + w / 2.0 - 14.0, + 0.0, + 28.0, + h, + draw::solid("#ff6a3d22"), + ), + ); + motion::add( + b, + band, + Motion::new(Target::Opacity) + .input(format!("min(max(norm({}) - 0.5, 0) * 8, 1)", a("gate"))) + .range(0.0, 1.0), + ); + motion::add( + b, + band, + Motion::new(Target::StretchX) + .input(format!("max(norm({}) - 0.5, 0) * 2", a("gate"))) + .range(0.05, 1.0) + .pivot(0.5, 0.5) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + // The curve, drawn three times the box wide: GAIN squeezes it about + // the middle (steeper), SAG slides it off centre, EDGE hardens its + // corner. + let wide = 3.0 * w; + let g = b.add(p, draw::frame("curve", -w, 0.0, wide, h)); + let soft = transfer(wide, h, 3.0, 1.2, false); + let hard = transfer(wide, h, 3.0, 1.2, true); + let c = b.add( + g, + crate::reverb::ui::glowing( + trace("transfer", (wide, h), soft.clone(), TRACE, 1.8), + TRACE, + 0.9, + 6.0, + ), + ); + motion::add( + b, + c, + Motion::new(Target::Morph { + shapes: vec![soft, hard], + }) + .input(format!("norm({})", a("edge"))) + .range(0.0, 1.0), + ); + motion::add( + b, + g, + Motion::new(Target::StretchX) + .input(format!("norm({})", a("drive"))) + .range(1.4, 0.25) + .pivot(0.5, 0.5) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + motion::add( + b, + g, + Motion::new(Target::X) + .input(format!("norm({}) - 0.5", a("sag"))) + .range(0.0, w * 0.3) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + parts::silk( + b, + p, + "In", + w - 9.0, + h / 2.0 - 7.0, + Ink::new(5.5, "#ffb34799").weight(800.0).track(0.0), + ); + parts::silk( + b, + p, + "Out", + w / 2.0 + 12.0, + 9.0, + Ink::new(5.5, "#ffb34799").weight(800.0).track(0.0), + ); +} + +/// The wave panel in (`x`, `y`, `w`, `h`) of the scope: the clean sine as +/// a ghost, the clipped one over it, rolling by while the pedal is on. +fn wave_panel(b: &mut Builder<'_>, scope: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + let mut p = draw::frame("Wave", x, y, w, h); + p.clips_content = true; + let p = b.add(scope, p); + let grid: Vec> = (1..8) + .map(|i| { + vec![ + (w * f64::from(i) / 8.0, 4.0), + (w * f64::from(i) / 8.0, h - 4.0), + ] + }) + .collect(); + b.add(p, parts::lines_node("grid", &grid, 0.6, "#ffb34714")); + b.add( + p, + parts::lines_node( + "zero", + &[vec![(0.0, h / 2.0), (w, h / 2.0)]], + 0.6, + "#ffb34730", + ), + ); + let cycles = 4.0; + let period = w / cycles; + // Two periods more than the window, rolled by one period at a time. + let long = w + 2.0 * period; + let lc = cycles + 2.0; + let roll = b.add(p, draw::frame("roll", -period, 0.0, long, h)); + motion::add( + b, + roll, + Motion::new(Target::X) + .input(format!("fract(time * 0.6) * param({})", a("on"))) + .range(0.0, period), + ); + b.add( + roll, + trace( + "clean", + (long, h), + clipped(long, h, lc, 0.05), + "#ffb34740", + 1.2, + ), + ); + let shapes: Vec = [0.6, 2.0, 5.0, 16.0] + .iter() + .map(|g| clipped(long, h, lc, *g)) + .collect(); + let out = b.add( + roll, + crate::reverb::ui::glowing( + trace("clipped", (long, h), shapes[0].clone(), TRACE, 2.0), + TRACE, + 0.9, + 7.0, + ), + ); + motion::add( + b, + out, + Motion::new(Target::Morph { shapes }) + .input(format!("norm({}) * 3", a("drive"))) + .range(0.0, 1.0), + ); + // The fizz on the flat tops: brighter with the tone, only when they + // are flat. + let mut fizz = Vec::new(); + for k in 0..(lc as u32 * 2) { + let cx = period * (f64::from(k) / 2.0 + 0.25); + let top = if k % 2 == 0 { + h / 2.0 - h * 0.36 + } else { + h / 2.0 + h * 0.36 + }; + let pts: Vec<(f64, f64)> = (0..=10) + .map(|j| { + ( + cx - period * 0.18 + period * 0.036 * f64::from(j), + top + crate::reverb::jitter(f64::from(k * 11 + j)) * 3.0, + ) + }) + .collect(); + fizz.push(pts); + } + let fz = b.add(roll, parts::lines_node("fizz", &fizz, 0.9, "#fff0d0")); + motion::add( + b, + fz, + Motion::new(Target::Opacity) + .input(format!( + "norm({}) * norm({}) * param({})", + a("tone"), + a("drive"), + a("on") + )) + .range(0.0, 0.9), + ); + // The gate's spitting: gaps at the zero crossings, wider with the gate. + let mut gaps = Vec::new(); + for k in 0..=(lc as u32 * 2) { + let gx = period * f64::from(k) / 2.0; + let gap = b.add( + roll, + draw::rect( + "starved", + gx - 7.0, + h / 2.0 - 9.0, + 14.0, + 18.0, + draw::solid("#0b0907"), + ), + ); + gaps.push(gap); + } + for gap in gaps { + // Past noon (the circuit as designed) the gate starves it. + motion::add( + b, + gap, + Motion::new(Target::Opacity) + .input(format!("min(max(norm({}) - 0.5, 0) * 8, 1)", a("gate"))) + .range(0.0, 1.0), + ); + motion::add( + b, + gap, + Motion::new(Target::StretchX) + .input(format!("max(norm({}) - 0.5, 0) * 2", a("gate"))) + .range(0.05, 1.0) + .pivot(0.5, 0.5) + .dynamics(Dynamics::spring(220.0, 26.0)), + ); + } + parts::silk( + b, + p, + "Clean", + 22.0, + 10.0, + Ink::new(5.5, "#ffb34780").weight(800.0).track(0.0), + ); + parts::silk( + b, + p, + "Out", + 52.0, + 10.0, + Ink::new(5.5, TRACE).weight(800.0).track(0.0), + ); +} + +/// The scope window at `(sx, sy, sw, sh)` of the lid (`size`), in the +/// picture layer; the emphasis EQ in its place while that shows. +fn scope( + b: &mut Builder<'_>, + lid: NodeId, + (sx, sy, sw, sh): (f64, f64, f64, f64), + size: (f64, f64), +) { + let pic = super::picture(b, lid, "transistor", size); + let scope = crate::reverb::ui::display(b, pic, "Scope", sx, sy, sw, sh, TRACE, "crt"); + curve_panel(b, scope, (8.0, 6.0, sh - 12.0, sh - 12.0)); + b.add( + scope, + parts::lines_node( + "divider", + &[vec![(sh + 2.0, 8.0), (sh + 2.0, sh - 8.0)]], + 0.8, + "#ffb34733", + ), + ); + wave_panel(b, scope, (sh + 10.0, 6.0, sw - sh - 18.0, sh - 12.0)); + crate::reverb::ui::glass(b, scope); + super::emphasis_eq(b, lid, "transistor", (sx, sy, sw, sh), TRACE); +} + +pub fn face() -> Face { + let scene = canvas("Transistor", |b, root| { + parts::fit_root(b, root, W, H); + let lid = art::enclosure( + b, + root, + (W, H), + &[(0.0, "#d6dbe0"), (0.5, "#b4bbc2"), (1.0, "#8c949c")], + (0.03, 0.05), + ); + + // ── Title ────────────────────────────────────────────────────── + parts::silk( + b, + lid, + "Transistor", + W / 2.0 - 4.0, + 36.0, + Ink::new(22.0, INK).weight(900.0).track(0.12), + ); + parts::silk( + b, + lid, + "Fuzz · Germanium PNP", + W / 2.0 - 4.0, + 58.0, + Ink::new(7.0, DIM).weight(800.0).track(0.3), + ); + + // ── The scope; or the emphasis EQ ────────────────────────────── + scope(b, lid, (56.0, 74.0, W - 112.0, 128.0), (W, H)); + super::emphasis_key(b, lid, "transistor", (646.0, 36.0), 64.0, "black", TRACE); + + let scale = Ink::new(7.0, DIM).weight(800.0).track(0.0).plain(); + let legend = Ink::new(10.5, INK).weight(900.0).track(0.18); + let value = Ink::new(7.5, DIM).weight(700.0).track(0.02).plain(); + + // ── The three every fuzz has ─────────────────────────────────── + let r1 = 268.0; + let big: [KnobAt; 3] = [ + ("drive", "Gain", 176.0, parts::PCT_MARKS), + ("tone", "Tone", 376.0, super::TRIM_MARKS), + ("output", "Level", 576.0, super::OUT_MARKS), + ]; + for (addr, name, x, marks) in big { + let k = parts::knob( + b, + lid, + "knob.bakelite", + "black", + &a(addr), + name, + (x, r1), + 66.0, + ); + let sweep = parts::sweep_of(b, k); + parts::marks(b, lid, (x, r1), 40.0, sweep, marks, 11, scale); + parts::legend_at(b, k, name, r1 + 58.0, legend); + parts::value_at(b, k, r1 + 73.0, value); + } + + // ── The circuit's trims ──────────────────────────────────────── + let r2 = 384.0; + let small: [KnobAt; 4] = [ + ("edge", "Edge", 150.0, super::TRIM_MARKS), + ("gate", "Gate", 290.0, super::TRIM_MARKS), + ("sag", "Sag", 430.0, super::TRIM_MARKS), + ("mix", "Mix", 590.0, parts::PCT_MARKS), + ]; + for (addr, name, x, marks) in small { + let k = parts::knob( + b, + lid, + "knob.bakelite", + "black", + &a(addr), + name, + (x, r2), + 42.0, + ); + let sweep = parts::sweep_of(b, k); + parts::marks(b, lid, (x, r2), 28.0, sweep, marks, 11, scale); + parts::legend_at(b, k, name, r2 + 42.0, legend.size(9.0)); + } + parts::vrule(b, lid, 512.0, r2 - 30.0, r2 + 48.0, 1.0, "#17181a40"); + + // ── Foot ─────────────────────────────────────────────────────── + let fy = 474.0; + let mode = Place::new("Mode") + .size(34.0) + .variant("chrome") + .legend("Mode") + .options(&["Fuzz", "Drive"]) + .bind(bind(&a("fuzz"))) + .ink(KitInk::Color(INK.into())); + let sw = parts::put(b, lid, "switch.toggle_bat", &mode, 170.0, fy - 14.0); + parts::caption_style(b, sw, Ink::new(7.5, INK).weight(900.0).track(0.18)); + let bypass = Place::new("Bypass") + .size(34.0) + .variant("red") + .bind(bind(&a("on"))); + let on = parts::footswitch(b, lid, &bypass, (376.0, fy)); + parts::label_at( + b, + on, + "On", + (406.0, fy - 36.0), + Ink::new(7.5, DIM).weight(900.0).track(0.2), + ); + symbol(b, lid, (650.0, fy - 12.0), 20.0, INK); + }); + + Face { + file: "04-transistor", + plugin: "saturate", + scene, + params: params(), + streams: Vec::new(), + } +} + +fn params() -> Vec { + vec![ + parts::percent(&a("drive"), "Gain", 60.0).featured(), + parts::trim(&a("tone"), "Tone").featured(), + super::output(&a("output")), + parts::trim(&a("edge"), "Edge"), + parts::trim(&a("gate"), "Gate"), + parts::trim(&a("sag"), "Sag"), + parts::percent(&a("mix"), "Mix", 100.0), + parts::toggle(&a("fuzz"), "Fuzz", true), + parts::toggle(&a("on"), "Bypass", true).featured(), + ] + .into_iter() + .chain(super::emphasis_params("transistor")) + .collect() +} + +/// The pedal alone in a lane ([`Shape::Tall`], 760 × 328): the name, the +/// mode toggle and the stomp switch at the left, the scope across the top +/// with the emphasis key and the part's symbol beside it, and the seven +/// knobs in one row below — the three every fuzz has, then the trims. +pub fn tall_face() -> Face { + let (w, h) = Shape::Tall.size(); + let scene = canvas("Transistor (tall)", |b, root| { + parts::fit_root(b, root, w, h); + let lid = art::enclosure( + b, + root, + (w, h), + &[(0.0, "#d6dbe0"), (0.5, "#b4bbc2"), (1.0, "#8c949c")], + (0.03, 0.05), + ); + let (lw, lh) = (w - 8.0, h - 12.0); + + // ── Title, mode, stomp ───────────────────────────────────────── + parts::silk_left( + b, + lid, + "Transistor", + 30.0, + 32.0, + Ink::new(18.0, INK).weight(900.0).track(0.12), + ); + parts::silk_left( + b, + lid, + "Fuzz · Germanium PNP", + 30.0, + 52.0, + Ink::new(6.5, DIM).weight(800.0).track(0.3), + ); + let mode = Place::new("Mode") + .size(30.0) + .variant("chrome") + .legend("Mode") + .options(&["Fuzz", "Drive"]) + .bind(bind(&a("fuzz"))) + .ink(KitInk::Color(INK.into())); + let sw = parts::put(b, lid, "switch.toggle_bat", &mode, 70.0, 112.0); + parts::caption_style(b, sw, Ink::new(7.0, INK).weight(900.0).track(0.18)); + let bypass = Place::new("Bypass") + .size(30.0) + .variant("red") + .bind(bind(&a("on"))); + let on = parts::footswitch(b, lid, &bypass, (150.0, 118.0)); + parts::label_at( + b, + on, + "On", + (174.0, 86.0), + Ink::new(7.0, DIM).weight(900.0).track(0.2), + ); + + // ── The scope; or the emphasis EQ ────────────────────────────── + scope(b, lid, (204.0, 18.0, 458.0, 136.0), (lw, lh)); + super::emphasis_key(b, lid, "transistor", (704.0, 46.0), 52.0, "black", TRACE); + symbol(b, lid, (712.0, 122.0), 15.0, INK); + + // ── The knobs ────────────────────────────────────────────────── + let row = 238.0; + let scale = Ink::new(7.0, DIM).weight(800.0).track(0.0).plain(); + let legend = Ink::new(9.5, INK).weight(900.0).track(0.18); + let value = Ink::new(7.5, DIM).weight(700.0).track(0.02).plain(); + let knobs: [(&str, &str, f64, f64, &[(f64, &str)]); 7] = [ + ("drive", "Gain", 70.0, 62.0, parts::PCT_MARKS), + ("tone", "Tone", 172.0, 62.0, super::TRIM_MARKS), + ("output", "Level", 274.0, 62.0, super::OUT_MARKS), + ("edge", "Edge", 380.0, 52.0, super::TRIM_MARKS), + ("gate", "Gate", 468.0, 52.0, super::TRIM_MARKS), + ("sag", "Sag", 556.0, 52.0, super::TRIM_MARKS), + ("mix", "Mix", 666.0, 52.0, parts::PCT_MARKS), + ]; + for (addr, name, x, d, marks) in knobs { + let k = parts::knob( + b, + lid, + "knob.bakelite", + "black", + &a(addr), + name, + (x, row), + d, + ); + let sweep = parts::sweep_of(b, k); + parts::marks(b, lid, (x, row), d / 2.0 + 7.0, sweep, marks, 11, scale); + parts::legend_at(b, k, name, row + 50.0, legend); + parts::value_at(b, k, row + 64.0, value); + } + parts::rule(b, lid, 24.0, lw - 24.0, 168.0, 1.0, "#17181a30"); + parts::vrule(b, lid, 327.0, row - 46.0, row + 66.0, 1.0, "#17181a40"); + parts::vrule(b, lid, 611.0, row - 46.0, row + 66.0, 1.0, "#17181a40"); + }); + Face { + file: Shape::Tall.file("04-transistor"), + plugin: "saturate", + scene, + params: params(), + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/saturate/tube.rs b/examples/plugin-faces/src/saturate/tube.rs new file mode 100644 index 00000000..7e0cbc9a --- /dev/null +++ b/examples/plugin-faces/src/saturate/tube.rs @@ -0,0 +1,518 @@ +//! The valve preamp: a black steel front with cream lettering, and the +//! valves themselves lit behind a window in it. +//! +//! The window is the unit — two small-signal valves standing in their +//! sockets, heaters glowing, their plates catching the light. Left of it the +//! stage selector (triode / pentode: the two profiles of the family), right +//! the output VU and the power toggle with its pilot. The knob row: DRIVE +//! and BIAS first (bias leans the curve — the even harmonics are there), +//! then SAG, HEAT (how hot the heater runs), TILT, and MIX and OUTPUT last, +//! where they are on every saturation face. The heaters glow with DRIVE +//! and HEAT and flicker as they're pushed (motion), with or without a GPU. + +use frame::document::proto::NodeId; +use frame::document::proto::motion::{Dynamics, Motion, Num, Target}; +use frame::surface::kit::{Place, draw, motion}; +use frame::surface::widgets::Builder; + +use crate::delay::art; +use crate::delay::parts::{self, Ink, bind}; +use crate::face::{Face, canvas}; +use crate::lane::Shape; + +const W: f64 = 960.0; +const H: f64 = 320.0; +const INK: &str = "#eadcb8"; +const DIM: &str = "#a8987a"; +const STAGES: &[&str] = &["Triode", "Pentode"]; +/// The emphasis EQ's colour: the heaters' glow. +const GLOW: &str = "#ff9a3c"; + +fn a(s: &str) -> String { + format!("tube/{s}") +} + +/// A valve's heater glow, alive: it warms (slowly, as a cathode does) +/// with DRIVE and HEAT and goes dark with the power off, and it wavers — +/// more the harder it's driven. Its light (emission) flickers with it. +fn heat(b: &mut Builder<'_>, heater: NodeId) { + let warm = "(0.6 + 0.4*norm(tube/drive)) * (0.8 + 0.2*norm(tube/heat)) * norm(tube/power)"; + motion::add( + b, + heater, + Motion::new(Target::Opacity) + .input(warm) + .dynamics(Dynamics::Ballistic { + attack: Num::Value(0.35), + release: Num::Value(0.9), + }), + ); + let waver = Motion::new(Target::Opacity).dynamics(Dynamics::Noise { + rate: Num::Value(7.0), + amount: Num::Expr("0.03 + 0.12*norm(tube/drive)".into()), + }); + motion::add( + b, + heater, + Motion { + input: "0".into(), + lag: true, + ..waver + }, + ); + let glows: Vec = b + .scene + .walk(heater) + .into_iter() + .skip(1) + .filter(|id| b.scene.node(*id).is_some_and(|n| n.emission.is_some())) + .collect(); + for g in glows { + motion::add(b, g, motion::flicker("0.05 + 0.25*norm(tube/drive)")); + } +} + +/// The window onto the valves, at `(x, y, w, h)` on the panel: the two +/// valves stand 70 either side of its middle, the can between them. +fn window(b: &mut Builder<'_>, panel: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + let mid = w / 2.0; + // A brass bezel round the glass. + let bezel = draw::rounded( + draw::rect( + "bezel", + x - 7.0, + y - 7.0, + w + 14.0, + h + 14.0, + draw::linear( + 160.0, + &[ + (0.0, "#e9d49a"), + (0.35, "#a8894a"), + (0.7, "#d8bf82"), + (1.0, "#6f5a2c"), + ], + ), + ), + 10.0, + ); + b.add( + panel, + draw::with_effects(bezel, vec![draw::drop_shadow("#000000a0", 0.0, 2.0, 5.0)]), + ); + let mut win = draw::frame("Valve window", x, y, w, h); + win.fills = vec![draw::linear(180.0, &[(0.0, "#0d0907"), (1.0, "#1c120b")])]; + win.clips_content = true; + let win = b.add( + panel, + draw::with_effects( + draw::rounded(win, 5.0), + vec![draw::inner_shadow("#000000", 0.0, 3.0, 8.0)], + ), + ); + // Warm light pooled behind the glass, and the chassis the valves stand on. + b.add( + win, + draw::rect( + "warmth", + 0.0, + 0.0, + w, + h, + draw::radial_at(0.5, 0.78, 0.7, &[(0.0, "#7a3a1255"), (1.0, "#7a3a1200")]), + ), + ); + b.add( + win, + draw::rect( + "chassis", + 0.0, + h - 22.0, + w, + 22.0, + draw::linear( + 180.0, + &[(0.0, "#6d6a64"), (0.2, "#3b3934"), (1.0, "#1b1a18")], + ), + ), + ); + // Each valve throws its light on the chassis at its foot. + for cx in [mid - 70.0, mid + 70.0] { + let pool = draw::rect( + "pool", + cx - 60.0, + h - 30.0, + 120.0, + 26.0, + draw::radial(&[(0.0, "#ff8a2a50"), (1.0, "#ff8a2a00")]), + ); + b.add(win, pool); + } + // Two valves, and a transformer can between them for scale. + for heater in [ + art::valve(b, win, (mid - 70.0, 22.0), (46.0, 118.0), 0.85), + art::valve(b, win, (mid + 70.0, 22.0), (46.0, 118.0), 0.7), + ] { + heat(b, heater); + } + let can = draw::rounded( + draw::rect( + "output can", + mid - 22.0, + 62.0, + 44.0, + 70.0, + draw::linear( + 90.0, + &[ + (0.0, "#2a2b2d"), + (0.4, "#6a6c70"), + (0.6, "#4a4c50"), + (1.0, "#1c1d1f"), + ], + ), + ), + 4.0, + ); + b.add( + win, + draw::with_effects(can, vec![draw::drop_shadow("#000000", 0.0, 2.0, 4.0)]), + ); + parts::silk( + b, + win, + "V1", + mid - 70.0, + h - 10.0, + Ink::new(6.5, "#c9c2b2").weight(800.0).track(0.2), + ); + parts::silk( + b, + win, + "V2", + mid + 70.0, + h - 10.0, + Ink::new(6.5, "#c9c2b2").weight(800.0).track(0.2), + ); + // The glass over it all: a faint diagonal glint. + let glint = draw::polygon(&[ + (w * 0.62, 0.0), + (w * 0.74, 0.0), + (w * 0.52, h), + (w * 0.4, h), + ]); + b.add( + win, + draw::path("glint", 0.0, 0.0, w, h, glint, draw::solid("#ffffff0c")), + ); +} + +pub fn face() -> Face { + let scene = canvas("Valve Preamp", |b, root| { + parts::fit_root(b, root, W, H); + let panel = parts::panel(b, root, "panel.painted", "black", W, H, "ears"); + parts::repaint( + b, + panel, + &[(0.0, "#2b231e"), (0.5, "#1e1814"), (1.0, "#14100d")], + ); + + // ── Nameplate and stage ─────────────────────────────────────── + parts::silk_left( + b, + panel, + "Valve Preamp", + 50.0, + 36.0, + Ink::new(16.0, INK).weight(800.0).track(0.06).plain(), + ); + parts::rule(b, panel, 50.0, 214.0, 49.0, 1.0, "#eadcb850"); + parts::silk_left( + b, + panel, + "Class A · single-ended", + 50.0, + 62.0, + Ink::new(6.8, DIM).weight(700.0).track(0.18), + ); + let stage = parts::selector( + b, + panel, + "Stage", + &a("stage"), + "cream", + (130.0, 134.0), + 54.0, + STAGES, + INK, + ); + parts::legend_at( + b, + stage, + "Stage", + 168.0, + Ink::new(7.0, DIM).weight(800.0).track(0.22), + ); + + // ── The valves and the output VU; or the emphasis EQ ─────────── + let pic = super::picture(b, panel, "tube", (W, H)); + window(b, pic, (250.0, 22.0, 360.0, 150.0)); + let vu = Place::new("Output") + .size(176.0) + .variant("black_rack") + .legend("VU") + .bind(parts::stream(&a("out"))); + let vu = parts::put(b, pic, "meter.vu_amber", &vu, 752.0, 92.0); + parts::glass(b, vu); + parts::silk( + b, + pic, + "Output", + 752.0, + 160.0, + Ink::new(6.8, DIM).weight(800.0).track(0.22), + ); + super::emphasis_eq(b, panel, "tube", (243.0, 15.0, 597.0, 157.0), GLOW); + + // ── Emphasis, power ──────────────────────────────────────────── + super::emphasis_key(b, panel, "tube", (882.0, 26.0), 60.0, "black", GLOW); + let power = Place::new("Power") + .size(38.0) + .variant("chrome") + .legend("Power") + .options(&["On", "Off"]) + .bind(bind(&a("power"))); + let pw = parts::put(b, panel, "switch.toggle_bat", &power, 882.0, 76.0); + parts::caption_style(b, pw, Ink::new(6.5, INK).weight(800.0).track(0.14)); + let pilot = parts::put( + b, + panel, + "button.jewel", + &Place::new("Pilot") + .size(24.0) + .variant("amber") + .bind(bind(&a("power"))), + 882.0, + 132.0, + ); + parts::legend_at( + b, + pilot, + "Pilot", + 154.0, + Ink::new(6.5, DIM).weight(800.0).track(0.2), + ); + + // ── Knob row ─────────────────────────────────────────────────── + parts::rule(b, panel, 44.0, W - 44.0, 184.0, 1.0, "#eadcb826"); + let row = 238.0; + let legend = Ink::new(8.5, INK).weight(800.0).track(0.14); + let value = Ink::new(7.5, DIM).weight(600.0).track(0.02).plain(); + let scale = Ink::new(6.5, DIM).weight(700.0).track(0.0).plain(); + let knobs: [(&str, &str, f64, f64, &[(f64, &str)]); 7] = [ + ("drive", "Drive", 110.0, 62.0, parts::PCT_MARKS), + ("bias", "Bias", 236.0, 62.0, super::TRIM_MARKS), + ("sag", "Sag", 360.0, 46.0, super::TRIM_MARKS), + ("heat", "Heat", 466.0, 46.0, super::TRIM_MARKS), + ("tilt", "Tilt", 572.0, 46.0, super::TRIM_MARKS), + ("mix", "Mix", 710.0, 50.0, parts::PCT_MARKS), + ("output", "Output", 842.0, 56.0, super::OUT_MARKS), + ]; + for (addr, name, x, d, marks) in knobs { + let k = parts::knob( + b, + panel, + "knob.bakelite", + "brown", + &a(addr), + name, + (x, row), + d, + ); + let sweep = parts::sweep_of(b, k); + parts::marks(b, panel, (x, row), d / 2.0 + 5.0, sweep, marks, 11, scale); + parts::legend_at(b, k, name, 290.0, legend); + parts::value_at(b, k, 305.0, value); + } + parts::vrule(b, panel, 640.0, 200.0, 304.0, 1.0, "#eadcb826"); + }); + + Face { + file: "01-tube", + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} + +fn params() -> Vec { + vec![ + parts::percent(&a("drive"), "Drive", 25.0).featured(), + parts::trim(&a("bias"), "Bias").featured(), + parts::trim(&a("sag"), "Sag"), + parts::trim(&a("heat"), "Heat"), + parts::trim(&a("tilt"), "Tilt"), + parts::percent(&a("mix"), "Mix", 100.0).featured(), + super::output(&a("output")), + parts::choice(&a("stage"), "Stage", STAGES, 0), + parts::toggle(&a("power"), "Power", true), + ] + .into_iter() + .chain(super::emphasis_params("tube")) + .collect() +} + +fn streams() -> Vec { + vec![parts::level_stream(&a("out"), "Output Level", 2)] +} + +/// The preamp alone in a lane ([`Shape::Tall`], 760 × 328): the same front +/// redrawn narrower — nameplate and stage, the valve window and the VU with +/// power across the top, the seven knobs below, bigger. +pub fn tall_face() -> Face { + let (w, h) = Shape::Tall.size(); + let scene = canvas("Valve Preamp (tall)", |b, root| { + parts::fit_root(b, root, w, h); + let panel = parts::panel(b, root, "panel.painted", "black", w, h, "none"); + parts::repaint( + b, + panel, + &[(0.0, "#2b231e"), (0.5, "#1e1814"), (1.0, "#14100d")], + ); + for (x, y) in [ + (12.0, 12.0), + (12.0, h - 12.0), + (w - 12.0, 12.0), + (w - 12.0, h - 12.0), + ] { + art::screw(b, panel, (x, y), 4.0, 30.0); + } + + // ── Nameplate and stage ─────────────────────────────────────── + parts::silk_left( + b, + panel, + "Valve Preamp", + 30.0, + 34.0, + Ink::new(16.0, INK).weight(800.0).track(0.06).plain(), + ); + parts::rule(b, panel, 30.0, 186.0, 47.0, 1.0, "#eadcb850"); + parts::silk_left( + b, + panel, + "Class A · single-ended", + 30.0, + 60.0, + Ink::new(6.8, DIM).weight(700.0).track(0.18), + ); + let stage = parts::selector( + b, + panel, + "Stage", + &a("stage"), + "cream", + (106.0, 130.0), + 54.0, + STAGES, + INK, + ); + parts::legend_at( + b, + stage, + "Stage", + 166.0, + Ink::new(7.0, DIM).weight(800.0).track(0.22), + ); + + // ── The valves and the output VU; or the emphasis EQ ─────────── + let pic = super::picture(b, panel, "tube", (w, h)); + window(b, pic, (208.0, 22.0, 300.0, 150.0)); + let vu = Place::new("Output") + .size(150.0) + .variant("black_rack") + .legend("VU") + .bind(parts::stream(&a("out"))); + let vu = parts::put(b, pic, "meter.vu_amber", &vu, 610.0, 88.0); + parts::glass(b, vu); + parts::silk( + b, + pic, + "Output", + 610.0, + 150.0, + Ink::new(6.8, DIM).weight(800.0).track(0.22), + ); + super::emphasis_eq(b, panel, "tube", (201.0, 15.0, 484.0, 157.0), GLOW); + + // ── Emphasis, power ──────────────────────────────────────────── + super::emphasis_key(b, panel, "tube", (718.0, 30.0), 52.0, "black", GLOW); + let power = Place::new("Power") + .size(38.0) + .variant("chrome") + .legend("Power") + .options(&["On", "Off"]) + .bind(bind(&a("power"))); + let pw = parts::put(b, panel, "switch.toggle_bat", &power, 718.0, 90.0); + parts::caption_style(b, pw, Ink::new(6.5, INK).weight(800.0).track(0.14)); + let pilot = parts::put( + b, + panel, + "button.jewel", + &Place::new("Pilot") + .size(24.0) + .variant("amber") + .bind(bind(&a("power"))), + 718.0, + 138.0, + ); + parts::legend_at( + b, + pilot, + "Pilot", + 160.0, + Ink::new(6.5, DIM).weight(800.0).track(0.2), + ); + + // ── Knob row ─────────────────────────────────────────────────── + parts::rule(b, panel, 24.0, w - 24.0, 186.0, 1.0, "#eadcb826"); + let row = 246.0; + let legend = Ink::new(8.5, INK).weight(800.0).track(0.14); + let value = Ink::new(7.5, DIM).weight(600.0).track(0.02).plain(); + let scale = Ink::new(6.5, DIM).weight(700.0).track(0.0).plain(); + let knobs: [(&str, &str, f64, f64, &[(f64, &str)]); 7] = [ + ("drive", "Drive", 72.0, 62.0, parts::PCT_MARKS), + ("bias", "Bias", 174.0, 58.0, super::TRIM_MARKS), + ("sag", "Sag", 270.0, 52.0, super::TRIM_MARKS), + ("heat", "Heat", 360.0, 52.0, super::TRIM_MARKS), + ("tilt", "Tilt", 450.0, 52.0, super::TRIM_MARKS), + ("mix", "Mix", 566.0, 54.0, parts::PCT_MARKS), + ("output", "Output", 680.0, 58.0, super::OUT_MARKS), + ]; + for (addr, name, x, d, marks) in knobs { + let k = parts::knob( + b, + panel, + "knob.bakelite", + "brown", + &a(addr), + name, + (x, row), + d, + ); + let sweep = parts::sweep_of(b, k); + parts::marks(b, panel, (x, row), d / 2.0 + 5.0, sweep, marks, 11, scale); + parts::legend_at(b, k, name, 300.0, legend); + parts::value_at(b, k, 315.0, value); + } + parts::vrule(b, panel, 508.0, 204.0, 312.0, 1.0, "#eadcb826"); + }); + Face { + file: Shape::Tall.file("01-tube"), + plugin: "saturate", + scene, + params: params(), + streams: streams(), + } +} diff --git a/examples/plugin-faces/src/suite_blackout_gain.rs b/examples/plugin-faces/src/suite_blackout_gain.rs new file mode 100644 index 00000000..808324a4 --- /dev/null +++ b/examples/plugin-faces/src/suite_blackout_gain.rs @@ -0,0 +1,139 @@ +//! Suite: **Blackout Gain** (`suite-blackout-gain`) — a single-amp +//! high-gain suite in the plugin's black "blackout" skin, recreated unit by +//! unit as frame surfaces: three pedals, the head, a 4×12, a tabletop +//! graphic EQ, and two post-FX boxes. +//! +//! | file | unit | form | +//! |---|---|---| +//! | `01-pre-standard-ward-gate` | Ward: one-knob noise gate, petal rosette, twin skulls, hollow name | Pedal Standard 156 × 286 | +//! | `02-pre-standard-omen-drive` | Omen: three-knob drive, skulls face to face, hexagram round the red LED | Pedal Standard | +//! | `03-pre-standard-maul-boost` | Maul: one-knob boost, knot lattice, star medallion | Pedal Standard | +//! | `04-amp-head-blackout` | Blackout head: black leather, black panel, green jewel, seven knobs, I/II inputs | Head 1200 × 460 (the plugin's 2.9 : 1 faceplate plus its handle) | +//! | `05-cab-4x12` | 4×12 cabinet with two mics and room | Cab 900 × 900 | +//! | `06-eq-tabletop-nine-band` | Nine-band graphic EQ, blue-lit faders, HPF/LPF | Tabletop 1000 × 302 (3.3 : 1) | +//! | `07-post-large-crypt-delay` | Crypt Delay: LCD, SYNC/PING-PONG, ENGAGE + TAP | Pedal Large 286 × 286 (the original is near square) | +//! | `08-post-standard-tomb-verb` | Tomb Verb: MIX / DECAY / TONE | Pedal Standard | +//! +//! No real maker, artist or product name is printed: the maker's badge +//! reads an invented "VANTOM", and each unit has an invented name set in +//! the original's lettering style (tall condensed display capitals, the +//! gate's hollow block letters, the badge's squared extended caps). + +pub mod amp; +pub mod art; +pub mod cab; +pub mod eq; +pub mod post; +pub mod pre; + +use crate::face::Face; + +/// The suite's plugin id. +pub const PLUGIN: &str = "suite-blackout-gain"; + +/// The component every knob of the suite is placed as: the GPU-rendered +/// `knob.3d` (its vector fallback is `knob.render`). +pub const KNOB_ID: &str = "knob.3d"; + +/// The pedals' knob: glossy black plastic, nine deep rounded flutes round +/// a flat top, a cream line from the shaft to the edge. +#[must_use] +pub const fn pedal_knob(d: f64) -> crate::rigkit::KnobStyle<'static> { + crate::rigkit::KnobStyle::new(KNOB_ID, "fluted", d, art::INK).opts(PEDAL_KNOB) +} + +/// [`pedal_knob`]'s `knob.3d` options. +pub const PEDAL_KNOB: &[&str] = &[ + "color=#040404", + "pointer=#e6dcc0", + "ribs=11", + "rib_depth=0.08", + "cap_r=0.98", + "height=0.5", + "dome=0.03", + "edge=0.12", + "rough=0.22", + "coat=0.35", + "spec=1.1", + "tilt=20", + "index=line", + "index_w=0.08", + "index_in=0.05", + "index_out=0.7", + "ticks=none", +]; + +/// The amp's knob: a black cap on a fine-knurled skirt, a white line index. +pub const AMP_KNOB: &[&str] = &[ + "color=#141414", + "pointer=#f0eee8", + "skirt=knurled", + "skirt_color=#101010", + "skirt_r=1.0", + "skirt_h=0.2", + "skirt_teeth=64", + "cap_r=0.74", + "height=0.56", + "dome=0.06", + "edge=0.1", + "rough=0.3", + "coat=0.5", + "index=line", + "index_w=0.08", + "index_in=0.12", + "index_out=0.9", + "ticks=none", +]; + +/// The EQ's filter knob: a spun aluminium cap in a knurled chrome skirt, a +/// black index. +pub const EQ_KNOB: &[&str] = &[ + "aniso=0.45", + "index_w=0.1", + "index_in=0.15", + "index_out=0.92", + "skirt=knurled", + "skirt_color=#c9ccd0", + "skirt_metal=1", + "skirt_rough=0.25", + "skirt_teeth=60", + "cap_r=0.82", + "dome=0.08", + "ticks=none", +]; + +/// The cab strip's knob: a dark grey knurled cap with a white dot index, +/// in its dotted ring. +pub const CAB_KNOB: &[&str] = &[ + "color=#2a2a2b", + "pointer=#f2f2f0", + "ribs=48", + "rib_depth=0.025", + "cap_r=0.84", + "dome=0.08", + "edge=0.12", + "rough=0.35", + "index=dot", + "index_w=0.09", + "index_in=0.62", + "index_out=0.62", +]; + +/// The component for a knob of kind `id` (the amp's, the EQ's, the cab +/// strip's) — every other knob of the suite is placed through this: all +/// of them are [`KNOB_ID`] now, `id` naming the vector knob it replaced. +#[must_use] +pub const fn knob_id(_id: &str) -> &str { + KNOB_ID +} + +/// Every unit of the suite, in signal order. +#[must_use] +pub fn faces() -> Vec { + let mut v = pre::faces(); + v.push(amp::face()); + v.push(cab::face()); + v.push(eq::face()); + v.extend(post::faces()); + v +} diff --git a/examples/plugin-faces/src/suite_blackout_gain/amp.rs b/examples/plugin-faces/src/suite_blackout_gain/amp.rs new file mode 100644 index 00000000..b98a2826 --- /dev/null +++ b/examples/plugin-faces/src/suite_blackout_gain/amp.rs @@ -0,0 +1,438 @@ +//! The Blackout head — Head form, 1200 × 460: the plugin's black +//! ("blackout") faceplate at its 2.9 : 1 aspect, plus its handle above. +//! +//! Black leather tolex with a welt round the front and a seam a third of +//! the way down; the invented maker's plate top right in squared extended +//! capitals; a recessed black control panel low across the front: a green +//! power jewel, OFF/ON (power) and STANDBY toggles, then MVC (pull for +//! more gain), PRESENCE, BASS, MIDDLE, TREBLE, GAIN II and GAIN I, each a +//! black knob in a printed 0–10 scale, and the four inputs (HIGH / LOW × +//! channel I / II) at the right — the cable's plug shows which is in use. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::{Place, TOUCH_MIN, draw, motion}; +use frame::surface::proto::{ButtonMode, Control, ControlKind, SlotRole}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::art; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Letter}; + +const W: f64 = 1200.0; +const H: f64 = 460.0; +/// Where the head's box starts (the handle is above it). +const TOP: f64 = 44.0; +const PANEL: &str = "#1d1d1d"; +const PRINT: &str = "#d9d9d6"; +const KNOB_Y: f64 = 381.0; +const LEGEND_Y: f64 = 345.0; +const UNDER_Y: f64 = 419.0; + +fn a(s: &str) -> String { + format!("bgamp/{s}") +} + +pub fn face() -> Face { + let scene = canvas("Blackout Head", |b, root| { + parts::fit_root(b, root, W, H); + handle(b, root); + // The box: tolex, the welt, the seam. + let shadow = draw::rounded( + draw::rect( + "box shadow", + 4.0, + TOP + 8.0, + W - 8.0, + H - TOP - 8.0, + draw::solid("#000000"), + ), + 24.0, + ); + b.add( + root, + draw::with_effects(shadow, vec![draw::drop_shadow("#000000c0", 0.0, 6.0, 14.0)]), + ); + let head = art::tolex(b, root, "Head", (0.0, TOP, W, H - TOP), 22.0, 0xb1ac_0417); + let hh = H - TOP; + // The box's rounded edge: the covering turns away from the light. + let edge = draw::rounded(draw::frame("edge", 0.0, 0.0, W, hh), 22.0); + let edge = draw::stroked(edge, "#050505", 3.0); + b.add( + head, + draw::with_effects( + edge, + vec![ + draw::inner_shadow("#000000e0", 0.0, 0.0, 14.0), + draw::inner_shadow("#ffffff26", 0.0, 2.0, 1.0), + ], + ), + ); + art::piping(b, head, (20.0, 20.0, W - 40.0, hh - 40.0), 12.0); + parts::rule(b, head, 22.0, W - 22.0, 148.0, 2.0, "#080808"); + parts::rule(b, head, 22.0, W - 22.0, 150.0, 1.0, "#ffffff18"); + maker_plate(b, head); + // The recess the panel sits in: a deep shadow over it. + let recess = draw::rounded( + draw::rect( + "recess", + 150.0, + 262.0, + 900.0, + 60.0, + draw::linear(180.0, &[(0.0, "#00000000"), (1.0, "#000000d0")]), + ), + 30.0, + ); + b.add( + head, + draw::with_effects(recess, vec![draw::drop_shadow("#000000", 0.0, 0.0, 18.0)]), + ); + let panel = control_panel(b, root); + let _ = panel; + }); + let mut params = vec![ + rk::toggle(&a("power"), "Power", true).featured(), + rk::toggle(&a("standby"), "Standby", false), + rk::dial10(&a("mvc"), "MVC", 5.0).featured(), + rk::toggle(&a("pull_gain"), "Pull Gain", false), + rk::dial10(&a("presence"), "Presence", 5.0), + rk::dial10(&a("bass"), "Bass", 5.0), + rk::dial10(&a("middle"), "Middle", 5.0), + rk::dial10(&a("treble"), "Treble", 5.0), + rk::dial10(&a("gain2"), "Gain II", 7.0).featured(), + rk::dial10(&a("gain1"), "Gain I", 6.0).featured(), + ]; + params.push(rk::choice(&a("input"), "Input", INPUTS, 0)); + Face { + file: "04-amp-head-blackout", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +const INPUTS: &[&str] = &["High I", "High II", "Low I", "Low II"]; + +/// The carry handle: two chrome end caps and a leather strap arching +/// between them. +fn handle(b: &mut Builder<'_>, root: NodeId) { + let (x0, x1) = (362.0, 838.0); + for (x, w) in [(x0, 90.0), (x1 - 90.0, 90.0)] { + let cap = draw::rounded( + draw::rect( + "handle cap", + x, + 30.0, + w, + 16.0, + draw::linear(180.0, &[(0.0, "#3a3a3a"), (1.0, "#101010")]), + ), + 3.0, + ); + b.add(root, draw::stroked(cap, "#000000", 1.0)); + } + rk::curve( + b, + root, + "strap", + (W, H), + &[ + (x0 + 80.0, 34.0), + (W / 2.0 - 150.0, 8.0), + (W / 2.0 + 150.0, 8.0), + (x1 - 80.0, 34.0), + ], + 9.0, + "#0c0c0c", + ); + rk::curve( + b, + root, + "strap light", + (W, H), + &[ + (x0 + 80.0, 31.0), + (W / 2.0 - 150.0, 5.5), + (W / 2.0 + 150.0, 5.5), + (x1 - 80.0, 31.0), + ], + 1.5, + "#4a4a4a", + ); + for x in [x0 + 80.0, x1 - 80.0] { + b.add( + root, + draw::rounded( + draw::rect( + "strap loop", + x - 7.0, + 28.0, + 14.0, + 12.0, + draw::solid("#262626"), + ), + 3.0, + ), + ); + } +} + +/// The maker's plate, top right: a raised dark slab, the invented name in +/// squared extended capitals, bevel-lit. +fn maker_plate(b: &mut Builder<'_>, head: NodeId) { + let (x, y, w, h) = (962.0, 39.0, 202.0, 38.0); + let slab = draw::rounded( + draw::rect( + "maker plate", + x, + y, + w, + h, + draw::linear(180.0, &[(0.0, "#2e2e2e"), (1.0, "#151515")]), + ), + 4.0, + ); + b.add( + head, + draw::with_effects( + slab, + vec![ + draw::drop_shadow("#000000d0", 0.0, 2.0, 4.0), + draw::inner_shadow("#ffffff22", 0.0, 1.0, 0.0), + ], + ), + ); + let l = Letter::new(26.0, 640.0, "#cfcfcf") + .stretch(1.32) + .track(0.08); + let (_, size) = rk::letter_fit(b, head, "VANTOM", (x + w / 2.0, y + h / 2.0), w - 22.0, l); + let _ = size; +} + +/// The recessed black control panel with everything on it; returns it. +#[allow(clippy::too_many_lines)] +fn control_panel(b: &mut Builder<'_>, root: NodeId) -> NodeId { + let (px, py, pw, ph) = (171.0, 332.0, 856.0, 106.0); + let mut plate = draw::frame("Control panel", px, py, pw, ph); + plate.fills = vec![draw::linear( + 180.0, + &[(0.0, "#262626"), (0.5, PANEL), (1.0, "#161616")], + )]; + let plate = draw::with_effects( + draw::stroked(draw::rounded(plate, 8.0), "#000000", 1.0), + vec![ + draw::inner_shadow("#ffffff1c", 0.0, 1.0, 0.0), + draw::drop_shadow("#000000", 0.0, -2.0, 6.0), + ], + ); + let panel = b.add(root, plate); + // The panel's print is in the root's coordinates (everything below is + // placed on `root`, over the panel). + let legend = Ink::new(8.2, PRINT).weight(500.0).track(0.02); + let scale = Ink::new(7.4, PRINT).weight(500.0).track(0.0).plain(); + + // Power: the green jewel is the power switch's lamp, OFF/ON its toggle. + let jewel = Place::new("Power lamp") + .size(34.0) + .variant("green") + .bind(rk::bind(&a("power"))) + .touch(TOUCH_MIN); + let jewel = parts::put(b, root, "button.jewel", &jewel, 212.0, KNOB_Y); + if let Some(lamp) = b + .controls + .get(&jewel) + .and_then(|c| c.slots_of(SlotRole::Led).next()) + .map(NodeId) + { + motion::add(b, lamp, motion::flicker(0.04)); + } + for (addr, name, x) in [("power", "Off", 264.0), ("standby", "Standby", 315.0)] { + let t = rk::mini_toggle(b, root, name, &a(addr), (x, KNOB_Y), 20.0, "chrome"); + parts::label_at(b, t, name, (x, LEGEND_Y), legend); + rk::silk(b, root, "On", x, UNDER_Y, legend); + } + + // The knobs, each in a printed 0–10. + let knobs = [ + ("mvc", "MVC", 374.5), + ("presence", "Presence", 451.0), + ("bass", "Bass", 527.0), + ("middle", "Middle", 606.0), + ("treble", "Treble", 682.0), + ("gain2", "", 759.0), + ("gain1", "", 836.0), + ]; + let marks: &[(f64, &str)] = &[ + (0.0, "0"), + (0.2, "2"), + (0.4, "4"), + (0.6, "6"), + (0.8, "8"), + (1.0, "10"), + ]; + for (addr, name, x) in knobs { + let mut p = Place::new(if name.is_empty() { addr } else { name }) + .size(40.0) + .variant("flat") + .bind(rk::bind(&a(addr))) + .touch(TOUCH_MIN); + p.options = Some(super::AMP_KNOB.iter().map(ToString::to_string).collect()); + let k = parts::put(b, root, super::knob_id("knob.bakelite"), &p, x, KNOB_Y); + parts::strip_legend(b, k); + parts::strip_scale(b, k); + let sweep = parts::sweep_of(b, k); + parts::marks(b, root, (x, KNOB_Y), 23.0, sweep, marks, 0, scale); + if name.is_empty() { + let under = if addr == "gain2" { "Gain II" } else { "Gain I" }; + parts::label_at(b, k, under, (x, UNDER_Y), legend); + } else { + parts::label_at(b, k, name, (x, LEGEND_Y), legend); + } + } + pull_gain(b, root, legend); + inputs(b, root, legend); + panel +} + +/// MVC's pull switch: the PULL GAIN legend under the knob is the button; +/// it lights while the pot is pulled. +fn pull_gain(b: &mut Builder<'_>, root: NodeId, legend: Ink<'_>) { + let (cx, cy) = (374.5, UNDER_Y); + let f = b.add( + root, + draw::frame("Pull Gain", cx - 32.0, 404.0, 64.0, TOUCH_MIN), + ); + let dim = parts::text_node( + "Pull Gain", + 0.0, + cy - 404.0, + 64.0, + &legend.color("#6a6a68"), + TextAlign::Center, + ); + b.add(f, dim); + let lit = parts::text_node( + "Pull Gain", + 0.0, + cy - 404.0, + 64.0, + &legend, + TextAlign::Center, + ); + let lit = b.add(f, parts::emit(lit, "#ffffff", 0.3, 4.0)); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, lit.0) + .slot(SlotRole::Label, lit.0); + c.binding = Some(rk::bind(&a("pull_gain"))); + b.controls.insert(f, c); +} + +/// The four input jacks: a Select bank over the input param, the plug in +/// the chosen jack. +fn inputs(b: &mut Builder<'_>, root: NodeId, legend: Ink<'_>) { + let jacks = [ + (914.0, 355.0), + (991.0, 355.0), + (914.0, 408.0), + (991.0, 408.0), + ]; + for (i, (cx, cy)) in jacks.iter().enumerate() { + let f = b.add( + root, + draw::frame(INPUTS[i], cx - 22.0, cy - 22.0, 44.0, 44.0), + ); + let (c0, c1) = (22.0, 22.0); + let nut = draw::path( + "jack nut", + 0.0, + 0.0, + 44.0, + 44.0, + draw::regular(c0, c1, 12.5, 6, 0.0), + draw::linear( + 160.0, + &[(0.0, "#f2f2f0"), (0.5, "#7d7e7c"), (1.0, "#c9cac7")], + ), + ); + b.add(f, draw::stroked(nut, "#202020", 0.8)); + b.add( + f, + draw::circle( + "jack ring", + c0, + c1, + 8.5, + draw::radial(&[(0.0, "#6a6a6a"), (1.0, "#d8d8d6")]), + ), + ); + b.add( + f, + draw::circle("jack hole", c0, c1, 5.0, draw::solid("#050505")), + ); + // The plug: a black barrel with a chrome ferrule, its cable + // dropping away below the panel. + let plug = b.add(f, draw::frame("plug", 0.0, 0.0, 44.0, 44.0)); + rk::curve( + b, + plug, + "cable", + (44.0, 44.0), + &[ + (c0, c1), + (c0 + 4.0, c1 + 20.0), + (c0 + 30.0, c1 + 60.0), + (c0 + 80.0, c1 + 110.0), + ], + 5.0, + "#0a0a0a", + ); + b.add( + plug, + draw::circle( + "plug ferrule", + c0, + c1, + 7.0, + draw::conic(0.0, &[(0.0, "#e8e8e6"), (0.5, "#6b6b69"), (1.0, "#e8e8e6")]), + ), + ); + b.add( + plug, + draw::circle( + "plug barrel", + c0, + c1, + 5.2, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#4a4a4a"), (1.0, "#080808")]), + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: 4, + }, + }) + .slot(SlotRole::Led, plug.0); + c.binding = Some(rk::bind(&a("input"))); + b.controls.insert(f, c); + } + let serif = Letter::new(11.0, 500.0, PRINT).stretch(0.8); + for (y, word) in [(355.0, "HIGH"), (408.0, "LOW")] { + rk::letter(b, root, "I", (894.0, y), serif); + rk::letter(b, root, "II", (1016.0, y), serif); + rk::silk( + b, + root, + word, + 953.0, + y, + Ink::new(10.0, PRINT).weight(500.0).track(0.02), + ); + } + let _ = legend; +} diff --git a/examples/plugin-faces/src/suite_blackout_gain/art.rs b/examples/plugin-faces/src/suite_blackout_gain/art.rs new file mode 100644 index 00000000..c9c02459 --- /dev/null +++ b/examples/plugin-faces/src/suite_blackout_gain/art.rs @@ -0,0 +1,911 @@ +//! The Blackout Gain family's printed art: white line-work on black — +//! profile skulls, braided locks, knots and petal rosettes, the notched +//! border every box is framed in, and the maker's badge (an invented +//! "VANTOM" plate with hex bolts at its ends). +//! +//! Every motif is authored once in a 100 × 100 box facing right and put +//! down with [`Put`] (where, how big, mirrored or not). + +use frame::document::proto::NodeId; +use frame::surface::kit::draw::family; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::widgets::Builder; + +use crate::delay::parts::{self, Ink}; +use crate::rigkit::{self as rk, Letter}; + +/// The print: an off-white with a little warmth. +pub const INK: &str = "#e4e1da"; +/// The shading on the print (skull shadows, ornament fills). +pub const SHADE: &str = "#8d8a84"; +/// The lid. +pub const BLACK: &str = "#161616"; +/// The LED green of the gate and boost boxes. +pub const GREEN: &str = "#3dff6a"; +/// The drive's red. +pub const RED: &str = "#ff3326"; + +/// Where a motif goes: its box's top-left, its size (the 100-unit box +/// becomes `s` px), mirrored left–right or not. +#[derive(Clone, Copy)] +pub struct Put { + pub x: f64, + pub y: f64, + pub s: f64, + pub flip: bool, + /// Vertical scale on top of `s` (1 = square). + pub tall: f64, +} + +impl Put { + #[must_use] + pub const fn new(x: f64, y: f64, s: f64, flip: bool) -> Self { + Self { + x, + y, + s, + flip, + tall: 1.0, + } + } + fn pt(&self, u: f64, v: f64) -> (f64, f64) { + let u = if self.flip { 100.0 - u } else { u }; + ( + self.x + u * self.s / 100.0, + self.y + v * self.s * self.tall / 100.0, + ) + } +} + +/// Map an absolute SVG path (M L C Q Z with coordinate pairs) through `p`. +#[must_use] +pub fn map_path(d: &str, p: &Put) -> String { + let mut out = String::new(); + let mut nums: Vec = Vec::new(); + let flush = |nums: &mut Vec, out: &mut String| { + for pair in nums.chunks(2) { + if let [u, v] = pair { + let (x, y) = p.pt(*u, *v); + out.push_str(&format!("{x:.2} {y:.2} ")); + } + } + nums.clear(); + }; + for tok in d.split_whitespace() { + if let Ok(v) = tok.parse::() { + nums.push(v); + } else { + flush(&mut nums, &mut out); + out.push_str(tok); + out.push(' '); + } + } + flush(&mut nums, &mut out); + out +} + +/// A filled motif path under `parent` (which spans `size`). +pub fn fill( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + size: (f64, f64), + d: &str, + p: &Put, + color: &str, +) -> NodeId { + b.add( + parent, + draw::path( + name, + 0.0, + 0.0, + size.0, + size.1, + map_path(d, p), + draw::solid(color), + ), + ) +} + +/// A stroked motif path. +#[allow(clippy::too_many_arguments)] +pub fn stroke( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + size: (f64, f64), + d: &str, + p: &Put, + weight: f64, + color: &str, +) -> NodeId { + let mut n = draw::path( + name, + 0.0, + 0.0, + size.0, + size.1, + map_path(d, p), + draw::solid(color), + ); + n.fills = Vec::new(); + n.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(color)], + weight, + cap: frame::document::proto::StrokeCap::Round, + join: frame::document::proto::StrokeJoin::Round, + align: frame::document::proto::StrokeAlign::Center, + ..frame::document::proto::Stroke::default() + }; + b.add(parent, n) +} + +// ── skull ──────────────────────────────────────────────────────────────── + +// The skull is drawn as the boxes print it: an engraved profile, facing +// right — a high domed cranium, a deep socket under a heavy brow, the +// nasal notch, a jutting cheekbone, the upper teeth set in the maxilla and +// a separate jaw hinged under the ear, hatched on its unlit (back) side. +const CRANIUM: &str = "M 10 48 C 6 28 20 8 44 5 C 64 2 82 10 89 24 C 92 30 91 36 94 41 L 97 47 C 97 51 99 55 99 59 L 94 60 C 94 63 95 66 93 68 C 80 69 64 70 52 70 C 46 70 42 67 38 63 C 35 68 31 73 25 75 C 16 70 11 60 10 48 Z"; +const JAW: &str = "M 40 64 C 44 66 46 70 49 73 L 93 72 C 94 79 92 85 86 89 C 76 95 62 96 52 93 C 46 91 43 86 42 80 C 40 74 38 68 40 64 Z"; +const EYE: &str = "M 63 32 C 67 24 81 24 86 32 C 89 39 84 47 76 47 C 67 47 61 40 63 32 Z"; +const EYE_DEEP: &str = "M 69 34 C 72 30 80 30 83 35 C 85 40 81 44 76 44 C 71 44 67 39 69 34 Z"; +const BROW: &str = "M 52 25 C 62 14 82 14 91 27 C 84 21 66 20 56 30 Z"; +const NOSE: &str = "M 90 43 C 93 48 97 53 96 58 L 86 58 C 86 53 88 47 90 43 Z"; +const CHEEK: &str = "M 46 50 C 56 45 72 47 86 55 L 85 59 C 72 55 58 55 48 59 Z"; +const HINGE: &str = "M 34 58 C 38 60 41 63 42 67 C 39 67 36 64 34 58 Z"; + +/// A profile skull, grinning, facing right (or left if flipped). +pub fn skull(b: &mut Builder<'_>, parent: NodeId, size: (f64, f64), p: &Put) { + let k = p.s / 100.0; + let p = &Put { + tall: p.tall * 0.9, + ..*p + }; + let hair = (0.7 * k).max(0.45); + fill(b, parent, "skull", size, CRANIUM, p, INK); + fill(b, parent, "skull jaw", size, JAW, p, INK); + // Engraved shading: arcs following the back of the cranium, tighter + // towards the edge, and a few along the jaw's underside. + let mut hatch = String::new(); + for i in 0..9 { + let r = 33.0 - f64::from(i) * 2.3; + let (a0, a1) = (246.0 + f64::from(i) * 1.5, 304.0 - f64::from(i) * 2.0); + for j in 0..=10 { + let a = (a0 + (a1 - a0) * f64::from(j) / 10.0).to_radians(); + let (u, v) = (50.0 + r * a.sin() * 1.02, 41.0 - r * a.cos()); + hatch.push_str(&format!( + "{} {u:.1} {v:.1} ", + if j == 0 { "M" } else { "L" } + )); + } + } + for i in 0..5 { + let v = 84.0 + f64::from(i) * 2.2; + let fi = f64::from(i); + hatch.push_str(&format!( + "M {:.1} {v:.1} C 60 {:.1} 74 {:.1} {:.1} {:.1} ", + 48.0 + fi * 2.0, + v + 3.0, + v + 3.0, + 88.0 - fi * 2.0, + v - 1.0 + )); + } + stroke(b, parent, "skull hatching", size, &hatch, p, hair, SHADE); + // Temple ridge, the suture, the cheek's hollow. + stroke( + b, + parent, + "skull ridges", + size, + "M 30 20 C 46 12 66 13 82 22 M 44 8 C 46 16 42 22 46 30 C 50 36 46 42 50 46 M 40 60 C 46 56 52 56 58 60", + p, + hair * 1.2, + SHADE, + ); + fill(b, parent, "skull cheek", size, CHEEK, p, SHADE); + fill(b, parent, "skull brow", size, BROW, p, SHADE); + fill(b, parent, "skull eye", size, EYE, p, BLACK); + fill(b, parent, "skull eye depth", size, EYE_DEEP, p, "#2a2927"); + fill(b, parent, "skull nose", size, NOSE, p, BLACK); + fill(b, parent, "skull hinge", size, HINGE, p, BLACK); + // Teeth: the upper row hangs from the maxilla, the lower rises from + // the jaw; the gap between them is the grin. + let mut upper = String::new(); + let mut lower = String::new(); + for i in 0..9 { + let u = 51.0 + f64::from(i) * 4.6; + upper.push_str(&format!( + "M {u} 66.5 L {} 66.5 L {} 72.5 L {} 73.2 L {} 72.5 Z ", + u + 3.9, + u + 3.6, + u + 1.95, + u + 0.3 + )); + lower.push_str(&format!( + "M {u} 80 L {} 80 L {} 75 L {} 74.4 L {} 75 Z ", + u + 3.9, + u + 3.6, + u + 1.95, + u + 0.3 + )); + } + fill( + b, + parent, + "skull gap", + size, + "M 49 66 L 94 65 L 93 81 L 48 82 Z", + p, + BLACK, + ); + fill(b, parent, "skull teeth", size, &upper, p, INK); + fill(b, parent, "skull teeth", size, &lower, p, INK); +} + +/// Braided locks hanging from `top` down `len` px at `x`: a few twisting +/// strands with the braid's cross ticks. +#[allow(clippy::too_many_arguments)] +pub fn locks( + b: &mut Builder<'_>, + parent: NodeId, + size: (f64, f64), + (x, top): (f64, f64), + len: f64, + strands: usize, + spread: f64, + weight: f64, +) { + for s in 0..strands { + let x0 = x + (s as f64 - (strands as f64 - 1.0) / 2.0) * spread; + let pts: Vec<(f64, f64)> = (0..=12) + .map(|i| { + let t = f64::from(i) / 12.0; + ( + x0 + (t * 7.0 + s as f64).sin() * spread * 0.35, + top + t * len, + ) + }) + .collect(); + rk::curve(b, parent, "lock", size, &pts, weight, INK); + let ticks: Vec> = pts + .windows(2) + .map(|w| { + let (mx, my) = ((w[0].0 + w[1].0) / 2.0, (w[0].1 + w[1].1) / 2.0); + vec![(mx - spread * 0.3, my - 1.2), (mx + spread * 0.3, my + 1.2)] + }) + .collect(); + let mut n = parts::stroke_path( + "braid", + (0.0, 0.0, size.0, size.1), + &ticks, + weight * 0.7, + BLACK, + false, + ); + n.stroke.cap = frame::document::proto::StrokeCap::None; + b.add(parent, n); + } +} + +/// A petal rosette (the gate's flower behind its knob): `n` white petals +/// round (`cx`, `cy`), outlined, their hearts black. +pub fn rosette( + b: &mut Builder<'_>, + parent: NodeId, + size: (f64, f64), + (cx, cy): (f64, f64), + r: f64, + n: u32, +) { + // A fan of round petals over the top half, each with a black keyhole + // in it, and a white ring round the knob's seat. + let step = 200.0 / f64::from(n.max(2) - 1); + let pr = r * 0.34; + let mut white = String::new(); + let mut holes = String::new(); + let circ = |x: f64, y: f64, r: f64| { + let k = 0.552_284_75 * r; + format!( + "M {:.2} {y:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {y:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {y:.2} Z ", + x + r, + x + r, + y + k, + x + k, + y + r, + y + r, + x - k, + y + r, + x - r, + y + k, + x - r, + x - r, + y - k, + x - k, + y - r, + y - r, + x + k, + y - r, + x + r, + y - k, + x + r + ) + }; + for i in 0..n { + let a = -100.0 + step * f64::from(i); + let (x, y) = draw::polar(cx, cy, r - pr, a); + white.push_str(&circ(x, y, pr)); + let (hx, hy) = draw::polar(cx, cy, r - pr * 0.8, a); + holes.push_str(&circ(hx, hy, pr * 0.32)); + } + white.push_str(&circ(cx, cy, r * 0.68)); + b.add( + parent, + draw::path("rosette", 0.0, 0.0, size.0, size.1, white, draw::solid(INK)), + ); + b.add( + parent, + draw::path( + "rosette holes", + 0.0, + 0.0, + size.0, + size.1, + holes, + draw::solid(BLACK), + ), + ); + b.add( + parent, + draw::circle("rosette seat", cx, cy, r * 0.6, draw::solid(BLACK)), + ); +} + +/// A celtic knot tile: a four-lobed interlace woven through a ring, drawn +/// as a white ribbon with black edges and a centre groove, the ring laid +/// over the lobes at alternate crossings — `s` px square at (`cx`, `cy`). +pub fn knot(b: &mut Builder<'_>, parent: NodeId, size: (f64, f64), (cx, cy): (f64, f64), s: f64) { + let h = s / 2.0; + let p = Put::new(cx - h, cy - h, s, false); + // Four lobes, each a loop out from the centre along a diagonal. + let mut lobes = String::new(); + for q in 0..4 { + let a = 45.0 + 90.0 * f64::from(q); + let tip = draw::polar(50.0, 50.0, 46.0, a); + let l = draw::polar(50.0, 50.0, 40.0, a - 44.0); + let r = draw::polar(50.0, 50.0, 40.0, a + 44.0); + let c0 = draw::polar(50.0, 50.0, 8.0, a - 80.0); + let c1 = draw::polar(50.0, 50.0, 8.0, a + 80.0); + let t = ((r.0 - l.0) * 0.28, (r.1 - l.1) * 0.28); + lobes.push_str(&format!( + "M {:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} ", + c0.0, c0.1, l.0, l.1, tip.0 - t.0, tip.1 - t.1, tip.0, tip.1, tip.0 + t.0, tip.1 + t.1, r.0, r.1, c1.0, c1.1 + )); + } + let rr = 27.0; + let k = 0.552_284_75 * rr; + let ring = format!( + "M 77 50 C 77 {:.2} {:.2} 23 50 23 C {:.2} 23 23 {:.2} 23 50 C 23 {:.2} {:.2} 77 50 77 C {:.2} 77 77 {:.2} 77 50 Z ", + 50.0 - k, + 50.0 + k, + 50.0 - k, + 50.0 - k, + 50.0 + k, + 50.0 - k, + 50.0 + k, + 50.0 + k + ); + let wide = (s * 0.075).max(1.2); + let edge = wide + (s * 0.035).max(0.7); + let groove = (s * 0.014).max(0.3); + for (name, d) in [("knot lobes", &lobes), ("knot ring", &ring)] { + stroke(b, parent, &format!("{name} edge"), size, d, &p, edge, BLACK); + stroke(b, parent, name, size, d, &p, wide, INK); + stroke( + b, + parent, + &format!("{name} groove"), + size, + d, + &p, + groove, + BLACK, + ); + } + // Over-under: at alternate crossings the lobe passes over the ring — + // a short piece of the lobe's ribbon laid back on top. + for q in 0..4 { + let a = 45.0 + 90.0 * f64::from(q) - 32.0; + let (u, v) = draw::polar(50.0, 50.0, rr, a); + let (dx, dy) = draw::polar(0.0, 0.0, 5.0, a - 40.0); + let piece = format!( + "M {:.2} {:.2} L {:.2} {:.2}", + u - dx, + v - dy, + u + dx, + v + dy + ); + stroke(b, parent, "knot over edge", size, &piece, &p, edge, BLACK); + stroke(b, parent, "knot over", size, &piece, &p, wide, INK); + stroke( + b, + parent, + "knot over groove", + size, + &piece, + &p, + groove, + BLACK, + ); + } +} + +/// A scroll flourish (a curl pair) centred on (`cx`, `cy`), `w` wide. +pub fn flourish( + b: &mut Builder<'_>, + parent: NodeId, + size: (f64, f64), + (cx, cy): (f64, f64), + w: f64, + flip: bool, +) { + const CURL: &str = "M 0 50 C 20 50 30 30 45 30 C 60 30 62 48 52 52 C 44 55 40 46 46 42 M 45 30 C 60 20 80 40 100 40"; + let p = Put::new(cx - w / 2.0, cy - w / 2.0, w, flip); + stroke( + b, + parent, + "flourish", + size, + CURL, + &p, + (w * 0.035).max(0.8), + INK, + ); +} + +// ── frame, badge, jacks ────────────────────────────────────────────────── + +/// The notched white border inset `m` px inside `w × h`, from `top` down to +/// `bottom`, with the jack arrows and the supply legend over the top rule. +pub fn border( + b: &mut Builder<'_>, + parent: NodeId, + (w, h): (f64, f64), + m: f64, + (top, bottom): (f64, f64), + supply: &str, +) { + let n = 7.0; + let (x0, x1, y0, y1) = (m, w - m, top, bottom); + let d = format!( + "M {} {y0} L {} {y0} L {x1} {} L {x1} {} L {} {y1} L {} {y1} L {x0} {} L {x0} {} Z", + x0 + n, + x1 - n, + y0 + n, + y1 - n, + x1 - n, + x0 + n, + y1 - n, + y0 + n + ); + let mut rim = draw::path("border", 0.0, 0.0, w, h, d, draw::solid(INK)); + rim.fills = Vec::new(); + rim.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(INK)], + weight: 1.6, + join: frame::document::proto::StrokeJoin::Miter, + ..frame::document::proto::Stroke::default() + }; + b.add(parent, rim); + // A gap in the top rule for the arrows and the supply legend. + b.add( + parent, + draw::rect( + "border gap", + w / 2.0 - 30.0, + y0 - 2.0, + 60.0, + 4.0, + draw::solid(BLACK), + ), + ); + for (x, up) in [(w * 0.3, false), (w * 0.7, true)] { + let tri = if up { + draw::polygon(&[(x - 3.2, y0 + 1.5), (x + 3.2, y0 + 1.5), (x, y0 - 4.0)]) + } else { + draw::polygon(&[(x - 3.2, y0 - 4.0), (x + 3.2, y0 - 4.0), (x, y0 + 1.5)]) + }; + b.add( + parent, + draw::path("jack arrow", 0.0, 0.0, w, h, tri, draw::solid(INK)), + ); + } + rk::silk( + b, + parent, + supply, + w / 2.0, + y0 - 1.0, + Ink::new(4.4, INK).weight(700.0).track(0.02), + ); + // The barrel-jack glyph under it. + let gy = y0 + 5.0; + b.add( + parent, + draw::stroked( + draw::circle("dc glyph", w / 2.0, gy, 1.8, draw::solid(BLACK)), + INK, + 0.7, + ), + ); + parts::rule(b, parent, w / 2.0 - 7.0, w / 2.0 - 2.0, gy, 0.7, INK); + parts::rule(b, parent, w / 2.0 + 2.0, w / 2.0 + 7.0, gy, 0.7, INK); +} + +/// A hex bolt head, printed (the badge's ends). +pub fn bolt(b: &mut Builder<'_>, parent: NodeId, size: (f64, f64), (cx, cy): (f64, f64), r: f64) { + let mut hexn = draw::path( + "bolt", + 0.0, + 0.0, + size.0, + size.1, + draw::regular(cx, cy, r, 6, 0.0), + draw::solid(BLACK), + ); + hexn = draw::stroked(hexn, INK, (r * 0.22).max(0.6)); + b.add(parent, hexn); + b.add( + parent, + draw::circle("bolt dot", cx, cy, r * 0.38, draw::solid(INK)), + ); +} + +/// The maker's badge centred on (`cx`, `cy`), `w` wide: a stretched +/// octagonal plate outlined in white, bolts at both ends, the invented +/// maker's word in squared extended capitals and a spaced line under it. +pub fn badge(b: &mut Builder<'_>, parent: NodeId, size: (f64, f64), (cx, cy): (f64, f64), w: f64) { + let h = w * 0.2; + let (x0, x1, y0, y1) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0); + let c = h * 0.45; + let d = format!( + "M {} {y0} L {} {y0} L {x1} {} L {x1} {} L {} {y1} L {} {y1} L {x0} {} L {x0} {} Z", + x0 + c, + x1 - c, + y0 + c, + y1 - c, + x1 - c, + x0 + c, + y1 - c, + y0 + c + ); + let plate = draw::stroked( + draw::path("badge", 0.0, 0.0, size.0, size.1, d, draw::solid(BLACK)), + INK, + (h * 0.07).max(0.9), + ); + b.add(parent, plate); + let br = h * 0.24; + bolt(b, parent, size, (x0 + c + br * 0.4, cy), br); + bolt(b, parent, size, (x1 - c - br * 0.4, cy), br); + let (_, s) = rk::letter_fit( + b, + parent, + "VANTOM", + (cx, cy - h * 0.1), + w * 0.6, + Letter::new(10.0, 560.0, INK).stretch(1.25).track(0.08), + ); + rk::letter_fit( + b, + parent, + "AMPLIFICATION", + (cx, cy + s * 0.62), + w * 0.44, + Letter::new(3.0, 500.0, INK).track(0.5), + ); +} + +/// A white printed ring round a stomp switch, with a flourish either side. +pub fn switch_ring(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64), r: f64) { + b.add( + parent, + draw::stroked( + draw::circle("switch ring", cx, cy, r, draw::solid("#00000000")), + INK, + 2.2, + ), + ); +} + +/// The plain stomp switch, its button centred on (`cx`, `cy`), the LED +/// (`dx`, `dy`) from it. +#[allow(clippy::too_many_arguments)] +pub fn stomp( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + d: f64, + led: &str, + (dx, dy): (f64, f64), + led_size: f64, + momentary: bool, +) -> NodeId { + let mut opts = vec![ + format!("color={led}"), + format!("led_dx={dx}"), + format!("led_dy={dy}"), + format!("led_size={led_size}"), + ]; + if momentary { + opts.push("momentary".into()); + } + let p = Place { + options: Some(opts), + ..Place::new(name) + .size(d) + .bind(rk::bind(addr)) + .touch(TOUCH_MIN) + }; + let root = frame::surface::kit::place(b, parent, "switch.stomp_box", &p) + .unwrap_or_else(|e| panic!("{e}")); + let at = b.scene.node(root).and_then(|n| { + n.children.iter().find_map(|c| { + b.scene.node(*c).filter(|k| k.name == "washer").map(|k| { + ( + k.transform.tx + k.size.width / 2.0, + k.transform.ty + k.size.height / 2.0, + ) + }) + }) + }); + let (ax, ay) = at.unwrap_or((0.0, 0.0)); + parts::move_to(b, root, cx - ax, cy - ay); + root +} + +/// A legend in the family's squared capitals. +pub fn legend(size: f64) -> Ink<'static> { + Ink::new(size, INK).weight(700.0).track(0.04) +} + +/// Model-name lettering: the boxes' tall condensed display capitals with +/// spurred serifs (Pirata One), fitted to `w` but never taller than a +/// capital of `max` px. +pub fn title(b: &mut Builder<'_>, parent: NodeId, s: &str, (cx, cy): (f64, f64), w: f64) -> f64 { + title_max(b, parent, s, (cx, cy), w, 46.0) +} + +/// [`title`] with its own size cap. +pub fn title_max( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + w: f64, + max: f64, +) -> f64 { + let l = Letter::new(20.0, 400.0, INK) + .family(family::PIRATA_ONE) + .track(0.04); + let probe = rk::lettering::set_width(s, &l.size(100.0)); + let size = (100.0 * w / probe).min(max); + rk::letter(b, parent, s, (cx, cy), l.size(size)); + size +} + +/// Model-name lettering cut hollow: the letters' outlines only, as the +/// gate prints its name. +pub fn hollow_title( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + (w, h): (f64, f64), + weight: f64, +) { + use crate::rigkit::lettering::outline; + let l = Letter::new(100.0, 400.0, INK) + .family(family::PIRATA_ONE) + .track(0.08); + // Cut at 100 px, then scale the outline's own box to `w` × `h`. + let (d, _) = outline(s, &l); + let nums: Vec = d + .split_whitespace() + .filter_map(|t| t.parse::().ok()) + .collect(); + let (mut bx0, mut by0, mut bx1, mut by1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for xy in nums.chunks(2) { + if let [x, y] = xy { + bx0 = bx0.min(*x); + bx1 = bx1.max(*x); + by0 = by0.min(*y); + by1 = by1.max(*y); + } + } + let (sx, sy) = (w / (bx1 - bx0).max(1.0), h / (by1 - by0).max(1.0)); + let (x0, y0) = (cx - w / 2.0, cy - h / 2.0); + let mapped = map_xy(&d, |x, y| (x0 + (x - bx0) * sx, y0 + (y - by0) * sy)); + let mut n = draw::path( + &format!("lettering {s}"), + 0.0, + 0.0, + cx + w / 2.0, + cy + h / 2.0, + mapped, + draw::solid(INK), + ); + n.fills = Vec::new(); + n.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(INK)], + weight, + join: frame::document::proto::StrokeJoin::Miter, + ..frame::document::proto::Stroke::default() + }; + b.add(parent, n); +} + +/// Map every coordinate pair of an absolute path through `f`. +#[must_use] +pub fn map_xy(d: &str, f: impl Fn(f64, f64) -> (f64, f64)) -> String { + let mut out = String::new(); + let mut nums: Vec = Vec::new(); + let flush = |nums: &mut Vec, out: &mut String| { + for pair in nums.chunks(2) { + if let [u, v] = pair { + let (x, y) = f(*u, *v); + out.push_str(&format!("{x:.2} {y:.2} ")); + } + } + nums.clear(); + }; + for tok in d.split_whitespace() { + if let Ok(v) = tok.parse::() { + nums.push(v); + } else { + flush(&mut nums, &mut out); + out.push_str(tok); + out.push(' '); + } + } + flush(&mut nums, &mut out); + out +} + +/// A control's printed legend cut as display lettering, centred on panel +/// (`cx`, `cy`), made the control's `Label` (so the word on the panel still +/// names the control). +pub fn label_letter( + b: &mut Builder<'_>, + control: NodeId, + s: &str, + (cx, cy): (f64, f64), + l: Letter<'_>, +) -> NodeId { + let (tx, ty) = b + .scene + .node(control) + .map_or((0.0, 0.0), |n| (n.transform.tx, n.transform.ty)); + let id = rk::letter(b, control, s, (cx - tx, cy - ty), l); + if let Some(c) = b.controls.get_mut(&control) { + c.slots + .retain(|s| s.role != frame::surface::proto::SlotRole::Label); + c.slots.push(frame::surface::proto::Slot { + role: frame::surface::proto::SlotRole::Label, + node: id.0, + }); + } + id +} + +/// The family's knob legend: tall condensed heavy capitals. +#[must_use] +pub fn knob_letter(size: f64) -> Letter<'static> { + Letter::new(size * 1.12, 600.0, INK) + .family(family::OSWALD) + .track(0.03) +} + +/// Black leather tolex over `(x, y, w, h)` (rounded `radius`): a near-black +/// base and the grain's crackle — short creases in a darker and a lighter +/// line — with the lighting shader on the grain (GPU renderers raise it +/// into relief; without effects it still reads as leather). Returns the +/// covering frame (clipped), a parent for anything on the covering. +pub fn tolex( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + radius: f64, + seed: u64, +) -> NodeId { + let mut n = draw::frame(name, x, y, w, h); + n.fills = vec![draw::linear( + 180.0, + &[(0.0, "#232323"), (0.5, "#1b1b1b"), (1.0, "#141414")], + )]; + n.clips_content = true; + let root = b.add(parent, draw::rounded(n, radius)); + let mut s = seed | 1; + let mut rnd = move || { + s = s + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (s >> 33) as f64 / f64::from(u32::MAX >> 1) + }; + for (color, weight, per) in [("#080808", 0.6, 34.0), ("#383838", 0.5, 60.0)] { + let mut d = String::new(); + let count = (w * h / per) as usize; + for _ in 0..count { + let (px, py) = (rnd() * w, rnd() * h); + let a = rnd() * std::f64::consts::TAU; + let len = 1.4 + rnd() * 2.6; + let bend = (rnd() - 0.5) * 2.4; + let (qx, qy) = (px + a.cos() * len, py + a.sin() * len); + let (mx, my) = ( + (px + qx) / 2.0 - a.sin() * bend, + (py + qy) / 2.0 + a.cos() * bend, + ); + d.push_str(&format!( + "M{px:.1} {py:.1} Q{mx:.1} {my:.1} {qx:.1} {qy:.1} " + )); + } + let mut g = draw::path("grain", 0.0, 0.0, w, h, d, draw::solid(color)); + g.fills = Vec::new(); + g.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(color)], + weight, + cap: frame::document::proto::StrokeCap::Round, + ..frame::document::proto::Stroke::default() + }; + let g = b.add(root, g); + parts::shader(b, g, "lighting", &[("strength", 0.6), ("scale", 1.5)]); + } + // The room's light across it. + b.add( + root, + draw::rect( + "sheen", + 0.0, + 0.0, + w, + h, + draw::linear( + 165.0, + &[(0.0, "#ffffff10"), (0.4, "#ffffff00"), (1.0, "#00000030")], + ), + ), + ); + root +} + +/// A piping line (the welt) inset along a rounded rectangle. +pub fn piping( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + radius: f64, +) { + let n = draw::rounded(draw::frame("piping", x, y, w, h), radius); + let n = draw::stroked(n, "#0a0a0a", 2.0); + b.add( + parent, + draw::with_effects(n, vec![draw::drop_shadow("#ffffff14", 0.0, 1.0, 0.0)]), + ); +} + +/// Left-aligned silkscreen helper re-exported for the units. +pub fn silk_left( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + x: f64, + cy: f64, + ink: Ink<'_>, +) -> NodeId { + parts::silk_left(b, parent, s, x, cy, ink) +} diff --git a/examples/plugin-faces/src/suite_blackout_gain/cab.rs b/examples/plugin-faces/src/suite_blackout_gain/cab.rs new file mode 100644 index 00000000..6d5d9d66 --- /dev/null +++ b/examples/plugin-faces/src/suite_blackout_gain/cab.rs @@ -0,0 +1,695 @@ +//! The 4×12 — Cab form, 900 × 900: a closed-back straight cab in black +//! leather, corner protectors, a two-tone baffle (lighter top half), four +//! twelves with ribbed paper cones, and the suite's cab controls: +//! +//! - two mics, A on the top-left speaker and B on the top-right, each a +//! handle you drag across the cone — sideways is POSITION (cap → edge), +//! down is DISTANCE (on the grille → back); +//! - a strip over the cab's foot (the featured-controls overlay): per mic +//! the model, on/off, LEVEL, PAN and PHASE; ROOM on/off and its LEVEL in +//! the middle. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{ + Axis, ButtonMode, Control, ControlKind, HandleAxis, SlotRole, ValueFormat, +}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::art; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, KnobStyle}; + +const S: f64 = 900.0; +const MICS: &[&str] = &[ + "Dynamic 1", + "Dynamic 1 Off-Axis", + "Dynamic 2", + "Condenser 1", + "Condenser 2", + "Ribbon 1", + "Ribbon 2", +]; +const TEXT: &str = "#d6d6d4"; +const LIT: &str = "#5dff8a"; + +fn a(s: &str) -> String { + format!("bgcab/{s}") +} + +pub fn face() -> Face { + let scene = canvas("4x12 Cabinet", |b, root| { + parts::fit_root(b, root, S, S); + cabinet(b, root); + let speakers = [ + (264.0, 256.0), + (633.0, 256.0), + (264.0, 637.0), + (633.0, 637.0), + ]; + for (i, (cx, cy)) in speakers.iter().enumerate() { + speaker(b, root, (*cx, *cy), 147.0, i); + } + mic_handle(b, root, "Mic A", "a", speakers[0], 147.0); + mic_handle(b, root, "Mic B", "b", speakers[1], 147.0); + strip(b, root); + }); + let mut params = Vec::new(); + for (m, on, model) in [("a", true, 0usize), ("b", true, 5)] { + params.push(rk::toggle( + &a(&format!("mic_{m}_on")), + &format!("Mic {} On", m.to_uppercase()), + on, + )); + params.push(rk::choice( + &a(&format!("mic_{m}_model")), + &format!("Mic {} Model", m.to_uppercase()), + MICS, + model, + )); + params.push( + crate::delay::parts::param( + &a(&format!("mic_{m}_position")), + &format!("Mic {} Position", m.to_uppercase()), + frame::surface::proto::Range::linear(0.0, 1.0, frame::surface::proto::Unit::None), + 0.5, + ) + .featured(), + ); + params.push(crate::delay::parts::param( + &a(&format!("mic_{m}_distance")), + &format!("Mic {} Distance", m.to_uppercase()), + frame::surface::proto::Range::linear(0.0, 1.0, frame::surface::proto::Unit::None), + 0.0, + )); + params.push( + rk::db( + &a(&format!("mic_{m}_level")), + &format!("Mic {} Level", m.to_uppercase()), + -36.0, + 12.0, + 0.0, + ) + .featured(), + ); + params.push( + crate::delay::parts::param( + &a(&format!("mic_{m}_pan")), + &format!("Mic {} Pan", m.to_uppercase()), + frame::surface::proto::Range::linear( + -100.0, + 100.0, + frame::surface::proto::Unit::Percent, + ), + 0.0, + ) + .bipolar(), + ); + params.push(rk::toggle( + &a(&format!("mic_{m}_phase")), + &format!("Mic {} Phase", m.to_uppercase()), + false, + )); + } + params.push(rk::toggle(&a("room_on"), "Room Send", false)); + params.push(rk::db(&a("room_level"), "Room Level", -36.0, 12.0, 0.0)); + Face { + file: "05-cab-4x12", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +/// The box: tolex, corner protectors, the frame round the baffle, the +/// two-tone baffle. +fn cabinet(b: &mut Builder<'_>, root: NodeId) { + b.add( + root, + draw::with_effects( + draw::rounded( + draw::rect( + "cab shadow", + 10.0, + 14.0, + S - 20.0, + S - 16.0, + draw::solid("#000000"), + ), + 16.0, + ), + vec![draw::drop_shadow("#000000c0", 0.0, 8.0, 16.0)], + ), + ); + let body = art::tolex( + b, + root, + "Cabinet", + (12.0, 4.0, S - 24.0, S - 10.0), + 14.0, + 0x4c12_cab5, + ); + let (bw, bh) = (S - 24.0, S - 10.0); + let edge = draw::stroked( + draw::rounded(draw::frame("edge", 0.0, 0.0, bw, bh), 14.0), + "#050505", + 3.0, + ); + b.add( + body, + draw::with_effects( + edge, + vec![ + draw::inner_shadow("#000000e0", 0.0, 0.0, 12.0), + draw::inner_shadow("#ffffff22", 0.0, 2.0, 1.0), + ], + ), + ); + // The baffle frame (a strip of grey-stained wood) and the two halves. + let (fx, fy, fw, fh) = (50.0, 54.0, bw - 100.0, bh - 110.0); + let frame = draw::rect( + "baffle frame", + fx, + fy, + fw, + fh, + draw::linear( + 90.0, + &[(0.0, "#2e2e2e"), (0.5, "#3a3a3a"), (1.0, "#2a2a2a")], + ), + ); + b.add( + body, + draw::with_effects(frame, vec![draw::inner_shadow("#000000", 0.0, 2.0, 4.0)]), + ); + let half = (fh - 30.0) / 2.0; + for (i, (y, c0, c1)) in [ + (fy + 10.0, "#383838", "#2c2c2c"), + (fy + 20.0 + half, "#1e1e1e", "#171717"), + ] + .iter() + .enumerate() + { + let panel = draw::rect( + if i == 0 { + "baffle top" + } else { + "baffle bottom" + }, + fx + 12.0, + *y, + fw - 24.0, + half, + draw::linear(180.0, &[(0.0, c0), (1.0, c1)]), + ); + b.add( + body, + draw::with_effects(panel, vec![draw::inner_shadow("#000000b0", 0.0, 2.0, 6.0)]), + ); + } + // Corner protectors. + for (x, y, rot) in [ + (0.0, 0.0, 0.0), + (bw - 40.0, 0.0, 90.0), + (0.0, bh - 40.0, 270.0), + (bw - 40.0, bh - 40.0, 180.0), + ] { + let d = match rot as i32 { + 0 => format!( + "M {x} {} L {x} {} Q {x} {y} {} {y} L {} {y} L {} {} L {} {} L {} {} Z", + y + 40.0, + y + 10.0, + x + 10.0, + x + 40.0, + x + 40.0, + y + 14.0, + x + 14.0, + y + 14.0, + x + 14.0, + y + 40.0 + ), + 90 => format!( + "M {x} {y} L {} {y} Q {} {y} {} {} L {} {} L {} {} L {} {} L {x} {} Z", + x + 30.0, + x + 40.0, + x + 40.0, + y + 10.0, + x + 40.0, + y + 40.0, + x + 26.0, + y + 40.0, + x + 26.0, + y + 14.0, + y + 14.0 + ), + 270 => format!( + "M {x} {y} L {} {y} L {} {} L {} {} L {} {} L {} {} Q {x} {} {x} {} Z", + x + 14.0, + x + 14.0, + y + 26.0, + x + 40.0, + y + 26.0, + x + 40.0, + y + 40.0, + x + 10.0, + y + 40.0, + y + 40.0, + y + 30.0 + ), + _ => format!( + "M {} {y} L {} {y} L {} {} Q {} {} {} {} L {x} {} L {x} {} L {} {} Z", + x + 26.0, + x + 40.0, + x + 40.0, + y + 30.0, + x + 40.0, + y + 40.0, + x + 30.0, + y + 40.0, + y + 40.0, + y + 26.0, + x + 26.0, + y + 26.0 + ), + }; + let n = draw::path( + "corner", + 0.0, + 0.0, + bw, + bh, + d, + draw::linear(160.0, &[(0.0, "#3c3c3c"), (1.0, "#0e0e0e")]), + ); + b.add( + body, + draw::with_effects( + draw::stroked(n, "#000000", 1.0), + vec![draw::drop_shadow("#00000099", 0.0, 2.0, 3.0)], + ), + ); + } +} + +/// One twelve: the basket's rim with its bolts, the surround, the ribbed +/// cone, the dust cap. +fn speaker(b: &mut Builder<'_>, root: NodeId, (cx, cy): (f64, f64), r: f64, i: usize) { + b.add( + root, + draw::circle( + "speaker hole", + cx, + cy + 3.0, + r + 4.0, + draw::solid("#00000099"), + ), + ); + b.add( + root, + draw::circle( + "basket", + cx, + cy, + r, + draw::linear(170.0, &[(0.0, "#2c2c2c"), (1.0, "#121212")]), + ), + ); + for k in 0..4 { + let (bx, by) = draw::polar(cx, cy, r * 0.93, 45.0 + 90.0 * f64::from(k)); + b.add( + root, + draw::stroked( + draw::circle("basket bolt", bx, by, 3.0, draw::solid("#262626")), + "#555555", + 0.8, + ), + ); + } + b.add( + root, + draw::circle("gasket", cx, cy, r * 0.87, draw::solid("#0d0d0d")), + ); + b.add( + root, + draw::circle( + "surround", + cx, + cy, + r * 0.83, + draw::radial(&[(0.0, "#1a1a1a"), (0.9, "#2b2b2b"), (1.0, "#101010")]), + ), + ); + let cone = draw::circle( + "cone", + cx, + cy, + r * 0.74, + draw::radial_at( + 0.45, + 0.4, + 0.75, + &[(0.0, "#3a3a3a"), (0.45, "#262626"), (1.0, "#141414")], + ), + ); + b.add(root, cone); + // The cone's ribs: fine concentric rings. + for k in 0..14 { + let rr = r * (0.3 + 0.031 * f64::from(k)); + let ring = draw::stroked( + draw::circle("cone rib", cx, cy, rr, draw::solid("#00000000")), + if k % 2 == 0 { "#5c5c5c" } else { "#0a0a0a" }, + 0.9, + ); + b.add(root, draw::with_opacity(ring, 0.6)); + } + b.add( + root, + draw::with_effects( + draw::circle( + "dust cap", + cx, + cy, + r * 0.2, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#4a4a4a"), (1.0, "#101010")]), + ), + vec![draw::drop_shadow("#000000b0", 0.0, 3.0, 6.0)], + ), + ); + b.add( + root, + draw::circle( + "cone light", + cx - r * 0.25, + cy - r * 0.3, + r * 0.5, + draw::radial(&[(0.0, "#ffffff10"), (1.0, "#ffffff00")]), + ), + ); + let _ = i; +} + +/// A mic over a speaker: a handle in the area from the cap out to the +/// rim (x = position) and down (y = distance), drawn as a dynamic's grille +/// and body seen end-on. +fn mic_handle( + b: &mut Builder<'_>, + root: NodeId, + name: &str, + m: &str, + (cx, cy): (f64, f64), + r: f64, +) { + let area = b.add( + root, + draw::frame( + &format!("{name} area"), + cx - r * 0.1, + cy - r * 0.55, + r * 0.85, + r * 1.1, + ), + ); + let h = b.add(area, draw::frame(name, 0.0, 0.0, 44.0, 44.0)); + let c = 22.0; + b.add( + h, + draw::with_effects( + draw::rotated( + draw::rounded( + draw::rect( + "mic body", + c - 5.0, + c - 2.0, + 10.0, + 42.0, + draw::linear( + 90.0, + &[(0.0, "#1a1a1a"), (0.5, "#555555"), (1.0, "#101010")], + ), + ), + 3.0, + ), + 35.0, + ), + vec![draw::drop_shadow("#000000c0", 3.0, 5.0, 6.0)], + ), + ); + b.add( + h, + draw::with_effects( + draw::circle( + "mic grille", + c, + c, + 9.0, + draw::radial_at( + 0.4, + 0.35, + 0.8, + &[(0.0, "#9a9a9a"), (0.6, "#4a4a4a"), (1.0, "#1a1a1a")], + ), + ), + vec![draw::drop_shadow("#000000c0", 2.0, 4.0, 6.0)], + ), + ); + let mut mesh = String::new(); + for k in -3..=3 { + let y = c + f64::from(k) * 2.2; + let half = (81.0 - (y - c).powi(2)).max(0.0).sqrt(); + mesh.push_str(&format!( + "M {:.2} {y:.2} L {:.2} {y:.2} ", + c - half, + c + half + )); + } + let mut mn = draw::path( + "mic mesh", + 0.0, + 0.0, + 44.0, + 44.0, + mesh, + draw::solid("#000000"), + ); + mn.fills = Vec::new(); + mn.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid("#2a2a2a")], + weight: 0.6, + ..frame::document::proto::Stroke::default() + }; + b.add(h, mn); + let kind = ControlKind::Handle { + x: Some(rk::bind(&a(&format!("mic_{m}_position")))), + y: Some(rk::bind(&a(&format!("mic_{m}_distance")))), + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { min: 1.0, max: 0.0 }), + }; + // The handle is named for its position axis (a readout host shows it). + b.controls.insert( + h, + Control::new(kind).bound(rk::bind(&a(&format!("mic_{m}_position")))), + ); +} + +/// The featured-controls strip over the cab's foot. +fn strip(b: &mut Builder<'_>, root: NodeId) { + let (x, y, w, h) = (24.0, 752.0, S - 48.0, 136.0); + let mut n = draw::frame("Mic strip", x, y, w, h); + n.fills = vec![draw::solid("#0b0b0bd9")]; + let n = draw::with_effects( + draw::stroked(draw::rounded(n, 12.0), "#ffffff1f", 1.0), + vec![draw::drop_shadow("#000000a0", 0.0, 4.0, 12.0)], + ); + let s = b.add(root, n); + let knob = KnobStyle::new( + super::knob_id("knob.pedal_grip"), + "knurled", + 36.0, + "#8a8a88", + ) + .opts(super::CAB_KNOB); + let legend = Ink::new(8.0, TEXT).weight(600.0).track(0.06); + let value = Ink::new(7.0, "#8a8a88").weight(500.0).track(0.02).plain(); + for (m, x0) in [("a", 14.0), ("b", w - 354.0)] { + let up = m.to_uppercase(); + rk::silk( + b, + s, + &format!("MIC {up}"), + x0 + 28.0, + 28.0, + Ink::new(11.0, TEXT).weight(700.0).track(0.1), + ); + pill_select( + b, + s, + &format!("Mic {up} model"), + &a(&format!("mic_{m}_model")), + (x0 + 60.0, 28.0 - 15.0, 170.0, 30.0), + ); + pill_toggle( + b, + s, + &format!("Mic {up} on"), + &a(&format!("mic_{m}_on")), + (x0 + 240.0, 13.0, 44.0, 30.0), + "ON", + ); + pill_toggle( + b, + s, + &format!("Mic {up} phase"), + &a(&format!("mic_{m}_phase")), + (x0 + 292.0, 13.0, 44.0, 30.0), + "Ø", + ); + for (i, (p, name)) in [ + ("position", "Position"), + ("distance", "Distance"), + ("level", "Level"), + ("pan", "Pan"), + ] + .iter() + .enumerate() + { + let kx = x0 + 42.0 + 84.0 * i as f64; + let k = rk::knob( + b, + s, + &knob, + &a(&format!("mic_{m}_{p}")), + name, + (kx, 78.0), + 118.0, + legend, + ); + parts::value_at(b, k, 128.0, value); + match *p { + "level" => parts::format(b, k, ValueFormat::decimals(1).signed()), + "pan" => parts::format(b, k, ValueFormat::decimals(0).mark(0.0, "C")), + _ => {} + } + } + } + let cx = w / 2.0; + pill_toggle( + b, + s, + "Room send", + &a("room_on"), + (cx - 24.0, 13.0, 48.0, 30.0), + "ROOM", + ); + let k = rk::knob( + b, + s, + &knob, + &a("room_level"), + "Room", + (cx, 78.0), + 118.0, + legend, + ); + parts::value_at(b, k, 128.0, value); + parts::format(b, k, ValueFormat::decimals(1).signed()); +} + +/// A dark pill that is a toggle: its text lights green while on. +fn pill_toggle( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (x, y, w, h): (f64, f64, f64, f64), + text: &str, +) -> NodeId { + let hh = h.max(TOUCH_MIN); + let f = b.add( + parent, + draw::frame(name, x, y - (hh - h) / 2.0, w.max(TOUCH_MIN), hh), + ); + let (fw, _) = (w.max(TOUCH_MIN), hh); + b.add( + f, + draw::stroked( + draw::rounded( + draw::rect( + "pill", + (fw - w) / 2.0, + (hh - h) / 2.0, + w, + h, + draw::solid("#1c1c1c"), + ), + h / 2.0, + ), + "#ffffff30", + 1.0, + ), + ); + let ink = Ink::new(9.0, "#777775").weight(700.0).track(0.06); + b.add( + f, + parts::text_node(text, 0.0, hh / 2.0, fw, &ink, TextAlign::Center), + ); + let lit = parts::text_node(text, 0.0, hh / 2.0, fw, &ink.color(LIT), TextAlign::Center); + let lit = b.add(f, parts::emit(lit, LIT, 0.5, 6.0)); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, lit.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(f, c); + f +} + +/// A dark pill reading a choice param (the mic model), with step arrows — +/// a `Selector` whose `Value` is the name. +fn pill_select( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (x, y, w, h): (f64, f64, f64, f64), +) -> NodeId { + let hh = h.max(TOUCH_MIN); + let f = b.add(parent, draw::frame(name, x, y - (hh - h) / 2.0, w, hh)); + b.add( + f, + draw::stroked( + draw::rounded( + draw::rect("pill", 0.0, (hh - h) / 2.0, w, h, draw::solid("#000000")), + 6.0, + ), + "#ffffff30", + 1.0, + ), + ); + let ink = Ink::new(10.0, TEXT).weight(500.0).track(0.0).plain(); + let mut t = parts::text_node("", 10.0, hh / 2.0, w - 30.0, &ink, TextAlign::Left); + t.name = "value".into(); + let v = b.add(f, t); + let (ax, ay) = (w - 14.0, hh / 2.0); + b.add( + f, + draw::path( + "chevron", + 0.0, + 0.0, + w, + hh, + draw::polygon(&[(ax - 4.0, ay - 2.0), (ax + 4.0, ay - 2.0), (ax, ay + 3.0)]), + draw::solid(TEXT), + ), + ); + let mut c = Control::new(ControlKind::Selector).slot(SlotRole::Value, v.0); + c.binding = Some(rk::bind(addr)); + c.format = Some(ValueFormat::labels(MICS)); + b.controls.insert(f, c); + let _ = Place::new(name); + f +} diff --git a/examples/plugin-faces/src/suite_blackout_gain/eq.rs b/examples/plugin-faces/src/suite_blackout_gain/eq.rs new file mode 100644 index 00000000..f9b521e6 --- /dev/null +++ b/examples/plugin-faces/src/suite_blackout_gain/eq.rs @@ -0,0 +1,306 @@ +//! The nine-band graphic EQ — Tabletop, 1000 × 282 (the unit's 3.3 : 1 +//! face plus its feet). +//! +//! A black anodised desktop box: a high-pass knob (its setting read over +//! it, "HPF" and the value), the slope glyph and the on/off bat toggle at +//! the left; nine long-throw faders whose caps are lit blue from inside, +//! each with its gain read over it and its band (65 Hz … 16 kHz) under it; +//! the low-pass knob and the invented maker's badge at the right. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{Control, ControlKind, Orientation, SlotRole, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Letter}; + +const W: f64 = 1000.0; +const H: f64 = 282.0; +const BODY_H: f64 = 263.0; +const PRINT: &str = "#e2e2e0"; +const BLUE: &str = "#7fc4ff"; +const BANDS: &[&str] = &[ + "65 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz", +]; +const XS: [f64; 9] = [ + 233.0, 299.0, 366.0, 433.0, 499.0, 566.0, 633.0, 700.0, 767.0, +]; + +fn a(s: &str) -> String { + format!("bgeq/{s}") +} + +pub fn face() -> Face { + let scene = canvas("Nine-Band EQ", |b, root| { + parts::fit_root(b, root, W, H); + for x in [40.0, W - 108.0] { + let foot = draw::rounded( + draw::rect( + "foot", + x, + BODY_H - 6.0, + 68.0, + H - BODY_H + 6.0, + draw::linear(180.0, &[(0.0, "#1a1a1a"), (1.0, "#050505")]), + ), + 4.0, + ); + b.add( + root, + draw::with_effects(foot, vec![draw::drop_shadow("#000000a0", 0.0, 3.0, 4.0)]), + ); + } + let mut body = draw::frame("Body", 0.0, 0.0, W, BODY_H); + body.fills = vec![draw::linear( + 180.0, + &[ + (0.0, "#2a2a2c"), + (0.04, "#161617"), + (0.9, "#111112"), + (1.0, "#060606"), + ], + )]; + let body = draw::with_effects( + draw::stroked(draw::rounded(body, 14.0), "#000000", 1.5), + vec![ + draw::inner_shadow("#ffffff30", 0.0, 1.5, 1.0), + draw::drop_shadow("#000000b0", 0.0, 6.0, 14.0), + ], + ); + let body = b.add(root, body); + // A faint horizontal brushing on the anodising. + let mut d = String::new(); + let mut y = 3.0; + while y < BODY_H { + d.push_str(&format!("M 0 {y:.1} H {W} V {:.1} H 0 Z ", y + 0.6)); + y += 2.7; + } + b.add( + body, + draw::path("brushing", 0.0, 0.0, W, BODY_H, d, draw::solid("#ffffff04")), + ); + + let legend = Ink::new(10.0, PRINT).weight(700.0).track(0.02).plain(); + for (i, x) in XS.iter().enumerate() { + fader(b, root, i, *x, legend); + } + filter_knob(b, root, "hpf", "HPF", 136.0, "/", legend, 20.0); + filter_knob(b, root, "lpf", "LPF", 866.0, "\\", legend, 20_000.0); + let t = rk::mini_toggle(b, root, "Power", &a("on"), (136.0, 204.0), 22.0, "chrome"); + let _ = t; + badge(b, root, (864.0, 204.0)); + }); + let mut params: Vec = (1..=9) + .map(|i| { + rk::db( + &a(&format!("b{i}")), + &format!("{} Gain", BANDS[i - 1]), + -12.0, + 12.0, + 0.0, + ) + .featured() + }) + .collect(); + params.push(rk::hz(&a("hpf"), "High-Pass", 20.0, 400.0, 20.0)); + params.push(rk::hz(&a("lpf"), "Low-Pass", 2000.0, 20000.0, 20000.0)); + params.push(rk::toggle(&a("on"), "On", true).featured()); + Face { + file: "06-eq-tabletop-nine-band", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +/// One band: the slot, the lit cap (the `Thumb`), the gain read over it, +/// the band under it. +fn fader(b: &mut Builder<'_>, root: NodeId, i: usize, x: f64, legend: Ink<'_>) { + let (top, bot) = (51.0, 221.0); + let (cw, ch) = (16.0, 32.0); + let travel = bot - top - ch; + let w = TOUCH_MIN; + let f = b.add(root, draw::frame(BANDS[i], x - w / 2.0, top, w, bot - top)); + let slot = draw::rounded( + draw::rect( + "slot", + w / 2.0 - 5.5, + 0.0, + 11.0, + bot - top, + draw::linear( + 90.0, + &[(0.0, "#000000"), (0.5, "#0a0a0a"), (1.0, "#1c1c1c")], + ), + ), + 5.5, + ); + b.add( + f, + draw::with_effects( + slot, + vec![ + draw::inner_shadow("#000000", 0.0, 2.0, 3.0), + draw::drop_shadow("#ffffff14", 0.0, 1.0, 0.0), + ], + ), + ); + let lane = b.add( + f, + draw::frame("lane", w / 2.0 - cw / 2.0, 0.0, cw, travel + ch), + ); + let cap = b.add(lane, draw::frame("cap", 0.0, 0.0, cw, ch)); + let body = draw::rounded( + draw::rect( + "cap body", + 0.0, + 0.0, + cw, + ch, + draw::linear( + 180.0, + &[(0.0, "#2a4a66"), (0.5, "#1c3a55"), (1.0, "#12283c")], + ), + ), + 2.5, + ); + b.add(cap, draw::stroked(body, "#000000", 0.8)); + let glow = draw::rounded( + draw::rect( + "cap light", + 2.5, + 3.0, + cw - 5.0, + ch - 6.0, + draw::linear( + 90.0, + &[(0.0, "#7ab8f0"), (0.5, "#e6f4ff"), (1.0, "#7ab8f0")], + ), + ), + 2.0, + ); + b.add(cap, draw::emissive(glow, draw::hex(BLUE), 0.8, 12.0)); + let mut c = Control::new(ControlKind::Fader { + orientation: Orientation::Vertical, + }) + .slot(SlotRole::Thumb, cap.0); + c.binding = Some(rk::bind(&a(&format!("b{}", i + 1)))); + b.controls.insert(f, c); + parts::label_at(b, f, BANDS[i], (x, 240.0), legend); + // The gain, read over the slot. + let r = parts::readout( + b, + root, + &format!("{} readout", BANDS[i]), + rk::bind(&a(&format!("b{}", i + 1))), + (x - 34.0, 31.0, 68.0), + 10.0, + PRINT, + TextAlign::Center, + ); + parts::format(b, r, ValueFormat::decimals(1).signed()); + unlit(b, r); +} + +/// A readout's value doesn't glow on this unit (it is print). +fn unlit(b: &mut Builder<'_>, readout: NodeId) { + let v = b + .controls + .get(&readout) + .and_then(|c| c.slots_of(SlotRole::Value).next()) + .map(NodeId); + if let Some(n) = v.and_then(|v| b.scene.node_mut(v)) { + n.emission = None; + } +} + +/// A filter knob: the chrome-capped knob, its setting read over it and the +/// slope glyph under it. +#[allow(clippy::too_many_arguments)] +fn filter_knob( + b: &mut Builder<'_>, + root: NodeId, + addr: &str, + name: &str, + x: f64, + slope: &str, + legend: Ink<'_>, + stop: f64, +) { + let mut p = Place::new(name) + .size(54.0) + .variant("spun_metal") + .bind(rk::bind(&a(addr))) + .touch(TOUCH_MIN); + p.options = Some(super::EQ_KNOB.iter().map(ToString::to_string).collect()); + let k = parts::put(b, root, super::knob_id("knob.knurled_cap"), &p, x, 91.0); + parts::strip_legend(b, k); + parts::strip_scale(b, k); + rk::silk(b, root, &format!("{name} :"), x - 22.0, 31.0, legend); + let r = parts::readout( + b, + root, + &format!("{name} readout"), + rk::bind(&a(addr)), + (x - 4.0, 31.0, 60.0), + 10.0, + PRINT, + TextAlign::Left, + ); + // At its stop the filter is out: the readout says so. + parts::format(b, r, ValueFormat::decimals(0).mark(stop, "OFF")); + unlit(b, r); + parts::label_at(b, k, name, (x, 31.0), legend.color("#00000000")); + let g = if slope == "/" { + vec![(x - 6.0, 152.0), (x - 1.0, 152.0), (x + 4.0, 144.0)] + } else { + vec![(x - 4.0, 144.0), (x + 1.0, 144.0), (x + 6.0, 152.0)] + }; + rk::line(b, root, &g, 1.8, PRINT); +} + +/// The maker's badge: a small outlined box with the invented name in +/// squared extended capitals. +fn badge(b: &mut Builder<'_>, root: NodeId, (cx, cy): (f64, f64)) { + let (w, h) = (92.0, 22.0); + let plate = draw::stroked( + draw::rect( + "badge", + cx - w / 2.0, + cy - h / 2.0, + w, + h, + draw::solid("#0c0c0c"), + ), + "#cfcfcf", + 1.2, + ); + b.add(root, plate); + b.add( + root, + draw::stroked( + draw::rect( + "badge inner", + cx - w / 2.0 + 2.5, + cy - h / 2.0 + 2.5, + w - 5.0, + h - 5.0, + draw::solid("#00000000"), + ), + "#cfcfcf80", + 0.6, + ), + ); + rk::letter_fit( + b, + root, + "VANTOM", + (cx, cy), + w - 18.0, + Letter::new(12.0, 600.0, "#e0e0e0").stretch(1.3).track(0.08), + ); +} diff --git a/examples/plugin-faces/src/suite_blackout_gain/post.rs b/examples/plugin-faces/src/suite_blackout_gain/post.rs new file mode 100644 index 00000000..8a52dff3 --- /dev/null +++ b/examples/plugin-faces/src/suite_blackout_gain/post.rs @@ -0,0 +1,286 @@ +//! Blackout Gain post-FX: the delay and the reverb, in the pre-FX boxes' +//! black and white line-art. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::draw; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::art::{self, BLACK, GREEN, INK, Put}; +use crate::delay::parts::{self, Ink, Screen}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Enclosure, KnobStyle}; + +pub fn faces() -> Vec { + vec![delay(), reverb()] +} + +const KNOB: KnobStyle<'static> = super::pedal_knob(44.0); +const LCD_BG: (&str, &str) = ("#2d3a33", "#23302a"); +const LCD_INK: &str = "#d6e4d8"; + +fn black(class: &'static str) -> Enclosure<'static> { + Enclosure::new(class, BLACK, "satin") + .base("#0b0b0b") + .jacks("top") +} + +/// A hexagram (two interlaced triangles) printed at (`cx`, `cy`). +fn hexagram(b: &mut Builder<'_>, p: NodeId, size: (f64, f64), (cx, cy): (f64, f64), r: f64) { + for rot in [0.0, 180.0] { + let pts: Vec<(f64, f64)> = (0..3) + .map(|i| draw::polar(cx, cy, r, rot + 120.0 * f64::from(i))) + .collect(); + let mut n = draw::path( + "hexagram", + 0.0, + 0.0, + size.0, + size.1, + draw::polygon(&pts), + draw::solid(INK), + ); + n.fills = Vec::new(); + n.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(INK)], + weight: 1.6, + join: frame::document::proto::StrokeJoin::Miter, + ..frame::document::proto::Stroke::default() + }; + b.add(p, n); + } +} + +// ── 07 · Crypt Delay ───────────────────────────────────────────────────── + +/// Crypt Delay — Pedal, Large (286 × 286: the original is a near-square +/// big box, 1.15 : 1, so the square class keeps its proportions). MIX and +/// FEEDBACK big in the top corners, SYNC and PING-PONG bat toggles between +/// them over a grey-green LCD (the sync source and ping-pong state, the +/// note value and the tempo), TIME and TONE either side of a hexagram, two +/// skulls looking out from the sides, the name in the family's spurred +/// capitals, ENGAGE and TAP TEMPO stomps in the bottom corners with their +/// green LEDs inboard, the maker's badge between them. +#[allow(clippy::too_many_lines)] +fn delay() -> Face { + let ns = "bgdelay"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Crypt Delay", |b, root| { + let e = black("large"); + let p = rk::pedal_body(b, root, &e); + let size = e.size(); + let (w, _) = size; + art::border(b, p, size, 6.0, (12.0, 262.0), "9V DC"); + // The skulls look out from the sides, their locks falling past the knobs. + art::skull(b, p, size, &Put::new(4.0, 112.0, 66.0, true)); + art::skull(b, p, size, &Put::new(w - 70.0, 112.0, 66.0, false)); + art::locks(b, p, size, (60.0, 128.0), 70.0, 3, 3.2, 1.0); + art::locks(b, p, size, (w - 60.0, 128.0), 70.0, 3, 3.2, 1.0); + hexagram(b, p, size, (w / 2.0, 137.0), 15.0); + + let none = Ink::new(6.0, INK); + let big = KNOB.size(46.0); + let mid = KNOB.size(38.0); + for (addr, name, at, ly, k) in [ + ("mix", "MIX", (40.0, 38.0), 97.0, big), + ("feedback", "FEEDBACK", (w - 40.0, 38.0), 97.0, big), + ("time", "TIME", (88.0, 146.0), 114.0, mid), + ("tone", "TONE", (w - 88.0, 146.0), 114.0, mid), + ] { + let kn = rk::knob(b, p, &k, &a(addr), "", at, 0.0, none); + art::label_letter(b, kn, name, (at.0, ly), art::knob_letter(10.0)); + } + for (addr, name, x) in [("sync", "SYNC", 88.0), ("pingpong", "PING PONG", w - 88.0)] { + let t = rk::mini_toggle(b, p, name, &a(addr), (x, 38.0), 14.0, "chrome"); + art::label_letter(b, t, name, (x, 22.0), art::knob_letter(6.8)); + for dx in [-6.0, 6.0] { + b.add( + p, + draw::circle("position dot", x + dx, 50.0, 1.2, draw::solid(INK)), + ); + } + } + // The LCD. + let win = parts::screen( + b, + p, + "LCD", + (80.0, 58.0, 126.0, 34.0), + Screen { + glass: LCD_BG, + ink: LCD_INK, + mesh: false, + }, + ); + let small = Ink::new(5.0, "#9fb0a3").weight(600.0).track(0.04); + parts::silk_left(b, win, "FREE / DAW / TAP", 4.0, 7.0, small); + parts::silk_right(b, win, "P-P: OFF / ON", 122.0, 7.0, small); + let seg = rk::lcd::dots(10.0, LCD_INK); + rk::lcd::readout( + b, + win, + "Note", + rk::bind(&a("div")), + (3.0, 22.5, 48.0), + seg, + TextAlign::Left, + None, + frame::surface::proto::ValueFormat::labels(rk::DIVISIONS), + ); + rk::lcd::readout( + b, + win, + "Tempo", + rk::bind(&a("tempo")), + (50.0, 22.5, 72.0), + seg, + TextAlign::Right, + None, + frame::surface::proto::ValueFormat::decimals(1).unit("BPM"), + ); + + art::title_max(b, p, "DELAY", (w / 2.0, 186.0), 92.0, 40.0); + rk::silk( + b, + p, + "HANDMADE IN THE DARK", + w / 2.0, + 209.0, + Ink::new(4.6, INK).weight(700.0).track(0.12), + ); + // The bottom corners: ENGAGE and TAP TEMPO, LEDs inboard of them. + art::switch_ring(b, p, (30.0, 228.0), 19.0); + art::switch_ring(b, p, (w - 30.0, 228.0), 19.0); + let on = art::stomp( + b, + p, + "Engage", + &a("on"), + (30.0, 228.0), + 30.0, + GREEN, + (39.0, 5.0), + 14.0, + false, + ); + art::label_letter(b, on, "ENGAGE", (66.0, 252.0), art::knob_letter(7.0)); + let tap = art::stomp( + b, + p, + "Tap Tempo", + &a("tap"), + (w - 30.0, 228.0), + 30.0, + GREEN, + (-39.0, 5.0), + 14.0, + true, + ); + art::label_letter( + b, + tap, + "TAP TEMPO", + (w - 66.0, 252.0), + art::knob_letter(7.0), + ); + art::badge(b, p, size, (w / 2.0, 247.0), 96.0); + }); + let params = vec![ + rk::ms(&a("time"), "Time", 20.0, 2000.0, 200.0).featured(), + rk::choice(&a("div"), "Note", rk::DIVISIONS, 4), + rk::toggle(&a("sync"), "Sync", true), + rk::toggle(&a("pingpong"), "Ping Pong", false), + rk::percent(&a("mix"), "Mix", 50.0).featured(), + rk::percent(&a("feedback"), "Feedback", 50.0).featured(), + rk::percent(&a("tone"), "Tone", 50.0), + crate::delay::parts::param( + &a("tempo"), + "Tempo", + frame::surface::proto::Range::linear(40.0, 240.0, frame::surface::proto::Unit::Bpm), + 120.0, + ), + rk::toggle(&a("on"), "Engage", true).featured(), + rk::toggle(&a("tap"), "Tap Tempo", false), + ]; + Face { + file: "07-post-large-crypt-delay", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 08 · Tomb Verb ─────────────────────────────────────────────────────── + +/// Tomb Verb — Pedal, Standard (156 × 286). MIX and DECAY high either side +/// of a hexagram, TONE between and below, skulls at the sides, the name in +/// tall condensed capitals, the green LED between two scrolls in the +/// bottom band over the stomp switch. +fn reverb() -> Face { + let ns = "bgverb"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Tomb Verb", |b, root| { + let e = black("standard"); + let p = rk::pedal_body(b, root, &e); + let size = e.size(); + let (w, _) = size; + art::border(b, p, size, 7.0, (15.0, 272.0), "9V DC"); + hexagram(b, p, size, (w / 2.0, 36.0), 12.0); + art::skull(b, p, size, &Put::new(-4.0, 112.0, 58.0, true)); + art::skull(b, p, size, &Put::new(w - 54.0, 112.0, 58.0, false)); + art::locks(b, p, size, (16.0, 150.0), 36.0, 2, 3.4, 1.0); + art::locks(b, p, size, (w - 16.0, 150.0), 36.0, 2, 3.4, 1.0); + art::knot(b, p, size, (22.0, 190.0), 16.0); + art::knot(b, p, size, (w - 22.0, 190.0), 16.0); + let small = KNOB.size(38.0); + let legend = art::knob_letter(10.0); + let none = Ink::new(6.0, INK); + for (addr, name, at, ly) in [ + ("mix", "MIX", (35.0, 42.0), 70.0), + ("decay", "DECAY", (w - 35.0, 42.0), 70.0), + ("tone", "TONE", (w / 2.0, 88.0), 117.0), + ] { + let k = rk::knob(b, p, &small, &a(addr), "", at, 0.0, none); + art::label_letter(b, k, name, (at.0, ly), legend); + } + art::title(b, p, "REVERB", (w / 2.0, 172.0), 96.0); + rk::silk( + b, + p, + "HANDMADE IN THE DARK", + w / 2.0, + 187.0, + Ink::new(4.2, INK).weight(700.0).track(0.1), + ); + art::flourish(b, p, size, (w / 2.0 - 26.0, 204.0), 22.0, true); + art::flourish(b, p, size, (w / 2.0 + 26.0, 204.0), 22.0, false); + art::switch_ring(b, p, (w / 2.0, 235.0), 21.0); + art::stomp( + b, + p, + "Bypass", + &a("on"), + (w / 2.0, 235.0), + 34.0, + GREEN, + (0.0, -31.0), + 17.0, + false, + ); + art::badge(b, p, size, (w / 2.0, 265.0), 100.0); + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 25.0).featured(), + rk::percent(&a("decay"), "Decay", 50.0).featured(), + rk::percent(&a("tone"), "Tone", 50.0), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "08-post-standard-tomb-verb", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_blackout_gain/pre.rs b/examples/plugin-faces/src/suite_blackout_gain/pre.rs new file mode 100644 index 00000000..9415a9d3 --- /dev/null +++ b/examples/plugin-faces/src/suite_blackout_gain/pre.rs @@ -0,0 +1,283 @@ +//! Blackout Gain pre-FX: three Standard (125B, 156 × 286) boxes in black +//! with white line-art, jacks on top. Every box is framed by the same +//! notched border, carries the maker's badge at its foot, and puts its LED +//! and plain stomp switch in the bottom band (the lower 30 %), the switch +//! in a printed ring. + +use frame::document::proto::NodeId; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::art::{self, BLACK, GREEN, INK, Put, RED}; +use crate::delay::parts::Ink; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Enclosure, KnobStyle}; + +pub fn faces() -> Vec { + vec![gate(), drive(), boost()] +} + +const BIG: KnobStyle<'static> = super::pedal_knob(48.0); +const SMALL: KnobStyle<'static> = super::pedal_knob(40.0); + +fn enclosure() -> Enclosure<'static> { + Enclosure::new("standard", BLACK, "satin") + .base("#0b0b0b") + .jacks("top") +} + +/// The border, the badge and the switch with its LED: what every box shares. +fn furniture( + b: &mut Builder<'_>, + p: NodeId, + size: (f64, f64), + addr: &str, + led: &str, + supply: &str, +) { + art::border(b, p, size, 7.0, (15.0, 272.0), supply); + let (w, _) = size; + art::switch_ring(b, p, (w / 2.0, 235.0), 21.0); + art::stomp( + b, + p, + "Bypass", + addr, + (w / 2.0, 235.0), + 34.0, + led, + (0.0, -31.0), + 17.0, + false, + ); + art::badge(b, p, size, (w / 2.0, 265.0), 100.0); +} + +// ── 01 · Ward (noise gate) ─────────────────────────────────────────────── + +/// Ward — Pedal, Standard (156 × 286). A noise gate: one big knob in a +/// petal rosette, knots in the top corners, two skulls looking out with +/// braided locks between them, the name in hollow block capitals, a green +/// LED. +fn gate() -> Face { + let ns = "bggate"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Ward", |b, root| { + let e = enclosure(); + let p = rk::pedal_body(b, root, &e); + let size = e.size(); + let (w, _) = size; + art::knot(b, p, size, (27.0, 36.0), 26.0); + art::knot(b, p, size, (w - 27.0, 36.0), 26.0); + art::flourish(b, p, size, (24.0, 58.0), 26.0, false); + art::flourish(b, p, size, (w - 24.0, 58.0), 26.0, true); + art::rosette(b, p, size, (w / 2.0, 68.0), 54.0, 7); + art::skull(b, p, size, &Put::new(0.0, 86.0, 70.0, true)); + art::skull(b, p, size, &Put::new(w - 70.0, 86.0, 70.0, false)); + art::locks(b, p, size, (w / 2.0 - 15.0, 94.0), 64.0, 4, 3.4, 1.3); + art::locks(b, p, size, (w / 2.0 + 15.0, 94.0), 64.0, 4, 3.4, 1.3); + art::hollow_title(b, p, "WARD", (w / 2.0, 172.0), (118.0, 36.0), 1.5); + rk::knob( + b, + p, + &BIG, + &a("threshold"), + "", + (w / 2.0, 68.0), + 0.0, + Ink::new(6.0, INK), + ); + furniture(b, p, size, &a("on"), GREEN, "9V DC"); + }); + let params = vec![ + rk::db(&a("threshold"), "Threshold", -96.0, 0.0, -60.0).featured(), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "01-pre-standard-ward-gate", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 02 · Omen (drive) ──────────────────────────────────────────────────── + +/// Omen — Pedal, Standard (156 × 286). A three-knob drive: DRIVE and LEVEL +/// high in the corners, TONE between and below them, two skulls face to +/// face, the name in tall condensed capitals, the red LED in a hexagram. +fn drive() -> Face { + let ns = "bgdrive"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Omen", |b, root| { + let e = enclosure(); + let p = rk::pedal_body(b, root, &e); + let size = e.size(); + let (w, _) = size; + art::skull(b, p, size, &Put::new(-2.0, 94.0, 70.0, false)); + art::skull(b, p, size, &Put::new(w - 68.0, 94.0, 70.0, true)); + art::locks(b, p, size, (12.0, 150.0), 44.0, 2, 4.0, 1.0); + art::locks(b, p, size, (w - 12.0, 150.0), 44.0, 2, 4.0, 1.0); + let none = Ink::new(6.0, INK); + for (addr, name, at, ly) in [ + ("drive", "DRIVE", (34.0, 40.0), 72.0), + ("level", "LEVEL", (w - 34.0, 40.0), 72.0), + ("tone", "TONE", (w / 2.0, 72.0), 36.0), + ] { + let k = rk::knob(b, p, &SMALL, &a(addr), "", at, 0.0, none); + art::label_letter(b, k, name, (at.0, ly), art::knob_letter(10.0)); + } + art::title(b, p, "OMEN", (w / 2.0, 176.0), 74.0); + hexagram(b, p, size, (w / 2.0, 204.0), 15.0); + furniture(b, p, size, &a("on"), RED, "9-32V DC"); + }); + let params = vec![ + rk::percent(&a("drive"), "Drive", 50.0).featured(), + rk::percent(&a("tone"), "Tone", 50.0), + rk::db(&a("level"), "Level", -24.0, 12.0, 0.0).featured(), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "02-pre-standard-omen-drive", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +/// Two interlaced triangles, the upper one taller (the drive's sigil). +fn hexagram(b: &mut Builder<'_>, p: NodeId, size: (f64, f64), (cx, cy): (f64, f64), r: f64) { + let up = format!( + "M {cx} {} L {} {} L {} {} Z", + cy - r * 1.35, + cx + r, + cy + r * 0.55, + cx - r, + cy + r * 0.55 + ); + let down = format!( + "M {cx} {} L {} {} L {} {} Z", + cy + r * 1.1, + cx + r, + cy - r * 0.55, + cx - r, + cy - r * 0.55 + ); + for d in [up, down] { + let mut n = frame::surface::kit::draw::path( + "sigil", + 0.0, + 0.0, + size.0, + size.1, + d, + frame::surface::kit::draw::solid(RED), + ); + n.fills = Vec::new(); + n.stroke = frame::document::proto::Stroke { + paints: vec![frame::surface::kit::draw::solid("#ff6a5c")], + weight: 1.0, + ..frame::document::proto::Stroke::default() + }; + b.add(p, n); + } +} + +// ── 03 · Maul (boost) ──────────────────────────────────────────────────── + +/// Maul — Pedal, Standard (156 × 286). A one-knob boost: the knob over a +/// lattice of knots and crossed bars, skulls either side, a star +/// medallion round the green LED, the name in wide capitals. +fn boost() -> Face { + let ns = "bgboost"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Maul", |b, root| { + let e = enclosure(); + let p = rk::pedal_body(b, root, &e); + let size = e.size(); + let (w, _) = size; + // The lattice: two crossed bars and knots at their ends. + for (x0, x1) in [(24.0, w - 38.0), (w - 24.0, 38.0)] { + rk::line(b, p, &[(x0, 28.0), (x1, 132.0)], 4.0, INK); + rk::line(b, p, &[(x0, 28.0), (x1, 132.0)], 1.4, BLACK); + } + art::knot(b, p, size, (28.0, 32.0), 22.0); + art::knot(b, p, size, (w - 28.0, 32.0), 22.0); + art::knot(b, p, size, (w / 2.0, 118.0), 26.0); + art::skull(b, p, size, &Put::new(2.0, 50.0, 50.0, true)); + art::skull(b, p, size, &Put::new(w - 52.0, 50.0, 50.0, false)); + rk::knob( + b, + p, + &BIG, + &a("level"), + "", + (w / 2.0, 72.0), + 0.0, + Ink::new(6.0, INK), + ); + art::flourish(b, p, size, (30.0, 150.0), 34.0, false); + art::flourish(b, p, size, (w - 30.0, 150.0), 34.0, true); + rk::letter_fit( + b, + p, + "MAUL", + (w / 2.0, 176.0), + 112.0, + rk::Letter::new(20.0, 400.0, INK) + .family(frame::surface::kit::draw::family::PIRATA_ONE) + .track(0.22), + ); + medallion(b, p, size, (w / 2.0, 204.0), 13.0); + furniture(b, p, size, &a("on"), GREEN, "9V DC"); + }); + let params = vec![ + rk::db(&a("level"), "Boost", 0.0, 20.0, 10.0).featured(), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "03-pre-standard-maul-boost", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +/// An eight-point star in a ring (the boost's medallion round its LED). +fn medallion(b: &mut Builder<'_>, p: NodeId, size: (f64, f64), (cx, cy): (f64, f64), r: f64) { + let pts: Vec<(f64, f64)> = (0..16) + .map(|i| { + frame::surface::kit::draw::polar( + cx, + cy, + if i % 2 == 0 { r * 1.25 } else { r * 0.9 }, + 22.5 * f64::from(i), + ) + }) + .collect(); + b.add( + p, + frame::surface::kit::draw::path( + "star", + 0.0, + 0.0, + size.0, + size.1, + frame::surface::kit::draw::polygon(&pts), + frame::surface::kit::draw::solid(INK), + ), + ); + b.add( + p, + frame::surface::kit::draw::circle( + "star heart", + cx, + cy, + r * 0.8, + frame::surface::kit::draw::solid(BLACK), + ), + ); +} diff --git a/examples/plugin-faces/src/suite_boutique_clean.rs b/examples/plugin-faces/src/suite_boutique_clean.rs new file mode 100644 index 00000000..9d65adc6 --- /dev/null +++ b/examples/plugin-faces/src/suite_boutique_clean.rs @@ -0,0 +1,36 @@ +//! Suite: boutique clean — a soft pastel boutique rig as one plugin +//! (`suite-boutique-clean`), each unit a page in its own form factor +//! (`docs/form-factors.md`): four standard pre-FX stompboxes, three amp +//! heads, the cab, the desktop graphic EQ, and the echo and reverb rack +//! units of the post-FX stand. +//! +//! The lettering follows the style of the rig it recreates — block caps +//! between chevrons on the pedals, a brush-drawn mark where the rig signs +//! its gear — with every name and mark invented. + +mod amps; +mod cab; +mod eq; +pub mod look; +mod pedals; +mod post; + +use crate::face::Face; + +/// Every unit of the suite, in signal order. +#[must_use] +pub fn faces() -> Vec { + vec![ + pedals::comp(), + pedals::od1(), + pedals::od2(), + pedals::modulation(), + amps::blush(), + amps::ranch(), + amps::kintsugi(), + cab::face(), + eq::face(), + post::echo(), + post::reverb(), + ] +} diff --git a/examples/plugin-faces/src/suite_boutique_clean/amps.rs b/examples/plugin-faces/src/suite_boutique_clean/amps.rs new file mode 100644 index 00000000..ccfde74c --- /dev/null +++ b/examples/plugin-faces/src/suite_boutique_clean/amps.rs @@ -0,0 +1,1418 @@ +//! The three amp heads (Head form, 1200 wide, the height from each +//! faceplate's aspect, handle included), drawn from the plugin's own amp +//! pages, their knobs at the angles those screenshots show: +//! +//! - `05-blush-head-1200x700`: *Blush* — a glossy pink head with rounded +//! ends, a recessed control shelf (input, VOLUME, BASS, CUT, MASTER, +//! LEVEL, power toggle and teal pilot) over a woven grille embossed with +//! a bold maze of raised keys — spirals, combs and meanders, one per +//! tile — and a braided cord handle. +//! - `06-ranch-head-1200x680`: *Ranch* — a leather-covered box with leather +//! corners, a cream faceplate with a swooping lower edge (input, a +//! three-way MODE, GAIN, BASS, MID, TREBLE, PRESENCE, MASTER, OUTPUT, +//! power, pilot, a script signature) over a brass circle-lattice grille +//! with a centre plaque. +//! - `07-kintsugi-head-1200x650`: *Kintsugi* — a cream tolex head whose +//! front is an art panel: a sleeping face between dark hair, broken like +//! porcelain and mended in gold, gold leaves drifting over the hair — +//! over a brushed-gold control strip (input, BRIGHT, GAIN, BASS, MIDDLE, +//! TREBLE, SHIFT, MASTER, PRESENCE, OUTPUT, POWER). + +use frame::document::proto::NodeId; +use frame::surface::kit::materials::{Dir, Grain, Material, fill}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::ParamInfo; +use frame::surface::widgets::Builder; + +use super::look::KnobLook; +use super::look::{self, TEAL}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; +use crate::suite_funk_rhythm::parts::{self, HEAD_W, Type}; + +// ── shared ─────────────────────────────────────────────────────────────── + +/// A carry handle: a strap (`strap` paint, `thick` px) arching between two +/// brass fittings at `x0`/`x1`, its feet on `y`, rising `rise`. +#[allow(clippy::too_many_arguments)] +fn handle( + b: &mut Builder<'_>, + root: NodeId, + x0: f64, + x1: f64, + y: f64, + rise: f64, + thick: f64, + strap: &str, +) { + let pts: Vec<(f64, f64)> = (0..=40) + .map(|i| { + let u = f64::from(i) / 40.0; + let lift = (u * std::f64::consts::PI).sin().powf(0.45); + (x0 + (x1 - x0) * u, y - rise * lift) + }) + .collect(); + b.add( + root, + draw::with_effects( + ui::strokes( + "strap", + std::slice::from_ref(&pts), + thick, + draw::solid(strap), + ), + vec![draw::drop_shadow("#00000059", 0.0, 3.0, 5.0)], + ), + ); + // The braid: short diagonal lays along the strap. + let lays: Vec> = pts + .windows(2) + .map(|p| { + let ((ax, ay), (bx, by)) = (p[0], p[1]); + vec![(ax, ay - thick * 0.3), (bx, by + thick * 0.3)] + }) + .collect(); + b.add( + root, + ui::strokes("braid", &lays, thick * 0.18, draw::solid("#ffffff1c")), + ); + let hi: Vec<(f64, f64)> = pts.iter().map(|(x, y)| (*x, y - thick * 0.22)).collect(); + b.add( + root, + ui::strokes("strap light", &[hi], thick * 0.2, draw::solid("#ffffff22")), + ); + for x in [x0, x1] { + let loop_ = parts::ring(x, y - 6.0, 9.0); + b.add( + root, + ui::strokes("fitting", &[loop_], 4.0, draw::solid("#c49a52")), + ); + b.add( + root, + draw::rounded( + draw::rect( + "foot", + x - 16.0, + y - 2.0, + 32.0, + 6.0, + draw::linear(180.0, &[(0.0, "#e6c886"), (1.0, "#8a6630")]), + ), + 3.0, + ), + ); + } +} + +/// A round push cap (bright, shift, power), its lamp over it in teal. +fn push( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + d: f64, + at: (f64, f64), + lamp: bool, +) -> NodeId { + let mut p = Place::new(addr) + .size(d) + .variant("grey") + .bind(ui::bind(addr)) + .legend("") + .touch(TOUCH_MIN); + let color = format!("color={TEAL}"); + p = p.options(&[if lamp { color.as_str() } else { "nolamp" }]); + parts::part(b, parent, "button.round_cap", p, &["cap"], at) +} + +fn amp_face( + file: &'static str, + scene: frame::document::proto::Scene, + params: Vec, +) -> Face { + look::face(file, scene, params) +} + +/// A covered box `(x, y, w, h)` in `m`, its edges lit: returns the box. +fn covered( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + r: f64, + m: &Material, +) -> NodeId { + let mut n = draw::rounded(draw::frame("Covering", x, y, w, h), r); + n.effects = vec![draw::drop_shadow("#00000066", 0.0, 10.0, 22.0)]; + let n = b.add(parent, n); + fill(b, n, (0.0, 0.0, w, h), r, m); + for e in frame::surface::kit::materials::light::edge( + "covering edge", + (0.0, 0.0, w, h), + r, + 10.0, + 1.0, + true, + ) { + b.add(n, e); + } + n +} + +// ── Blush ──────────────────────────────────────────────────────────────── + +/// One maze tile's raised key inside (`x, y, w, h`), motif `v`: a squared +/// spiral, a comb, a mirrored spiral or a meander — drawn as thick +/// polylines on a `step` grid. +fn maze(x: f64, y: f64, w: f64, h: f64, v: usize) -> Vec> { + let step = 21.0; + let (l, r, t, bt) = (x + 16.0, x + w - 16.0, y + 15.0, y + h - 15.0); + let flip = |pts: Vec<(f64, f64)>, fx: bool, fy: bool| -> Vec<(f64, f64)> { + pts.into_iter() + .map(|(px, py)| { + ( + if fx { l + r - px } else { px }, + if fy { t + bt - py } else { py }, + ) + }) + .collect() + }; + // A squared spiral winding in from the bottom-left: up, right, down, + // left, each lap a step further in. + let spiral = || { + let mut pts = vec![(l, bt)]; + let (mut a, mut b, mut c, mut d) = (l, t, r, bt); + let mut k = 0; + while c - a > step * 0.9 && d - b > step * 0.9 && k < 14 { + match k % 4 { + 0 => pts.push((a, b)), + 1 => pts.push((c, b)), + 2 => { + pts.push((c, d)); + a += step; + b += step; + } + _ => { + pts.push((a, d)); + c -= step; + d -= step; + } + } + k += 1; + } + pts + }; + match v % 4 { + 0 => vec![spiral()], + 1 => { + // A comb: a spine down the left, teeth to the right, a hook at the end. + let n = ((bt - t) / step).floor() as usize; + let mut lines = vec![vec![(l, t), (l, bt)]]; + for i in 0..=n { + let yy = t + step * i as f64; + let reach = if i % 2 == 0 { r } else { r - step * 1.5 }; + lines.push(vec![(l, yy), (reach, yy)]); + } + lines.push(vec![(r, t), (r, t + step)]); + lines + .into_iter() + .map(|p| flip(p, v % 8 == 5, false)) + .collect() + } + 2 => vec![flip(spiral(), true, true)], + _ => { + // A meander: rows joined at alternate ends. + let n = ((bt - t) / step).floor() as usize; + let mut pts = Vec::new(); + for i in 0..=n { + let yy = t + step * i as f64; + let (a, c) = if i % 2 == 0 { (l, r) } else { (r, l + step) }; + pts.push((a, yy)); + pts.push((c, yy)); + } + vec![flip(pts, v % 8 == 7, false)] + } + } +} + +pub(super) fn blush() -> Face { + const PINK: &str = "#efc9cf"; + const INK: &str = "#2a1d20"; + let (w, h) = (HEAD_W, 700.0); + let top = 96.0; // the head's top edge (the handle stands above) + let scene = canvas("Blush Head", |b, root| { + parts::page(b, root, w, h); + // Feet. + for x in [300.0, 900.0] { + b.add( + root, + draw::path( + "foot", + 0.0, + 0.0, + w, + h, + draw::polygon(&[ + (x - 60.0, top + 560.0), + (x + 60.0, top + 560.0), + (x + 30.0, h), + (x - 30.0, h), + ]), + draw::linear(180.0, &[(0.0, "#2d2c3a"), (1.0, "#12121a")]), + ), + ); + } + // the shell: smooth painted, fully rounded ends. + let shell = covered( + b, + root, + (0.0, top, w, 580.0), + 130.0, + &Material::Powder { + color: PINK.into(), + coarse: false, + }, + ); + // The recessed front: the control shelf over the grille. + let front = look::slab( + "Front", + (34.0, 36.0, w - 68.0, 510.0), + 96.0, + vec![draw::solid("#e8bac1")], + ); + let front = b.add( + shell, + draw::with_effects(front, vec![draw::inner_shadow("#8a4a5577", 0.0, 6.0, 12.0)]), + ); + let shelf = look::slab( + "Shelf", + (8.0, 8.0, w - 84.0, 196.0), + 88.0, + vec![draw::linear(180.0, &[(0.0, "#f2c9cf"), (1.0, "#e6b3ba")])], + ); + let shelf = b.add( + front, + draw::with_effects( + shelf, + vec![ + draw::drop_shadow("#00000033", 0.0, 3.0, 5.0), + draw::inner_shadow("#ffffff80", 0.0, 1.5, 1.0), + ], + ), + ); + // Grille panel: woven cloth with a bold maze of raised keys. + let (gw, gh) = (w - 180.0, 250.0); + let grille = look::slab( + "Grille", + (56.0, 226.0, gw, gh), + 22.0, + vec![draw::solid("#dcb0b7")], + ); + let grille = b.add( + front, + draw::with_effects(grille, vec![draw::inner_shadow("#7a3a4566", 0.0, 3.0, 6.0)]), + ); + let mut weave = String::new(); + for i in 0..(gh as usize / 3) { + weave.push_str(&format!("M 0 {y:.1} L {gw:.1} {y:.1} ", y = 3.0 * i as f64)); + } + let mut wn = draw::path("weave", 0.0, 0.0, gw, gh, weave, draw::solid("#00000000")); + wn.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid("#ffffff22")], + weight: 1.0, + ..frame::document::proto::Stroke::default() + }; + b.add(grille, wn); + let cols = 7; + let (tw, th) = (gw / cols as f64, gh / 2.0); + let mut keys = Vec::new(); + for row in 0..2 { + for col in 0..cols { + if row == 1 && col == cols - 1 { + continue; + } + let (x, y) = (tw * col as f64, th * f64::from(row)); + keys.extend(maze(x, y, tw, th, col * 3 + row as usize * 5 + 1)); + keys.push(parts::round_rect(x + 4.0, y + 4.0, tw - 8.0, th - 8.0, 8.0)); + } + } + // Raised keys: their shadow down-right, the light up-left, the top. + let off = |dx: f64, dy: f64| { + keys.iter() + .map(|l| l.iter().map(|(x, y)| (x + dx, y + dy)).collect()) + .collect::>>() + }; + b.add( + grille, + ui::strokes("key shade", &off(2.6, 3.2), 11.0, draw::solid("#8a4a58a0")), + ); + b.add( + grille, + ui::strokes("key light", &off(-1.3, -1.3), 10.0, draw::solid("#fff0f2")), + ); + b.add( + grille, + ui::strokes("keys", &keys, 9.4, draw::solid("#ebbec5")), + ); + look::brush_mark(b, front, (w - 150.0, 440.0), 34.0, INK); + // The control shelf. Coordinates in the shelf's space. + let plate = look::slab( + "Emblem", + (58.0, 38.0, 104.0, 104.0), + 12.0, + vec![draw::linear(160.0, &[(0.0, "#f8dde1"), (1.0, "#e0b0b8")])], + ); + let plate = b.add( + shelf, + draw::with_effects( + plate, + vec![ + draw::drop_shadow("#00000040", 0.0, 3.0, 6.0), + draw::inner_shadow("#ffffffa0", 0.0, 1.5, 1.0), + ], + ), + ); + for (x, y) in [(12.0, 12.0), (92.0, 12.0), (12.0, 92.0), (92.0, 92.0)] { + b.add( + plate, + draw::circle( + "rivet", + x, + y, + 3.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#fff1c4"), (1.0, "#8a6630")]), + ), + ); + } + look::emblem(b, plate, (52.0, 48.0), 30.0, INK); + let t = Type::new(10.5, INK, 600.0).track(0.28).upper(); + parts::centred(b, shelf, "Input", (245.0, 58.0), t); + let knob = KnobLook { + preset: "scalloped", + opts: &[ + "color=#0e0b0c", + "pointer=#d9a64e", + "ribs=9", + "rib_depth=0.12", + "height=0.6", + "coat=0.85", + "rough=0.14", + "index_in=0.08", + "index_out=0.74", + "index_w=0.07", + "ticks=none", + "fallback=fluted", + ], + }; + let knobs = [ + ("blush/volume", "Volume"), + ("blush/bass", "Bass"), + ("blush/cut", "Cut"), + ("blush/master", "Master"), + ("blush/level", "Level"), + ]; + for (i, (addr, legend)) in knobs.iter().enumerate() { + let x = 358.0 + 133.0 * i as f64; + look::knob( + b, + shelf, + &knob, + addr, + 70.0, + (x, 104.0), + 41, + "#5a4046", + false, + ); + parts::centred(b, shelf, legend, (x, 166.0), t); + } + parts::centred(b, shelf, "Power", (1003.0, 50.0), t); + look::brass_toggle(b, shelf, "blush/power", "Power", 22.0, (1003.0, 100.0)); + parts::status_led( + b, + shelf, + "blush/power", + 13.0, + (1003.0, 148.0), + TEAL, + "light", + None, + ); + // Input jack, and the cable over the grille and off the unit. + let (jx, jy) = (34.0 + 8.0 + 245.0, top + 36.0 + 8.0 + 104.0); + parts::cable( + b, + root, + (jx, jy), + 14.0, + &[ + (jx + 4.0, jy + 200.0), + (jx - 6.0, jy + 400.0), + (jx - 60.0, h - 30.0), + (jx - 200.0, h - 8.0), + ], + ); + handle(b, root, 340.0, 860.0, top + 6.0, 84.0, 12.0, "#2b2340"); + }); + // The knobs as the reference screenshot turns them. + let params = vec![ + parts::dial10("blush/volume", "Volume", 4.0), + parts::dial10("blush/bass", "Bass", 2.3), + parts::dial10("blush/cut", "Cut", 0.8), + parts::dial10("blush/master", "Master", 5.7), + parts::dial10("blush/level", "Level", 6.7), + parts::switch("blush/power", "Power", true), + ]; + amp_face("05-blush-head-1200x700", scene, params) +} + +// ── Ranch ──────────────────────────────────────────────────────────────── + +pub(super) fn ranch() -> Face { + const INK: &str = "#1d1a17"; + let (w, h) = (HEAD_W, 680.0); + let top = 56.0; + let bh = 606.0; + let scene = canvas("Ranch Head", |b, root| { + parts::page(b, root, w, h); + for x in [210.0, 990.0] { + b.add( + root, + draw::rounded( + draw::rect( + "foot", + x - 40.0, + top + bh - 10.0, + 80.0, + h - top - bh + 10.0, + draw::linear(180.0, &[(0.0, "#2a2a2c"), (1.0, "#0e0e10")]), + ), + 14.0, + ), + ); + } + let body = covered( + b, + root, + (0.0, top, w, bh), + 30.0, + &Material::Leather { + color: "#8e4524".into(), + }, + ); + // The grille: brass circle lattice over black cloth. + let (gx, gy, gw, gh) = (54.0, 170.0, w - 108.0, bh - 170.0 - 54.0); + let mut g = look::slab( + "Grille", + (gx, gy, gw, gh), + 4.0, + vec![draw::radial_at( + 0.5, + 0.5, + 0.8, + &[(0.0, "#1a1714"), (1.0, "#090807")], + )], + ); + g.clips_content = true; + let grille = b.add(body, g); + let pitch = 32.0; + let mut circles = Vec::new(); + for row in 0..((gh / pitch) as i32 + 2) { + for col in 0..((gw / pitch) as i32 + 2) { + circles.push(parts::ring( + f64::from(col) * pitch, + f64::from(row) * pitch, + pitch * 0.5, + )); + circles.push(parts::ring( + f64::from(col) * pitch + pitch / 2.0, + f64::from(row) * pitch + pitch / 2.0, + pitch * 0.36, + )); + } + } + b.add( + grille, + ui::strokes( + "lattice shade", + &circles + .iter() + .map(|l| l.iter().map(|(x, y)| (x + 1.5, y + 2.0)).collect()) + .collect::>(), + 5.0, + draw::solid("#00000099"), + ), + ); + b.add( + grille, + ui::strokes("lattice", &circles, 3.4, draw::solid("#bf954c")), + ); + for n in frame::surface::kit::materials::light::edge( + "grille recess", + (0.0, 0.0, gw, gh), + 4.0, + 6.0, + 1.0, + false, + ) { + b.add(grille, n); + } + // The faceplate, its lower edge swooping up in the middle. + let (fx, fy, fw) = (54.0, 54.0, w - 108.0); + let mut edge = vec![(0.0, 0.0), (fw, 0.0), (fw, 248.0)]; + edge.extend((0..=40).map(|i| { + let u = f64::from(i) / 40.0; + ( + fw * (1.0 - u), + 248.0 - 56.0 * (u * std::f64::consts::PI).sin().powf(0.8), + ) + })); + let mut face = draw::path( + "Faceplate", + fx, + fy, + fw, + 250.0, + draw::polygon(&edge), + draw::linear(180.0, &[(0.0, "#f3ecdc"), (1.0, "#e2d6bd")]), + ); + face.effects = vec![ + draw::drop_shadow("#00000080", 0.0, 4.0, 8.0), + draw::inner_shadow("#ffffff99", 0.0, 1.5, 1.0), + ]; + b.add(body, face); + let plate = b.add(body, draw::frame("Controls", fx, fy, fw, 250.0)); + for (x, y) in [(20.0, 20.0), (fw - 20.0, 20.0)] { + parts::screw(b, plate, x, y, 5.0, "#9a7236"); + } + let t = Type::new(9.5, INK, 700.0).track(0.06).upper(); + let small = Type::new(8.0, INK, 700.0).upper(); + parts::centred(b, plate, "Input", (78.0, 64.0), t); + let (jx, jy) = (fx + 78.0, top + fy + 98.0); + parts::centred(b, plate, "Mode", (138.0, 38.0), t); + parts::centred(b, plate, "Thicker", (138.0, 64.0), small); + parts::centred(b, plate, "Thick", (182.0, 98.0), small); + parts::centred(b, plate, "Normal", (138.0, 132.0), small); + look::brass_toggle3(b, plate, "ranch/mode", "Mode", 20.0, (138.0, 98.0)); + let knob = KnobLook { + preset: "knurled", + opts: &[ + "color=#171616", + "pointer=#e0b48a", + "index=line", + "cap_r=0.96", + "height=0.62", + "dome=0.06", + "edge=0.12", + "ribs=70", + "knurl_band=0.55", + "rib_depth=0.025", + "tilt=18", + "index_in=0.02", + "index_out=0.95", + "index_w=0.06", + "coat=0.6", + "rough=0.22", + "ticks=none", + "fallback=domed", + ], + }; + let knobs = [ + ("ranch/gain", "Gain"), + ("ranch/bass", "Bass"), + ("ranch/mid", "Mid"), + ("ranch/treble", "Treble"), + ("ranch/presence", "Presence"), + ("ranch/master", "Master"), + ("ranch/output", "Output"), + ]; + for (i, (addr, legend)) in knobs.iter().enumerate() { + let x = 253.0 + 100.0 * i as f64; + look::knob(b, plate, &knob, addr, 54.0, (x, 98.0), 41, "#4a4238", false); + parts::centred(b, plate, legend, (x, 150.0), t); + if i + 1 < knobs.len() { + ui::rule(b, plate, x + 30.0, 150.0, 40.0, 1.0, "#4a423880"); + } + } + ui::rule(b, plate, 897.0, 98.0, 40.0, 1.0, "#4a423880"); + parts::centred(b, plate, "Power", (955.0, 60.0), t); + look::brass_toggle(b, plate, "ranch/power", "Power", 22.0, (955.0, 98.0)); + parts::status_led( + b, + plate, + "ranch/power", + 14.0, + (955.0, 148.0), + TEAL, + "dark", + None, + ); + look::emblem(b, plate, (44.0, 190.0), 22.0, INK); + look::signature(b, plate, (fw - 72.0, 188.0), 22.0, "#4a4238"); + // The plaque on the grille. + let (px, py, pw, ph) = (w / 2.0 - 100.0, gy + 150.0, 200.0, 100.0); + let plq = look::slab( + "Plaque", + (px, py, pw, ph), + 12.0, + vec![draw::linear(170.0, &[(0.0, "#f4efe4"), (1.0, "#d9d1c0")])], + ); + let plq = b.add( + body, + draw::with_effects( + draw::stroked(plq, "#a88a5a", 3.0), + vec![draw::drop_shadow("#000000a0", 0.0, 4.0, 8.0)], + ), + ); + for (x, y) in [ + (12.0, 12.0), + (pw - 12.0, 12.0), + (12.0, ph - 12.0), + (pw - 12.0, ph - 12.0), + ] { + b.add( + plq, + draw::circle( + "rivet", + x, + y, + 3.5, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#fff1c4"), (1.0, "#8a6630")]), + ), + ); + } + look::brush_mark(b, plq, (pw / 2.0, ph / 2.0), 26.0, "#2b2622"); + for (x0, dir) in [(18.0, 1.0), (pw - 18.0, -1.0)] { + let wave: Vec<(f64, f64)> = (0..=20) + .map(|i| { + let u = f64::from(i) / 20.0; + ( + x0 + dir * 40.0 * u, + ph / 2.0 + 10.0 - 12.0 * (u * 3.2).sin(), + ) + }) + .collect(); + parts::art(b, plq, &[wave], 2.4, "#4d5a52"); + } + // Leather corners. + for (cx, cy) in [ + (0.0, top), + (w - 64.0, top), + (0.0, top + bh - 64.0), + (w - 64.0, top + bh - 64.0), + ] { + let mut c = draw::rounded(draw::frame("Corner", cx, cy, 64.0, 64.0), 22.0); + c.effects = vec![draw::drop_shadow("#00000080", 0.0, 2.0, 4.0)]; + let c = b.add(root, c); + fill( + b, + c, + (0.0, 0.0, 64.0, 64.0), + 22.0, + &Material::Leather { + color: "#6a2f16".into(), + }, + ); + for (rx, ry) in [(16.0, 16.0), (48.0, 16.0), (16.0, 48.0), (48.0, 48.0)] { + b.add( + c, + draw::circle( + "rivet", + rx, + ry, + 3.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#fff1c4"), (1.0, "#8a6630")]), + ), + ); + } + } + parts::cable( + b, + root, + (jx, jy), + 13.0, + &[ + (jx + 2.0, jy + 180.0), + (jx - 8.0, jy + 360.0), + (jx - 50.0, h - 20.0), + (jx - 150.0, h - 4.0), + ], + ); + handle(b, root, 430.0, 770.0, top + 4.0, 44.0, 16.0, "#7a3a1c"); + }); + let params = vec![ + parts::pick("ranch/mode", "Mode", &["Thick", "Normal", "Thicker"], 0), + parts::dial10("ranch/gain", "Gain", 4.5), + parts::dial10("ranch/bass", "Bass", 2.7), + parts::dial10("ranch/mid", "Mid", 4.3), + parts::dial10("ranch/treble", "Treble", 6.2), + parts::dial10("ranch/presence", "Presence", 5.3), + parts::dial10("ranch/master", "Master", 5.3), + parts::dial10("ranch/output", "Output", 5.5), + parts::switch("ranch/power", "Power", true), + ]; + amp_face("06-ranch-head-1200x680", scene, params) +} + +// ── Kintsugi ───────────────────────────────────────────────────────────── + +/// A five-pointed maple leaf at (`cx`, `cy`), `r` across, turned `rot`. +fn leaf(cx: f64, cy: f64, r: f64, rot: f64) -> String { + // Five broad lobes (the top three largest), each serrated, over a + // notched base where the stem comes in. + let pts: Vec<(f64, f64)> = (0..120) + .map(|i| { + let t = f64::from(i) / 120.0; + let a = t * 360.0 - 180.0; // −180..180, 0 = up + let lobe = (a.to_radians() * 2.5).cos().abs().powf(0.7); + let size = if a.abs() > 150.0 { + 0.25 + } else if a.abs() > 100.0 { + 0.72 + } else { + 1.0 + }; + let serr = if i % 4 == 0 { 0.1 } else { 0.0 }; + draw::polar(cx, cy, r * size * (0.42 + 0.58 * lobe + serr), rot + a) + }) + .collect(); + let mut d = draw::polygon(&pts); + let stem = draw::polar(cx, cy, r * 0.7, rot + 180.0); + d.push_str(&format!( + " M {cx:.1} {cy:.1} L {:.1} {:.1} L {:.1} {:.1} Z", + stem.0 + 1.0, + stem.1, + stem.0 - 1.0, + stem.1 + )); + d +} + +/// A small deterministic noise source. +struct Noise(u32); + +impl Noise { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + f64::from(self.0) / f64::from(u32::MAX) + } +} + +/// A crack through `pts`, broken into short jagged runs. +fn jag(pts: &[(f64, f64)], n: &mut Noise, amp: f64) -> Vec<(f64, f64)> { + let mut out = vec![pts[0]]; + for p in pts.windows(2) { + let ((ax, ay), (bx, by)) = (p[0], p[1]); + let len = (bx - ax).hypot(by - ay); + let steps = (len / 14.0).ceil().max(1.0) as usize; + let (nx, ny) = (-(by - ay) / len.max(1e-6), (bx - ax) / len.max(1e-6)); + for s in 1..=steps { + let u = s as f64 / steps as f64; + let off = if s == steps { + 0.0 + } else { + (n.next() - 0.5) * 2.0 * amp + }; + out.push((ax + (bx - ax) * u + nx * off, ay + (by - ay) * u + ny * off)); + } + } + out +} + +/// A tapered stroke (a brow) along `spine`, `w0` wide at its start and +/// `w1` at its end, as a filled outline. +fn tapered(spine: &[(f64, f64)], w0: f64, w1: f64) -> String { + let n = spine.len(); + let norm = |i: usize| { + let (a, c) = (spine[i.saturating_sub(1)], spine[(i + 1).min(n - 1)]); + let (dx, dy) = (c.0 - a.0, c.1 - a.1); + let l = dx.hypot(dy).max(1e-6); + (-dy / l, dx / l) + }; + let half = |i: usize| (w0 + (w1 - w0) * i as f64 / (n - 1) as f64) / 2.0; + let mut pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + ( + spine[i].0 + norm(i).0 * half(i), + spine[i].1 + norm(i).1 * half(i), + ) + }) + .collect(); + pts.extend((0..n).rev().map(|i| { + ( + spine[i].0 - norm(i).0 * half(i), + spine[i].1 - norm(i).1 * half(i), + ) + })); + draw::polygon(&pts) +} + +/// An ellipse filling `(x, y, w, h)`. +fn oval( + name: &str, + x: f64, + y: f64, + w: f64, + h: f64, + paint: frame::document::proto::Paint, +) -> frame::document::proto::Node { + let mut n = draw::circle(name, x + w / 2.0, y + w / 2.0, w / 2.0, paint); + n.size.height = h; + n.transform.ty = y; + n +} + +/// A quadratic curve from `a` through control `c` to `z`, `n` points. +fn curve(a: (f64, f64), c: (f64, f64), z: (f64, f64), n: usize) -> Vec<(f64, f64)> { + (0..=n) + .map(|i| { + let t = i as f64 / n as f64; + let u = 1.0 - t; + ( + u * u * a.0 + 2.0 * u * t * c.0 + t * t * z.0, + u * u * a.1 + 2.0 * u * t * c.1 + t * t * z.1, + ) + }) + .collect() +} + +/// One closed eye: the lid's line from `inner` to `outer` sagging to +/// `sag`, the lashes fanning down from it (longer toward the outer +/// corner), a blue wash of shadow over the lid, a warm glint at the inner +/// corner. +fn eye( + b: &mut Builder<'_>, + art: NodeId, + (w, h): (f64, f64), + inner: (f64, f64), + outer: (f64, f64), + sag: (f64, f64), +) { + let (mx, my) = ((inner.0 + outer.0) / 2.0, (inner.1 + outer.1) / 2.0); + let span = (outer.0 - inner.0).abs(); + b.add( + art, + oval( + "eyeshadow", + mx - span * 0.62, + my - span * 0.42, + span * 1.24, + span * 0.62, + draw::radial(&[(0.0, "#6d78b0a0"), (0.55, "#7d86b855"), (1.0, "#8a90c000")]), + ), + ); + // The lid's crease above it, faint. + let crease = curve( + (inner.0, inner.1 - span * 0.1), + (sag.0, sag.1 - span * 0.32), + (outer.0, outer.1 - span * 0.12), + 24, + ); + b.add( + art, + ui::strokes("crease", &[crease], 1.6, draw::solid("#7a6f8060")), + ); + let lid = curve(inner, sag, outer, 30); + b.add( + art, + draw::path( + "lid", + 0.0, + 0.0, + w, + h, + tapered(&lid, 3.0, 6.5), + draw::solid("#1f1c2a"), + ), + ); + let lashes: Vec> = lid + .iter() + .enumerate() + .skip(3) + .map(|(i, (x, y))| { + let u = i as f64 / 30.0; + let len = 8.0 + 16.0 * u.powf(1.6); + let lean = (outer.0 - inner.0).signum() * (4.0 + 10.0 * u); + vec![ + (*x, *y), + (x + lean * 0.5, y + len * 0.6), + (x + lean, y + len), + ] + }) + .collect(); + b.add( + art, + ui::strokes("lashes", &lashes, 1.5, draw::solid("#1f1c2a")), + ); + b.add( + art, + oval( + "glint", + inner.0 - 7.0, + inner.1 - 1.0, + 14.0, + 6.0, + draw::radial(&[(0.0, "#e0703acc"), (1.0, "#e0703a00")]), + ), + ); +} + +#[allow(clippy::too_many_lines)] +fn kintsugi_art(b: &mut Builder<'_>, art: NodeId, w: f64, h: f64) { + let at = |u: f64, v: f64| (w * u, h * v); + // Porcelain skin, lit from the upper left, cooler toward the hair. + b.add( + art, + draw::rect( + "skin", + 0.0, + 0.0, + w, + h, + draw::radial_at( + 0.47, + 0.42, + 0.62, + &[ + (0.0, "#f4efec"), + (0.45, "#e6e0de"), + (0.75, "#b9b4b8"), + (1.0, "#6a676d"), + ], + ), + ), + ); + // A soft shade down the nose and under the brows. + b.add( + art, + oval( + "nose shade", + w * 0.4, + h * 0.35, + w * 0.2, + h * 0.9, + draw::radial(&[(0.0, "#ffffff26"), (0.5, "#ffffff10"), (1.0, "#ffffff00")]), + ), + ); + // Dark hair falling either side of the face, in strands. + let mut n = Noise(0x9e37_79b9); + for (x0, x1, flip) in [(0.0, 0.2, false), (0.81, 1.0, true)] { + let edge: Vec<(f64, f64)> = (0..=24) + .map(|i| { + let v = f64::from(i) / 24.0; + let bulge = (v * std::f64::consts::PI).sin() * 0.05; + let x = if flip { + x0 - bulge + 0.02 * v + } else { + x1 + bulge - 0.03 * v + }; + (w * x, h * v) + }) + .collect(); + let mut poly = if flip { + vec![(w, 0.0)] + } else { + vec![(0.0, 0.0)] + }; + poly.extend(edge.iter().copied()); + poly.push(if flip { (w, h) } else { (0.0, h) }); + let hair = draw::path( + "hair", + 0.0, + 0.0, + w, + h, + draw::polygon(&poly), + draw::linear( + if flip { 270.0 } else { 90.0 }, + &[(0.0, "#0c0b0d"), (0.7, "#1e1b1f"), (1.0, "#39343a")], + ), + ); + b.add( + art, + draw::with_effects( + hair, + vec![draw::drop_shadow( + "#00000080", + if flip { -8.0 } else { 8.0 }, + 0.0, + 18.0, + )], + ), + ); + let strands: Vec> = (0..40) + .map(|i| { + let u = x0 + (x1 - x0) * (f64::from(i) + n.next()) / 40.0; + let drift = (n.next() - 0.5) * 0.08; + curve( + (w * u, -10.0), + (w * (u + drift), h * 0.5), + ( + w * (u + drift * 2.0 + if flip { 0.03 } else { -0.03 }), + h + 10.0, + ), + 16, + ) + }) + .collect(); + b.add( + art, + ui::strokes("strands", &strands, 1.1, draw::solid("#ffffff16")), + ); + } + // The brows: tapered strokes, thick at the inner end. + b.add( + art, + draw::path( + "brow", + 0.0, + 0.0, + w, + h, + tapered( + &curve(at(0.4, 0.34), at(0.31, 0.2), at(0.22, 0.34), 30), + 19.0, + 4.0, + ), + draw::linear(90.0, &[(0.0, "#232233"), (1.0, "#3c3b52")]), + ), + ); + b.add( + art, + draw::path( + "brow", + 0.0, + 0.0, + w, + h, + tapered( + &curve(at(0.56, 0.17), at(0.67, 0.02), at(0.78, 0.13), 30), + 19.0, + 4.0, + ), + draw::linear(90.0, &[(0.0, "#232233"), (1.0, "#3c3b52")]), + ), + ); + // The closed eyes, the face turned a little: the right eye higher. + eye( + b, + art, + (w, h), + at(0.42, 0.6), + at(0.25, 0.57), + at(0.33, 0.71), + ); + eye( + b, + art, + (w, h), + at(0.575, 0.47), + at(0.745, 0.41), + at(0.665, 0.57), + ); + // The gold seams: long breaks across the face, branching. + let mut n = Noise(0x2545_f491); + let mains: Vec> = vec![ + vec![ + at(0.3, 0.0), + at(0.34, 0.2), + at(0.3, 0.4), + at(0.36, 0.55), + at(0.33, 0.8), + at(0.4, 1.0), + ], + vec![at(0.34, 0.2), at(0.46, 0.18), at(0.52, 0.0)], + vec![ + at(0.46, 0.18), + at(0.5, 0.42), + at(0.6, 0.5), + at(0.64, 0.8), + at(0.6, 1.0), + ], + vec![at(0.5, 0.42), at(0.41, 0.5), at(0.36, 0.55)], + vec![at(0.6, 0.5), at(0.72, 0.3), at(0.86, 0.36), at(1.0, 0.33)], + vec![at(0.0, 0.3), at(0.1, 0.27), at(0.19, 0.12), at(0.24, 0.0)], + vec![at(0.1, 0.27), at(0.18, 0.46), at(0.3, 0.4)], + vec![at(0.64, 0.8), at(0.78, 0.9), at(0.86, 1.0)], + vec![at(0.57, 0.0), at(0.6, 0.13), at(0.7, 0.1), at(0.75, 0.0)], + vec![at(0.72, 0.3), at(0.69, 0.18), at(0.6, 0.13)], + vec![at(0.18, 0.46), at(0.12, 0.7), at(0.2, 1.0)], + vec![at(0.86, 0.36), at(0.83, 0.6), at(0.9, 0.78), at(1.0, 0.8)], + vec![at(0.33, 0.8), at(0.22, 0.86), at(0.14, 1.0)], + vec![at(0.5, 0.42), at(0.54, 0.62), at(0.49, 0.83), at(0.52, 1.0)], + ]; + let cracks: Vec> = mains.iter().map(|l| jag(l, &mut n, 4.0)).collect(); + // Short hairline breaks off the main seams. + let mut twigs = Vec::new(); + for c in &cracks { + for p in c.iter().step_by(5).skip(1) { + if n.next() < 0.45 { + let a = n.next() * 360.0; + let len = 10.0 + n.next() * 24.0; + twigs.push(jag(&[*p, draw::polar(p.0, p.1, len, a)], &mut n, 2.0)); + } + } + } + let shade = |ls: &[Vec<(f64, f64)>]| { + ls.iter() + .map(|l| l.iter().map(|(x, y)| (x + 1.2, y + 1.6)).collect()) + .collect::>>() + }; + b.add( + art, + ui::strokes("seam shade", &shade(&cracks), 4.2, draw::solid("#00000050")), + ); + b.add( + art, + ui::strokes("twig shade", &shade(&twigs), 2.0, draw::solid("#00000040")), + ); + let gold = draw::solid("#dcb257"); + let seams = ui::strokes("seams", &cracks, 3.2, gold.clone()); + b.add(art, seams); + b.add(art, ui::strokes("twigs", &twigs, 1.6, gold)); + b.add( + art, + ui::strokes( + "seam light", + &cracks + .iter() + .map(|l| l.iter().map(|(x, y)| (x - 0.6, y - 0.6)).collect()) + .collect::>(), + 0.9, + draw::solid("#fff6d0c0"), + ), + ); + // Gold leaves drifting over the hair, flakes of leaf along the seams. + let leaves = [ + (60.0, 60.0, 34.0, 20.0), + (150.0, 150.0, 26.0, -30.0), + (70.0, 250.0, 30.0, 50.0), + (190.0, 300.0, 20.0, 10.0), + (40.0, 330.0, 16.0, -25.0), + (220.0, 40.0, 14.0, 40.0), + (w - 150.0, 50.0, 36.0, -15.0), + (w - 60.0, 150.0, 28.0, 35.0), + (w - 200.0, 300.0, 30.0, -40.0), + (w - 70.0, 320.0, 20.0, 5.0), + (w - 230.0, 170.0, 16.0, 60.0), + (w - 110.0, 240.0, 12.0, -60.0), + (w * 0.58, h * 0.72, 16.0, 25.0), + (w * 0.44, h * 0.9, 12.0, -20.0), + (w * 0.27, h * 0.14, 10.0, 15.0), + ]; + let mut d = String::new(); + for (x, y, r, rot) in leaves { + d.push_str(&leaf(x, y, r, rot)); + d.push(' '); + } + let mut flakes = String::new(); + for c in &cracks { + for p in c.iter().step_by(7) { + if n.next() < 0.35 { + let s = 2.0 + n.next() * 4.0; + let pts: Vec<(f64, f64)> = (0..5) + .map(|k| { + draw::polar( + p.0 + (n.next() - 0.5) * 16.0, + p.1 + (n.next() - 0.5) * 16.0, + s * (0.6 + n.next() * 0.6), + 72.0 * f64::from(k) + n.next() * 30.0, + ) + }) + .collect(); + flakes.push_str(&draw::polygon(&pts)); + flakes.push(' '); + } + } + } + let leaf_gold = draw::linear( + 140.0, + &[(0.0, "#fbe7a8"), (0.5, "#cfa24a"), (1.0, "#f2d88a")], + ); + b.add( + art, + draw::with_effects( + draw::path("leaves", 0.0, 0.0, w, h, d, leaf_gold.clone()), + vec![draw::drop_shadow("#00000066", 1.0, 2.0, 2.0)], + ), + ); + b.add(art, draw::path("flakes", 0.0, 0.0, w, h, flakes, leaf_gold)); + // The panel's glaze. + b.add( + art, + draw::rect( + "glaze", + 0.0, + 0.0, + w, + h, + draw::linear( + 160.0, + &[(0.0, "#ffffff1c"), (0.3, "#ffffff00"), (1.0, "#00000026")], + ), + ), + ); +} + +pub(super) fn kintsugi() -> Face { + const INK: &str = "#2a2418"; + let (w, h) = (HEAD_W, 650.0); + let top = 58.0; + let bh = 575.0; + let scene = canvas("Kintsugi Head", |b, root| { + parts::page(b, root, w, h); + for x in [150.0, 1050.0] { + b.add( + root, + draw::rounded( + draw::rect( + "foot", + x - 50.0, + top + bh - 10.0, + 100.0, + h - top - bh + 10.0, + draw::linear(180.0, &[(0.0, "#2a2a2c"), (1.0, "#0e0e10")]), + ), + 10.0, + ), + ); + } + let body = covered( + b, + root, + (0.0, top, w, bh), + 34.0, + &Material::Tolex { + grain: Grain::Fine, + color: "#efe9de".into(), + scale: 1.0, + }, + ); + let (ax, ay, aw, ah) = (41.0, 38.0, w - 82.0, 368.0); + let mut art = look::slab("Art", (ax, ay, aw, ah), 10.0, vec![draw::solid("#1c1b1d")]); + art.clips_content = true; + art.effects = vec![draw::drop_shadow("#ffffff80", 0.0, 1.5, 0.0)]; + let art = b.add(body, art); + kintsugi_art(b, art, aw, ah); + for n in frame::surface::kit::materials::light::edge( + "art recess", + (0.0, 0.0, aw, ah), + 10.0, + 6.0, + 1.0, + false, + ) { + b.add(art, n); + } + // The gold control strip: brushed brass. + let (sx, sy, sw, sh) = (25.0, 439.0, w - 50.0, 108.0); + let mut strip = draw::rounded(draw::frame("Strip", sx, sy, sw, sh), 8.0); + strip.effects = vec![draw::drop_shadow("#00000080", 0.0, 3.0, 6.0)]; + strip.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid("#7a5a26")], + weight: 2.0, + ..frame::document::proto::Stroke::default() + }; + let strip = b.add(body, strip); + fill( + b, + strip, + (0.0, 0.0, sw, sh), + 8.0, + &Material::Brushed { + color: "#d2ae62".into(), + dir: Dir::Horizontal, + }, + ); + for n in frame::surface::kit::materials::light::edge( + "strip edge", + (0.0, 0.0, sw, sh), + 8.0, + 4.0, + 1.0, + true, + ) { + b.add(strip, n); + } + for (x, y) in [ + (14.0, 14.0), + (sw - 14.0, 14.0), + (14.0, sh - 14.0), + (sw - 14.0, sh - 14.0), + ] { + parts::screw(b, strip, x, y, 5.0, "#7a5a26"); + } + let cy = 53.0; + let t = Type::new(9.5, INK, 600.0).track(0.12).upper(); + look::brush_mark(b, strip, (80.0, cy - 2.0), 22.0, INK); + look::emblem(b, strip, (140.0, cy), 18.0, INK); + parts::centred(b, strip, "Input", (218.0, 86.0), t); + push(b, strip, "kintsugi/bright", 26.0, (306.0, cy), true); + parts::centred(b, strip, "Bright", (306.0, 86.0), t); + let knob = KnobLook { + preset: "fluted", + opts: &[ + "color=#3b2427", + "pointer=#e8d6bc", + "ribs=18", + "rib_depth=0.045", + "cap_r=0.92", + "height=0.6", + "dome=0.04", + "edge=0.1", + "index=line", + "index_in=0.3", + "index_out=0.85", + "index_w=0.05", + "coat=0.5", + "rough=0.25", + "ticks=dots", + "fallback=fluted", + ], + }; + let knobs: [(&str, &str, f64); 7] = [ + ("kintsugi/gain", "Gain", 388.0), + ("kintsugi/bass", "Bass", 475.0), + ("kintsugi/middle", "Middle", 561.0), + ("kintsugi/treble", "Treble", 648.0), + ("kintsugi/master", "Master", 808.0), + ("kintsugi/presence", "Presence", 894.0), + ("kintsugi/output", "Output", 979.0), + ]; + for (addr, legend, x) in knobs { + look::place_knob(b, strip, &knob, addr, legend, 48.0, (x, cy - 4.0), INK); + parts::centred(b, strip, legend, (x, 90.0), t); + } + push(b, strip, "kintsugi/shift", 26.0, (729.0, cy), true); + parts::centred(b, strip, "Shift", (729.0, 86.0), t); + push(b, strip, "kintsugi/power", 26.0, (1057.0, cy), true); + parts::centred(b, strip, "Power", (1057.0, 86.0), t); + let (jx, jy) = (sx + 218.0, top + sy + cy - 4.0); + parts::cable( + b, + root, + (jx, jy), + 12.0, + &[ + (jx - 60.0, jy + 26.0), + (jx - 140.0, jy + 70.0), + (jx - 200.0, h - 6.0), + ], + ); + handle(b, root, 390.0, 810.0, top + 4.0, 46.0, 14.0, "#2a2224"); + }); + // Every knob at noon, as the reference screenshot shows them. + let params = vec![ + parts::switch("kintsugi/bright", "Bright", false), + parts::dial10("kintsugi/gain", "Gain", 5.0), + parts::dial10("kintsugi/bass", "Bass", 5.0), + parts::dial10("kintsugi/middle", "Middle", 5.0), + parts::dial10("kintsugi/treble", "Treble", 5.0), + parts::switch("kintsugi/shift", "Shift", false), + parts::dial10("kintsugi/master", "Master", 5.0), + parts::dial10("kintsugi/presence", "Presence", 5.0), + parts::dial10("kintsugi/output", "Output", 5.0), + parts::switch("kintsugi/power", "Power", true), + ]; + amp_face("07-kintsugi-head-1200x650", scene, params) +} diff --git a/examples/plugin-faces/src/suite_boutique_clean/cab.rs b/examples/plugin-faces/src/suite_boutique_clean/cab.rs new file mode 100644 index 00000000..505cfe24 --- /dev/null +++ b/examples/plugin-faces/src/suite_boutique_clean/cab.rs @@ -0,0 +1,601 @@ +//! `08-cab-square-900x900`: the cab page (900 × 900) — the chosen cab on +//! the plugin's periwinkle ground (`cab/model`, each look a layer shown +//! for its option: the cream 4×12 with brass speaker rings and a brass +//! foot strip, the brown leather 4×12 behind a brass circle lattice, or +//! the blush 2×10 with its brass rings), a mic panel mirrored either side +//! of it (mic, position, distance, level, pan, the mic's power and phase) +//! and the room send under it, with the cab selector. +//! +//! Each mic also sits on the cab as a handle: drag it across its speaker +//! for position and up for distance. The top row of speakers sits in the +//! same place in every cab, so the mics stay on their cones. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::materials::{Grain, Material, fill}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{Range, Unit, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::look::{self, TEAL}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; +use crate::suite_funk_rhythm::parts::{self, CAB, Type}; + +const INK: &str = "#262a3a"; +const DIM: &str = "#5a6078"; +const MICS: &[&str] = &[ + "Dynamic 1", + "Dynamic 2", + "Ribbon 1", + "Ribbon 2", + "Condenser 1", + "Condenser 2", +]; +const CABS: [&str; 3] = ["Cream 4x12", "Brown 4x12", "Blush 2x10"]; +/// The top row of speakers, the same in every cab: centres and radius. +const TOP_ROW: [(f64, f64); 2] = [(355.0, 211.0), (545.0, 211.0)]; +const SPEAKER_R: f64 = 76.0; + +/// One speaker: basket, brass ring and screws, cone, dust cap. +fn speaker(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, r: f64) { + b.add( + parent, + draw::circle("well", cx, cy + 3.0, r * 1.06, draw::solid("#00000030")), + ); + let ring = draw::circle( + "ring", + cx, + cy, + r * 1.04, + draw::conic( + 30.0, + &[ + (0.0, "#fff1c4"), + (0.25, "#a77a3a"), + (0.5, "#f0d48e"), + (0.75, "#9a7236"), + (1.0, "#fff1c4"), + ], + ), + ); + b.add( + parent, + draw::with_effects(ring, vec![draw::drop_shadow("#00000059", 0.0, 2.0, 4.0)]), + ); + for i in 0..8 { + let (x, y) = draw::polar(cx, cy, r * 0.98, 22.5 + 45.0 * f64::from(i)); + b.add( + parent, + draw::circle("screw", x, y, r * 0.025, draw::solid("#5e421a")), + ); + } + b.add( + parent, + draw::circle("gasket", cx, cy, r * 0.92, draw::solid("#e9e2d4")), + ); + b.add( + parent, + draw::circle( + "surround", + cx, + cy, + r * 0.84, + draw::radial(&[(0.0, "#2c2a28"), (0.85, "#3a3734"), (1.0, "#171615")]), + ), + ); + b.add( + parent, + draw::circle( + "cone", + cx, + cy, + r * 0.7, + draw::radial_at( + 0.45, + 0.4, + 0.75, + &[(0.0, "#f4eee2"), (0.7, "#d8cfbe"), (1.0, "#b3a893")], + ), + ), + ); + b.add( + parent, + draw::with_effects( + draw::circle( + "dust cap", + cx, + cy, + r * 0.28, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#fbf7ee"), (1.0, "#c9bfae")]), + ), + vec![draw::drop_shadow("#00000040", 0.0, 2.0, 4.0)], + ), + ); +} + +/// A covered shell `(x, y, w, h)` in `m`: returns it. +fn shell( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + r: f64, + m: &Material, +) -> NodeId { + let mut n = draw::rounded(draw::frame("Cabinet", x, y, w, h), r); + n.effects = vec![draw::drop_shadow("#0000004d", 0.0, 12.0, 24.0)]; + let n = b.add(parent, n); + fill(b, n, (0.0, 0.0, w, h), r, m); + for e in + frame::surface::kit::materials::light::edge("edge", (0.0, 0.0, w, h), r, 8.0, 1.0, true) + { + b.add(n, e); + } + n +} + +fn feet(b: &mut Builder<'_>, parent: NodeId, xs: &[f64], y: f64) { + for x in xs { + b.add( + parent, + draw::rounded( + draw::rect( + "foot", + x - 20.0, + y, + 40.0, + 16.0, + draw::linear(180.0, &[(0.0, "#2a2a2c"), (1.0, "#0a0a0b")]), + ), + 4.0, + ), + ); + } +} + +/// The cream 4×12: tolex, a black-piped baffle, brass rings, a brass foot +/// strip and corner caps. +fn cream(b: &mut Builder<'_>, layer: NodeId) { + let (x, y, w, h) = (250.0, 70.0, 400.0, 530.0); + feet(b, layer, &[x + 80.0, x + w - 80.0], y + h); + let s = shell( + b, + layer, + (x, y, w, h), + 14.0, + &Material::Tolex { + grain: Grain::Fine, + color: "#f1ebdf".into(), + scale: 1.0, + }, + ); + let (bx, by, bw, bh) = (24.0, 24.0, w - 48.0, h - 80.0); + let baffle = look::slab( + "Baffle", + (bx, by, bw, bh), + 8.0, + vec![draw::radial_at( + 0.5, + 0.45, + 0.8, + &[(0.0, "#f4efe6"), (1.0, "#e0d8c8")], + )], + ); + b.add( + s, + draw::with_effects( + draw::stroked(baffle, "#2a2826", 5.0), + vec![draw::inner_shadow("#00000040", 0.0, 3.0, 6.0)], + ), + ); + for (cx, cy) in [ + TOP_ROW[0], + TOP_ROW[1], + (TOP_ROW[0].0, TOP_ROW[0].1 + 226.0), + (TOP_ROW[1].0, TOP_ROW[1].1 + 226.0), + ] { + speaker(b, s, cx - x, cy - y, SPEAKER_R); + } + let strip = draw::rounded( + draw::rect( + "strip", + 30.0, + h - 38.0, + w - 60.0, + 9.0, + draw::linear( + 180.0, + &[(0.0, "#fff1c4"), (0.5, "#c9a45c"), (1.0, "#8a6630")], + ), + ), + 4.0, + ); + b.add( + s, + draw::with_effects(strip, vec![draw::drop_shadow("#00000066", 0.0, 1.5, 2.0)]), + ); + for i in 0..9 { + b.add( + s, + draw::circle( + "rivet", + 44.0 + (w - 88.0) * f64::from(i) / 8.0, + h - 33.5, + 1.8, + draw::solid("#6b4e22"), + ), + ); + } + for (cx, cy) in [ + (0.0, 0.0), + (w - 22.0, 0.0), + (0.0, h - 22.0), + (w - 22.0, h - 22.0), + ] { + b.add( + s, + draw::rounded( + draw::rect( + "corner", + cx, + cy, + 22.0, + 22.0, + draw::linear( + 150.0, + &[(0.0, "#fff1c4"), (0.5, "#c9a45c"), (1.0, "#7a5a26")], + ), + ), + 5.0, + ), + ); + } +} + +/// The brown 4×12: leather, a brass circle lattice over black cloth +/// behind a cream-framed baffle. +fn brown(b: &mut Builder<'_>, layer: NodeId) { + let (x, y, w, h) = (240.0, 100.0, 420.0, 470.0); + feet(b, layer, &[x + 70.0, x + w - 70.0], y + h); + let s = shell( + b, + layer, + (x, y, w, h), + 12.0, + &Material::Leather { + color: "#8e4524".into(), + }, + ); + let (bx, by, bw, bh) = (22.0, 22.0, w - 44.0, h - 44.0); + let mut grille = look::slab( + "Lattice", + (bx, by, bw, bh), + 6.0, + vec![draw::solid("#0d0b09")], + ); + grille.clips_content = true; + grille.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid("#e6dccb")], + weight: 5.0, + ..frame::document::proto::Stroke::default() + }; + let g = b.add(s, grille); + let pitch = 22.0; + let mut circles = Vec::new(); + for row in 0..((bh / pitch) as i32 + 2) { + for col in 0..((bw / pitch) as i32 + 2) { + circles.push(parts::ring( + f64::from(col) * pitch, + f64::from(row) * pitch, + pitch * 0.5, + )); + } + } + b.add( + g, + ui::strokes("lattice", &circles, 2.4, draw::solid("#bf954c")), + ); + for (cx, cy) in [ + TOP_ROW[0], + TOP_ROW[1], + (TOP_ROW[0].0, TOP_ROW[0].1 + 200.0), + (TOP_ROW[1].0, TOP_ROW[1].1 + 200.0), + ] { + speaker(b, s, cx - x, cy - y, SPEAKER_R * 0.92); + } + for (cx, cy) in [ + (bx + 14.0, by + 14.0), + (bx + bw - 14.0, by + 14.0), + (bx + 14.0, by + bh - 14.0), + (bx + bw - 14.0, by + bh - 14.0), + ] { + parts::screw(b, s, cx, cy, 6.0, "#2a2826"); + } +} + +/// The blush 2×10: a glossy pink box, two brass-ringed speakers, a small +/// badge and the signature. +fn blush(b: &mut Builder<'_>, layer: NodeId) { + let (x, y, w, h) = (230.0, 116.0, 440.0, 200.0); + feet(b, layer, &[x + 80.0, x + w / 2.0, x + w - 80.0], y + h); + let s = shell( + b, + layer, + (x, y, w, h), + 40.0, + &Material::Powder { + color: "#efd5d7".into(), + coarse: false, + }, + ); + let face = look::slab( + "Front", + (16.0, 16.0, w - 32.0, h - 32.0), + 30.0, + vec![draw::solid("#f3e0e1")], + ); + b.add( + s, + draw::with_effects(face, vec![draw::inner_shadow("#a0606a40", 0.0, 3.0, 6.0)]), + ); + for (cx, cy) in TOP_ROW { + speaker(b, s, cx - x, cy - y, SPEAKER_R * 0.86); + } + b.add( + s, + draw::rounded( + draw::rect( + "badge", + w / 2.0 - 22.0, + 22.0, + 44.0, + 12.0, + draw::linear(180.0, &[(0.0, "#fff1c4"), (1.0, "#8a6630")]), + ), + 2.0, + ), + ); + look::signature(b, s, (w / 2.0 + 6.0, h - 36.0), 13.0, "#6a5055"); +} + +/// The mic panels' knob: a low white plastic disc with a dark line, as the +/// plugin's own panels draw it. +const PANEL_KNOB: look::KnobLook<'static> = look::KnobLook { + preset: "flat", + opts: &[ + "color=#f6f7fb", + "skirt=none", + "index=line", + "pointer=#1c1d22", + "cap_r=0.96", + "height=0.26", + "edge=0.1", + "tilt=14", + "spec=0.6", + "index_in=0.15", + "index_out=0.9", + "ticks=none", + ], +}; + +/// A flat white knob with its caption and live value (a pan reads `C` at +/// its centre). +fn flat(b: &mut Builder<'_>, parent: NodeId, addr: &str, legend: &str, at: (f64, f64)) { + let k = look::place_knob(b, parent, &PANEL_KNOB, addr, legend, 48.0, at, INK); + // The value a little clear of the knob's skirt (the vector knob + // reaches its box's edge). + let v = ui::add_value(b, k, 11.0, INK); + if let Some(n) = b.scene.node_mut(v) { + n.transform.ty += 4.0; + } + if addr.ends_with("pan") { + look::set_format(b, k, ValueFormat::decimals(0).mark(0.0, "C")); + } + parts::centred( + b, + parent, + legend, + (at.0, at.1 + 58.0), + Type::new(9.5, DIM, 700.0).track(0.08).upper(), + ); +} + +/// One mic's panel, its column centred on `cx`. +fn mic_panel(b: &mut Builder<'_>, root: NodeId, id: &str, title: &str, cx: f64) { + let ns = |p: &str| format!("cab/{id}_{p}"); + let panel = look::slab( + "Mic panel", + (cx - 105.0, 60.0, 210.0, 470.0), + 16.0, + vec![draw::solid("#dfe5fa")], + ); + let panel = b.add( + root, + draw::with_effects(panel, vec![draw::drop_shadow("#3a44701f", 0.0, 4.0, 12.0)]), + ); + parts::words( + b, + panel, + title, + 18.0, + 30.0, + 100.0, + TextAlign::Left, + Type::new(12.0, INK, 700.0).track(0.12).upper(), + ); + // The mic's power: a push cap with its lamp. + let p = Place::new(format!("{title} on")) + .size(22.0) + .variant("grey") + .bind(ui::bind(&ns("on"))) + .legend("") + .options(&[&format!("color={TEAL}")]) + .touch(TOUCH_MIN); + parts::part(b, panel, "button.round_cap", p, &["cap"], (175.0, 32.0)); + // The mic in use, on a dark readout, chosen by the knob under it. + ui::readout( + b, + panel, + &format!("{title} mic"), + ui::bind(&ns("mic")), + 105.0, + 78.0, + 182.0, + 30.0, + "#e8ecf8", + ); + flat(b, panel, &ns("mic"), "Mic", (55.0, 150.0)); + flat(b, panel, &ns("pos"), "Position", (155.0, 150.0)); + flat(b, panel, &ns("dist"), "Distance", (55.0, 260.0)); + flat(b, panel, &ns("level"), "Level", (155.0, 260.0)); + flat(b, panel, &ns("pan"), "Pan", (55.0, 370.0)); + let p = Place::new(format!("{title} phase")) + .size(28.0) + .variant("grey") + .bind(ui::bind(&ns("phase"))) + .legend("") + .options(&["nolamp"]) + .touch(TOUCH_MIN); + parts::part(b, panel, "button.round_cap", p, &["cap"], (155.0, 370.0)); + parts::centred( + b, + panel, + "Phase Ø", + (155.0, 428.0), + Type::new(9.5, DIM, 700.0).track(0.08).upper(), + ); +} + +pub(super) fn face() -> Face { + let scene = canvas("Cab", |b, root| { + parts::page(b, root, CAB, CAB); + b.add( + root, + draw::rect( + "ground", + 0.0, + 0.0, + CAB, + CAB, + draw::linear( + 180.0, + &[(0.0, "#cdd6f5"), (0.7, "#c2ccef"), (1.0, "#aab4dc")], + ), + ), + ); + b.add( + root, + draw::rect( + "floor", + 0.0, + 600.0, + CAB, + 300.0, + draw::linear( + 180.0, + &[(0.0, "#b8b0a6"), (0.05, "#d8cfc2"), (1.0, "#a89c8c")], + ), + ), + ); + for (i, paint) in [cream as fn(&mut Builder<'_>, NodeId), brown, blush] + .iter() + .enumerate() + { + let layer = parts::variant_layer(b, root, "cab/model", i, (0.0, 0.0, CAB, 620.0)); + paint(b, layer); + } + for (i, m) in ["a", "b"].iter().enumerate() { + let (cx, cy) = TOP_ROW[i]; + parts::mic_handle( + b, + root, + (cx, cy, SPEAKER_R), + &format!("Mic {}", m.to_uppercase()), + &format!("cab/{m}_pos"), + &format!("cab/{m}_dist"), + ); + } + mic_panel(b, root, "a", "Mic A", 120.0); + mic_panel(b, root, "b", "Mic B", 780.0); + // The cab selector. + let sel = Place::new("Cab") + .size(56.0) + .variant("white") + .bind(ui::bind("cab/model")) + .options(&["Cream 4×12", "Brown 4×12", "Blush 2×10"]) + .keep_case(); + let s = frame::surface::kit::place(b, root, "button.square_bank", &sel) + .unwrap_or_else(|e| panic!("{e}")); + ui::strip_legend(b, s); + parts::centre_on(b, s, (CAB / 2.0, 670.0)); + // The room send. + let room = look::slab( + "Room", + (300.0, 720.0, 300.0, 150.0), + 16.0, + vec![draw::solid("#dfe5fa")], + ); + let room = b.add( + root, + draw::with_effects(room, vec![draw::drop_shadow("#3a44701f", 0.0, 4.0, 12.0)]), + ); + parts::words( + b, + room, + "Room Send", + 20.0, + 28.0, + 160.0, + TextAlign::Left, + Type::new(12.0, INK, 700.0).track(0.12).upper(), + ); + let p = Place::new("Room send") + .size(22.0) + .variant("grey") + .bind(ui::bind("cab/room_on")) + .legend("") + .options(&[&format!("color={TEAL}")]) + .touch(TOUCH_MIN); + parts::part(b, room, "button.round_cap", p, &["cap"], (260.0, 30.0)); + flat(b, room, "cab/room_level", "Level", (150.0, 70.0)); + }); + let mut params = vec![parts::pick("cab/model", "Cab", &CABS, 0)]; + for (id, name, pos) in [("a", "Mic A", 0.45), ("b", "Mic B", 0.6)] { + let a = |p: &str| format!("cab/{id}_{p}"); + params.extend([ + parts::switch(&a("on"), &format!("{name} On"), id == "a"), + parts::pick( + &a("mic"), + &format!("{name} Mic"), + MICS, + if id == "a" { 0 } else { 2 }, + ), + ui::param( + &a("pos"), + &format!("{name} Position"), + Range::linear(0.0, 1.0, Unit::None), + pos, + ) + .featured(), + ui::param( + &a("dist"), + &format!("{name} Distance"), + Range::linear(0.0, 1.0, Unit::None), + 0.1, + ) + .featured(), + parts::db(&a("level"), &format!("{name} Level"), -24.0, 6.0, 0.0), + ui::param( + &a("pan"), + &format!("{name} Pan"), + Range::linear(-100.0, 100.0, Unit::Percent), + 0.0, + ) + .featured() + .bipolar(), + parts::switch(&a("phase"), &format!("{name} Phase"), false), + ]); + } + params.push(parts::switch("cab/room_on", "Room Send", false)); + params.push(parts::db("cab/room_level", "Room Level", -24.0, 6.0, 0.0)); + look::face("08-cab-square-900x900", scene, params) +} diff --git a/examples/plugin-faces/src/suite_boutique_clean/eq.rs b/examples/plugin-faces/src/suite_boutique_clean/eq.rs new file mode 100644 index 00000000..34efdfc6 --- /dev/null +++ b/examples/plugin-faces/src/suite_boutique_clean/eq.rs @@ -0,0 +1,348 @@ +//! `09-eq-rack-1140x286`: the nine-band graphic EQ in its desk case (1140 +//! wide, the case's own 4:1 aspect), in the three colourways the rig ships +//! (`eq/color`, each a layer shown for its option: blush-pink plastic, +//! brushed gold, or cream) — HPF and LPF on dark scalloped knobs with +//! brass lines (both open, as the reference shows them), a brass bypass +//! toggle, nine wide faders (deep rounded slots, dark domed caps with a +//! cream index) over tick scales with the gain printed above each and the +//! band below, and the teal pilot. HPF and LPF read OFF at their open +//! stops and the gains read signed; three swatches under the pilot pick +//! the colourway. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::draw; +use frame::surface::kit::materials::{Dir, Material, fill}; +use frame::surface::proto::{ButtonMode, Control, ControlKind, Orientation, SlotRole, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::look::KnobLook; +use super::look::{self, TEAL}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; +use crate::suite_funk_rhythm::parts::{self, RACK_W, Type}; + +const INK: &str = "#1e1719"; +const BANDS: [(&str, &str); 9] = [ + ("65", "65 Hz"), + ("125", "125 Hz"), + ("250", "250 Hz"), + ("500", "500 Hz"), + ("1k", "1 kHz"), + ("2k", "2 kHz"), + ("4k", "4 kHz"), + ("8k", "8 kHz"), + ("16k", "16 kHz"), +]; +/// The fader travel: top of the lane and its length. +const LANE: (f64, f64) = (58.0, 150.0); + +/// A plain printed readout of `addr` (`Value` slot), centred at `at`. +pub(super) fn readout( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + at: (f64, f64), + w: f64, + t: Type<'_>, + align: TextAlign, +) -> NodeId { + let h = (t.size * 1.6).ceil(); + let x = match align { + TextAlign::Left => at.0, + TextAlign::Right => at.0 - w, + _ => at.0 - w / 2.0, + }; + let root = b.add( + parent, + draw::frame(&format!("{addr} readout"), x, at.1 - h / 2.0, w, h), + ); + let v = parts::words(b, root, "", 0.0, h / 2.0, w, align, t); + let mut c = Control::new(ControlKind::Readout).slot(SlotRole::Value, v.0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(root, c); + root +} + +/// A wide EQ fader centred on `x`: a deep rounded slot between tick +/// scales and a dark domed cap with a cream index, the cap riding a lane +/// `LANE.1` long (a vertical `Fader` whose `Thumb` is the cap). +fn fader(b: &mut Builder<'_>, parent: NodeId, addr: &str, name: &str, x: f64) -> NodeId { + let (top, travel) = LANE; + let (cap_w, cap_h) = (34.0, 20.0); + let w = 62.0; + let root = b.add( + parent, + draw::frame(name, x - w / 2.0, top - cap_h / 2.0, w, travel + cap_h), + ); + let c = w / 2.0; + // Tick scales either side. + let ticks: Vec> = (0..=12) + .flat_map(|i| { + let y = cap_h / 2.0 + travel * f64::from(i) / 12.0; + let len = if i % 6 == 0 { 9.0 } else { 6.0 }; + [ + vec![(c - 13.0 - len, y), (c - 13.0, y)], + vec![(c + 13.0, y), (c + 13.0 + len, y)], + ] + }) + .collect(); + b.add( + root, + ui::strokes("ticks", &ticks, 0.9, draw::solid("#1e171966")), + ); + let slot = draw::rounded( + draw::rect( + "slot", + c - 7.0, + cap_h / 2.0 - 6.0, + 14.0, + travel + 12.0, + draw::linear( + 90.0, + &[(0.0, "#050405"), (0.5, "#221b1d"), (1.0, "#0a0809")], + ), + ), + 7.0, + ); + b.add( + root, + draw::with_effects(slot, vec![draw::drop_shadow("#ffffff80", 0.0, 1.0, 0.0)]), + ); + let lane = b.add( + root, + draw::frame("lane", c - cap_w / 2.0, 0.0, cap_w, travel + cap_h), + ); + let cap = b.add(lane, draw::frame("cap", 0.0, 0.0, cap_w, cap_h)); + let body = draw::rounded( + draw::rect( + "cap body", + 0.0, + 0.0, + cap_w, + cap_h, + draw::linear( + 180.0, + &[(0.0, "#5a5456"), (0.35, "#2a2426"), (1.0, "#0d0a0b")], + ), + ), + cap_h / 2.0, + ); + b.add( + cap, + draw::with_effects(body, vec![draw::drop_shadow("#000000a0", 1.0, 4.0, 5.0)]), + ); + b.add( + cap, + draw::rounded( + draw::rect( + "cap sheen", + 4.0, + 2.0, + cap_w - 8.0, + cap_h * 0.35, + draw::solid("#ffffff24"), + ), + cap_h * 0.2, + ), + ); + b.add( + cap, + draw::rounded( + draw::rect( + "index", + 5.0, + cap_h / 2.0 - 1.2, + cap_w - 10.0, + 2.4, + draw::solid("#efe0c4"), + ), + 1.2, + ), + ); + let mut ctl = Control::new(ControlKind::Fader { + orientation: Orientation::Vertical, + }) + .slot(SlotRole::Thumb, cap.0); + ctl.binding = Some(ui::bind(addr)); + b.controls.insert(root, ctl); + root +} + +/// The filters' ranges: each reads OFF at its open stop. +const HPF: (f64, f64) = (20.0, 500.0); +const LPF: (f64, f64) = (2000.0, 20000.0); + +/// The colourways, in `eq/color`'s order: name and swatch. +const COLOURWAYS: [(&str, &str); 3] = [ + ("Blush", "#eec7ca"), + ("Gold", "#c79a4e"), + ("Cream", "#f0ece5"), +]; + +/// The on-face colourway selector: a row of three swatch buttons centred +/// on `(cx, cy)`, each a `Select` over `eq/color`, the chosen one ringed +/// in brass. +fn colour_selector(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64)) { + let (r, pitch) = (4.5, 15.0); + let n = COLOURWAYS.len(); + for (i, (name, swatch)) in COLOURWAYS.iter().enumerate() { + let x = cx + (i as f64 - (n - 1) as f64 / 2.0) * pitch; + let s = pitch; + let root = b.add( + parent, + draw::frame(&format!("Colour {name}"), x - s / 2.0, cy - s / 2.0, s, s), + ); + let dot = draw::with_effects( + draw::circle("swatch", s / 2.0, s / 2.0, r, draw::solid(swatch)), + vec![draw::drop_shadow("#00000070", 0.0, 0.8, 1.5)], + ); + b.add(root, draw::stroked(dot, "#1e171955", 0.6)); + let ring = b.add( + root, + draw::stroked( + draw::circle( + "chosen", + s / 2.0, + s / 2.0, + r + 2.2, + draw::solid("#00000000"), + ), + "#b8893c", + 1.4, + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: n as u32, + }, + }) + .slot(SlotRole::Led, ring.0); + c.binding = Some(ui::bind("eq/color")); + b.controls.insert(root, c); + } +} + +/// The case in colourway `i`: blush paint, brushed gold, cream paint. +fn case(b: &mut Builder<'_>, layer: NodeId, (w, h): (f64, f64), i: usize) { + let mut shell = draw::rounded(draw::frame("Case", 0.0, 0.0, w, h), 16.0); + shell.effects = vec![draw::drop_shadow("#00000066", 0.0, 8.0, 16.0)]; + let shell = b.add(layer, shell); + let m = match i { + 0 => Material::Powder { + color: "#eec7ca".into(), + coarse: false, + }, + 1 => Material::Brushed { + color: "#c79a4e".into(), + dir: Dir::Horizontal, + }, + _ => Material::Powder { + color: "#f0ece5".into(), + coarse: false, + }, + }; + fill(b, shell, (0.0, 0.0, w, h), 16.0, &m); + for n in frame::surface::kit::materials::light::edge( + "case edge", + (0.0, 0.0, w, h), + 16.0, + 10.0, + 1.0, + true, + ) { + b.add(shell, n); + } +} + +pub(super) fn face() -> Face { + let (w, h) = (RACK_W, 286.0); + let scene = canvas("Graphic EQ", |b, root| { + parts::page(b, root, w, h); + for x in [120.0, w - 170.0] { + b.add( + root, + draw::rounded( + draw::rect("foot", x, h - 14.0, 50.0, 14.0, draw::solid("#1b1b1d")), + 4.0, + ), + ); + } + for i in 0..3 { + let layer = parts::variant_layer(b, root, "eq/color", i, (0.0, 0.0, w, h - 12.0)); + case(b, layer, (w, h - 12.0), i); + } + let case = b.add(root, draw::frame("Controls", 0.0, 0.0, w, h - 12.0)); + let lab = Type::new(10.5, INK, 700.0); + let knob = KnobLook { + preset: "scalloped", + opts: &[ + "color=#1e1413", + "pointer=#dcaa4c", + "ribs=9", + "rib_depth=0.13", + "coat=0.85", + "rough=0.14", + "height=0.56", + "index_in=0.05", + "index_out=0.72", + "index_w=0.07", + "ticks=none", + "fallback=fluted", + ], + }; + // HPF. + parts::words(b, case, "HPF :", 100.0, 35.0, 44.0, TextAlign::Right, lab); + let hpf = readout(b, case, "eq/hpf", (148.0, 35.0), 60.0, lab, TextAlign::Left); + look::set_format(b, hpf, ValueFormat::default().mark(HPF.0, "OFF")); + look::place_knob(b, case, &knob, "eq/hpf", "HPF", 62.0, (153.0, 116.0), INK); + parts::art( + b, + case, + &[vec![(142.0, 176.0), (146.0, 169.0), (158.0, 169.0)]], + 1.8, + INK, + ); + look::brass_toggle(b, case, "eq/on", "Bypass", 20.0, (150.0, 218.0)); + // LPF. + parts::words(b, case, "LPF :", 944.0, 35.0, 44.0, TextAlign::Right, lab); + let lpf = readout(b, case, "eq/lpf", (992.0, 35.0), 70.0, lab, TextAlign::Left); + look::set_format(b, lpf, ValueFormat::default().mark(LPF.1, "OFF")); + look::place_knob(b, case, &knob, "eq/lpf", "LPF", 62.0, (990.0, 116.0), INK); + parts::art( + b, + case, + &[vec![(980.0, 169.0), (992.0, 169.0), (996.0, 176.0)]], + 1.8, + INK, + ); + parts::status_led(b, case, "eq/on", 12.0, (990.0, 218.0), TEAL, "light", None); + // The nine faders. + for (i, (id, band)) in BANDS.iter().enumerate() { + let x = 262.0 + 76.4 * i as f64; + let addr = format!("eq/g{id}"); + fader(b, case, &addr, band, x); + let gain = readout(b, case, &addr, (x, 35.0), 70.0, lab, TextAlign::Center); + look::set_format(b, gain, ValueFormat::decimals(1).signed()); + parts::centred(b, case, band, (x, 238.0), lab); + } + colour_selector(b, case, (990.0, 252.0)); + }); + let mut params = vec![ + parts::pick("eq/color", "Colour", &COLOURWAYS.map(|(n, _)| n), 0), + parts::hz("eq/hpf", "HPF", HPF.0, HPF.1, HPF.0), + parts::hz("eq/lpf", "LPF", LPF.0, LPF.1, LPF.1), + parts::switch("eq/on", "Bypass", true), + ]; + // The reference's faders: top, middle, bottom, repeated. + for (i, (id, band)) in BANDS.iter().enumerate() { + params.push(parts::db( + &format!("eq/g{id}"), + band, + -12.0, + 12.0, + [12.0, 0.0, -12.0][i % 3], + )); + } + look::face("09-eq-rack-1140x286", scene, params) +} diff --git a/examples/plugin-faces/src/suite_boutique_clean/look.rs b/examples/plugin-faces/src/suite_boutique_clean/look.rs new file mode 100644 index 00000000..b261e63e --- /dev/null +++ b/examples/plugin-faces/src/suite_boutique_clean/look.rs @@ -0,0 +1,432 @@ +//! The boutique suite's own look: its brush-lettered maker's mark and +//! script signature, the carved walnut plates, the chevron name row, the +//! suite's knobs and printed tick rings, brass hardware placed by centre, +//! and the soft teal lamp. + +use frame::document::proto::{Node, NodeId}; +use frame::surface::kit::materials::{Dir, Material, fill}; +use frame::surface::kit::{Ink, Place, TOUCH_MIN, draw}; +use frame::surface::widgets::Builder; + +use crate::face::Face; +use crate::reverb::ui; +use crate::suite_funk_rhythm::parts::{self, Lid, Type}; + +pub const PLUGIN: &str = "suite-boutique-clean"; +/// The suite's lamp colour. +pub const TEAL: &str = "#3ff0e6"; +/// Printed ink on the cream enclosures. +pub const INK: &str = "#161514"; +/// The maker's mark, invented, set in a brush hand where the rig signs its +/// gear. +pub const MARK: &str = "hana"; +/// The invented signature the rig prints in script. +pub const SIGNATURE: &str = "Hana K."; + +/// A face in this suite, its readouts formatted. +#[must_use] +pub fn face( + file: &'static str, + scene: frame::document::proto::Scene, + params: Vec, +) -> Face { + ui::formatted(Face { + file, + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + }) +} + +/// A face with streams. +#[must_use] +pub fn face_with( + file: &'static str, + scene: frame::document::proto::Scene, + params: Vec, + streams: Vec, +) -> Face { + ui::formatted(Face { + file, + plugin: PLUGIN, + scene, + params, + streams, + }) +} + +/// The maker's mark: the invented name brush-lettered (a marker brush +/// hand), `s` px tall, centred at (`cx`, `cy`). +pub fn brush_mark( + b: &mut Builder<'_>, + parent: NodeId, + (cx, cy): (f64, f64), + s: f64, + color: &str, +) -> NodeId { + parts::centred( + b, + parent, + MARK, + (cx, cy), + Type::new(s * 1.25, color, 400.0).family(draw::family::CAVEAT_BRUSH), + ) +} + +/// The invented signature in a flowing script, `s` px tall, centred at +/// (`cx`, `cy`), leaning a little uphill. +pub fn signature( + b: &mut Builder<'_>, + parent: NodeId, + (cx, cy): (f64, f64), + s: f64, + color: &str, +) -> NodeId { + let t = Type::new(s, color, 500.0).family(draw::family::DANCING_SCRIPT); + let w = s * 5.0; + b.add( + parent, + draw::rotated( + parts::set( + SIGNATURE, + cx - w / 2.0, + cy, + w, + frame::document::proto::TextAlign::Center, + t, + ), + -8.0, + ), + ) +} + +/// The rig's emblem, invented: a small lotus in a circle. +pub fn emblem(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64), r: f64, color: &str) { + let petal = |a: f64| { + let tip = draw::polar(cx, cy + r * 0.25, r * 0.85, a); + let l = draw::polar(cx, cy + r * 0.25, r * 0.3, a - 40.0); + let rr = draw::polar(cx, cy + r * 0.25, r * 0.3, a + 40.0); + vec![(cx, cy + r * 0.25), l, tip, rr, (cx, cy + r * 0.25)] + }; + let lines = vec![ + petal(0.0), + petal(-45.0), + petal(45.0), + vec![(cx - r * 0.7, cy + r * 0.45), (cx + r * 0.7, cy + r * 0.45)], + ]; + parts::art(b, parent, &lines, (r * 0.09).max(0.9), color); +} + +/// A carved walnut plate `(x, y, w, h)`: real walnut (grain running +/// down), a dark rebate round it. Returns the clipped plate frame to carve +/// into. +pub fn walnut(b: &mut Builder<'_>, parent: NodeId, (x, y, w, h): (f64, f64, f64, f64)) -> NodeId { + let mut plate = draw::frame("Walnut", x, y, w, h); + plate.corner_radii = frame::document::proto::CornerRadii::uniform(3.5); + plate.clips_content = true; + plate.effects = vec![draw::drop_shadow("#ffffff66", 0.0, 0.8, 0.0)]; + let plate = b.add(parent, plate); + fill( + b, + plate, + (0.0, 0.0, w, h), + 3.5, + &Material::Wood { + color: "#7a3d27".into(), + dir: Dir::Vertical, + }, + ); + plate +} + +/// Finish a carved plate: its rebate (shadow in along the top and left, +/// a lit lip at the bottom) over the carving. +pub fn plate_edge(b: &mut Builder<'_>, plate: NodeId) { + let (w, h) = b + .scene + .node(plate) + .map_or((0.0, 0.0), |n| (n.size.width, n.size.height)); + for n in frame::surface::kit::materials::light::edge( + "rebate", + (0.0, 0.0, w, h), + 3.5, + 3.0, + 1.0, + false, + ) { + b.add(plate, n); + } + b.add( + plate, + draw::stroked( + draw::rounded( + draw::rect("rim", 0.0, 0.0, w, h, draw::solid("#00000000")), + 3.5, + ), + "#2c150c", + 1.2, + ), + ); +} + +/// Carve `lines` into a plate: a dark groove with a lit lower lip. +pub fn carve(b: &mut Builder<'_>, plate: NodeId, lines: &[Vec<(f64, f64)>], weight: f64) { + let lit: Vec> = lines + .iter() + .map(|l| { + l.iter() + .map(|(x, y)| (*x + weight * 0.35, *y + weight * 0.45)) + .collect() + }) + .collect(); + b.add( + plate, + ui::strokes("carve light", &lit, weight * 0.8, draw::solid("#d08a6a60")), + ); + b.add( + plate, + ui::strokes("carve", lines, weight, draw::solid("#2e140bd8")), + ); +} + +/// The `> >>>> NAME >>>> >` row across a pedal at `cy`. +pub fn name_row(b: &mut Builder<'_>, parent: NodeId, name: &str, w: f64, cy: f64, ink: &str) { + let chevron = |x: f64, h: f64| { + vec![ + (x - h * 0.45, cy - h), + (x + h * 0.45, cy), + (x - h * 0.45, cy + h), + ] + }; + let big = vec![chevron(12.0, 5.0), chevron(w - 12.0, 5.0)]; + let mut small = Vec::new(); + for i in 0..4 { + small.push(chevron(27.0 + 4.0 * f64::from(i), 1.8)); + small.push(chevron(w - 40.0 + 4.0 * f64::from(i), 1.8)); + } + parts::art(b, parent, &big, 1.9, ink); + parts::art(b, parent, &small, 0.7, ink); + parts::centred( + b, + parent, + name, + (w / 2.0, cy), + Type::new(12.5, ink, 700.0).track(0.06), + ); +} + +/// A tick ring printed round a knob at (`cx`, `cy`): `n` ticks over a +/// 300° travel from `r` out `len`, every fifth (and the ends) longer, and +/// `min` / `max` under its ends when `minmax`. +#[allow(clippy::too_many_arguments)] +pub fn tick_ring( + b: &mut Builder<'_>, + parent: NodeId, + (cx, cy): (f64, f64), + r: f64, + len: f64, + n: usize, + ink: &str, + minmax: bool, +) { + let lines: Vec> = (0..n) + .map(|i| { + let a = -150.0 + 300.0 * i as f64 / (n - 1) as f64; + let l = if i == 0 || i == n - 1 || i % 5 == 0 { + len * 1.5 + } else { + len + }; + vec![draw::polar(cx, cy, r, a), draw::polar(cx, cy, r + l, a)] + }) + .collect(); + parts::art(b, parent, &lines, (r * 0.03).clamp(0.5, 1.2), ink); + if minmax { + // Beside the ends of the travel, as the reference prints them. + let t = Type::new((r * 0.2).max(3.5), ink, 500.0); + let (dx, dy) = (r + len * 1.2, r * 0.95); + parts::centred(b, parent, "min", (cx - dx, cy + dy), t); + parts::centred(b, parent, "max", (cx + dx, cy + dy), t); + } +} + +/// How one of the suite's knobs looks: a `knob.3d` preset and its options +/// (the GPU knob; `knob.render` draws underneath where the shader doesn't +/// run). +#[derive(Clone, Copy)] +pub struct KnobLook<'a> { + pub preset: &'a str, + pub opts: &'a [&'a str], +} + +/// Every knob of the suite is placed here: a `knob.3d` of diameter `d` on +/// `addr`, its shaft at (`cx`, `cy`), its hit box tight round the body, its +/// printed ring (if any) in `ink`. The face prints the legend. +#[allow(clippy::too_many_arguments)] +pub fn place_knob( + b: &mut Builder<'_>, + parent: NodeId, + look: &KnobLook<'_>, + addr: &str, + name: &str, + d: f64, + (cx, cy): (f64, f64), + ink: &str, +) -> NodeId { + let mut p = Place::new(name) + .size(d) + .variant(look.preset) + .bind(ui::bind(addr)) + .ink(Ink::Color(ink.into())) + .keep_case() + .touch(TOUCH_MIN); + if !look.opts.is_empty() { + p = p.options(look.opts); + } + let k = ui::put(b, parent, "knob.3d", &p, cx, cy); + ui::strip_legend(b, k); + k +} + +/// The suite's knob: `look` of diameter `d` on `addr` at `at`, with its +/// tick ring printed round it in `ink` (`n` ticks; none when 0). +#[allow(clippy::too_many_arguments)] +pub fn knob( + b: &mut Builder<'_>, + parent: NodeId, + look: &KnobLook<'_>, + addr: &str, + d: f64, + at: (f64, f64), + n: usize, + ink: &str, + minmax: bool, +) -> NodeId { + if n > 0 { + tick_ring(b, parent, at, d * 0.56, d * 0.09, n, ink, minmax); + } + place_knob(b, parent, look, addr, "", d, at, ink) +} + +/// Give a control's readout its own format (`OFF` at a filter's stop, +/// `+` on a gain). +pub fn set_format(b: &mut Builder<'_>, control: NodeId, f: frame::surface::proto::ValueFormat) { + if let Some(c) = b.controls.get_mut(&control) { + c.format = Some(f); + } +} + +/// A rendered stompbox (`panel.pedal_3d`) filling the page (`w × h`): the +/// lid in `finish` and `color` running down to the board (no front-face +/// band: the reference's boxes show only their lid), the jacks out of its +/// sides. Returns the lid; the pedal's parts go on it. +/// +/// # Panics +/// If the kit's enclosure has no `lid` frame. +pub fn stompbox( + b: &mut Builder<'_>, + root: NodeId, + (w, h): (f64, f64), + color: &str, + finish: &str, + radius: f64, +) -> Lid { + parts::page(b, root, w, h); + let opts = vec![ + format!("w={w}"), + format!("h={h}"), + format!("color={color}"), + format!("finish={finish}"), + format!("radius={radius}"), + "jacks=side".into(), + "front=0".into(), + ]; + let p = Place { + options: Some(opts), + ..Place::new("Enclosure").variant("standard") + }; + let body = ui::put_at(b, root, "panel.pedal_3d", &p, 0.0, 0.0); + let lid = b + .scene + .node(body) + .and_then(|n| { + n.children + .iter() + .copied() + .find(|c| b.scene.node(*c).is_some_and(|k| k.name == "lid")) + }) + .expect("panel.pedal_3d has a lid"); + let (lw, lh) = b + .scene + .node(lid) + .map_or((w, h), |n| (n.size.width, n.size.height)); + Lid { + id: lid, + w: lw, + h: lh, + } +} + +/// A brass stomp on `addr`, its cap `d` across, centred at `at`. +pub fn stomp( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + d: f64, + at: (f64, f64), + variant: &str, +) -> NodeId { + let p = Place::new("Bypass") + .size(d) + .variant(variant) + .bind(ui::bind(addr)) + .touch(TOUCH_MIN); + parts::part(b, parent, "switch.brass_stomp", p, &["cap"], at) +} + +/// A brass bat toggle on toggle `addr`, nut `d`, centred at `at`. +pub fn brass_toggle( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + name: &str, + d: f64, + at: (f64, f64), +) -> NodeId { + let p = Place::new(name) + .size(d) + .bind(ui::bind(addr)) + .touch(TOUCH_MIN); + parts::part(b, parent, "switch.brass_toggle", p, &["bushing"], at) +} + +/// A three-way brass toggle on choice `addr` (options right, down, left). +pub fn brass_toggle3( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + name: &str, + d: f64, + at: (f64, f64), +) -> NodeId { + let p = Place::new(name) + .size(d) + .bind(ui::bind(addr)) + .touch(TOUCH_MIN); + parts::part(b, parent, "switch.brass_toggle3", p, &["bushing"], at) +} + +/// A rounded-rect frame with fills — the building block of cabinets. +#[must_use] +pub fn slab( + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + r: f64, + fills: Vec, +) -> Node { + let mut n = draw::frame(name, x, y, w, h); + n.fills = fills; + n.corner_radii = frame::document::proto::CornerRadii::uniform(r); + n +} diff --git a/examples/plugin-faces/src/suite_boutique_clean/pedals.rs b/examples/plugin-faces/src/suite_boutique_clean/pedals.rs new file mode 100644 index 00000000..171e9f23 --- /dev/null +++ b/examples/plugin-faces/src/suite_boutique_clean/pedals.rs @@ -0,0 +1,348 @@ +//! The four pre-FX stompboxes (156 × 286, the Standard class) as rendered +//! enclosures (`panel.pedal_3d`: soft satin lids in cream, peach and +//! khaki running down to the board, the jacks out of the sides): teal +//! scalloped `knob.3d` knobs inside fine tick +//! rings with `min` / `max` (all turned down near `min`, as the reference +//! shows them), the brush-lettered mark at the top, the block-caps name +//! between chevrons, and a carved walnut plate round a brass stomp with a +//! teal cap and a glowing teal LED. +//! +//! Positions are fractions of the lid, measured off the reference. +//! +//! - `01-comp-standard-156x286`: COMP (Comp, EQ, Volume, Mix) — grooves. +//! - `02-od1-standard-156x286`: OD1 (Volume, Gain, Tone, Mid Boost) — a +//! hexagon lattice. +//! - `03-od2-standard-156x286`: OD2 (Volume, Gain, Cut) — tumbling blocks +//! with droplets. +//! - `04-mod-standard-156x286`: MOD (Speed, Intensity; tremolo / chorus / +//! vibrato on a three-way toggle) — concentric rings; its LED breathes +//! at the speed. + +use frame::document::proto::NodeId; +use frame::surface::proto::{Range, Unit}; +use frame::surface::widgets::Builder; + +use super::look::KnobLook; +use super::look::{self, INK, TEAL}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; +use crate::suite_funk_rhythm::parts::{self, Lid, PEDAL_H, STANDARD_W, Type}; + +const DIM: &str = "#4a4744"; +const KNOB_D: f64 = 40.0; +/// The pedals' knob: a teal cylinder of rounded scallops, a flat top whose +/// edge follows them, a painted white line for the index. +pub const KNOB: KnobLook<'static> = KnobLook { + preset: "scalloped", + opts: &[ + "color=#5a807f", + "pointer=#f3f0e7", + "ribs=16", + "rib_depth=0.045", + "dome=0.08", + "edge=0.14", + "ticks=none", + ], +}; +/// Where the reference leaves every pedal knob: turned down near `min`. +const DOWN: f64 = 0.07; + +/// Plate geometry, as fractions of the lid. +const PLATE: (f64, f64, f64, f64) = (0.068, 0.615, 0.864, 0.315); + +/// A carving: the plate, its size, and the stomp's centre in it. +type Carve = fn(&mut Builder<'_>, NodeId, (f64, f64), (f64, f64)); + +struct Pedal<'a> { + title: &'a str, + ns: &'a str, + name: &'a str, + color: &'a str, + /// Knob: param id, legend, centre and legend y (lid fractions). + knobs: &'a [(&'a str, &'a str, (f64, f64), f64)], + carve: Carve, + pulse: Option<&'a str>, + extra: Option, &Lid)>, +} + +fn build(p: &Pedal<'_>) -> frame::document::proto::Scene { + canvas(p.title, |b, root| { + let lid = look::stompbox(b, root, (STANDARD_W, PEDAL_H), p.color, "satin", 12.0); + look::brush_mark(b, lid.id, lid.at(0.5, 0.042), 11.0, INK); + for (id, legend, (fx, fy), ly) in p.knobs { + look::knob( + b, + lid.id, + &KNOB, + &format!("{}/{id}", p.ns), + KNOB_D, + lid.at(*fx, *fy), + 29, + DIM, + true, + ); + parts::centred( + b, + lid.id, + legend, + lid.at(*fx, *ly), + Type::new(6.8, INK, 700.0).track(0.04), + ); + } + if let Some(extra) = p.extra { + extra(b, &lid); + } + look::name_row(b, lid.id, p.name, lid.w, lid.h * 0.533, INK); + let (px, py) = lid.at(PLATE.0, PLATE.1); + let (pw, ph) = (lid.w * PLATE.2, lid.h * PLATE.3); + let plate = look::walnut(b, lid.id, (px, py, pw, ph)); + let stomp_at = lid.at(0.5, 0.78); + (p.carve)(b, plate, (pw, ph), (stomp_at.0 - px, stomp_at.1 - py)); + look::plate_edge(b, plate); + let on = format!("{}/on", p.ns); + parts::status_led( + b, + lid.id, + &on, + 5.5, + lid.at(0.5, 0.668), + TEAL, + "dark", + p.pulse, + ); + look::stomp(b, lid.id, &on, 20.0, stomp_at, "teal"); + }) +} + +// ── carvings, in the plate's own space (w × h), `c` the stomp ───────────── + +fn grooves(b: &mut Builder<'_>, plate: NodeId, (w, h): (f64, f64), _: (f64, f64)) { + let n = (w / 4.0) as i32 + 2; + let lines: Vec> = (0..n) + .map(|i| { + let x = 2.0 + 4.0 * f64::from(i); + vec![(x, -2.0), (x, h + 2.0)] + }) + .collect(); + look::carve(b, plate, &lines, 1.5); +} + +fn hexes(b: &mut Builder<'_>, plate: NodeId, _: (f64, f64), _: (f64, f64)) { + let r = 7.5; + let (dx, dy) = (r * 3.0_f64.sqrt(), r * 1.5); + let mut lines = Vec::new(); + for row in -1..9 { + for col in -1..11 { + let cx = f64::from(col) * dx + if row % 2 == 0 { 0.0 } else { dx / 2.0 }; + let cy = f64::from(row) * dy; + let hex: Vec<(f64, f64)> = (0..=6) + .map(|i| frame::surface::kit::draw::polar(cx, cy, r, 60.0 * f64::from(i))) + .collect(); + let inner: Vec<(f64, f64)> = (0..=6) + .map(|i| frame::surface::kit::draw::polar(cx, cy, r * 0.5, 60.0 * f64::from(i))) + .collect(); + lines.push(hex); + lines.push(inner[..4].to_vec()); + } + } + look::carve(b, plate, &lines, 0.9); +} + +fn blocks(b: &mut Builder<'_>, plate: NodeId, (w, h): (f64, f64), _: (f64, f64)) { + let r = 11.0; + let (dx, dy) = (r * 3.0_f64.sqrt(), r * 1.5); + let mut lines = Vec::new(); + let mut drops = String::new(); + for row in -1..8 { + for col in -1..9 { + let cx = f64::from(col) * dx + if row % 2 == 0 { 0.0 } else { dx / 2.0 }; + let cy = f64::from(row) * dy; + let hex: Vec<(f64, f64)> = (0..=6) + .map(|i| frame::surface::kit::draw::polar(cx, cy, r, 60.0 * f64::from(i))) + .collect(); + lines.push(hex); + // The cube's three edges from the centre. + for a in [0.0, 120.0, 240.0] { + lines.push(vec![ + (cx, cy), + frame::surface::kit::draw::polar(cx, cy, r, a), + ]); + } + // A droplet on each face. + for a in [60.0, 180.0, 300.0] { + let (x, y) = frame::surface::kit::draw::polar(cx, cy, r * 0.5, a); + let (tx, ty) = frame::surface::kit::draw::polar(x, y, 3.2, a + 180.0); + drops.push_str(&format!("M {tx:.2} {ty:.2} Q {:.2} {:.2} {x:.2} {y:.2} Q {:.2} {:.2} {tx:.2} {ty:.2} Z ", x + 2.4, y - 0.5, x - 2.4, y + 0.5)); + } + } + } + look::carve(b, plate, &lines, 0.9); + b.add( + plate, + frame::surface::kit::draw::path( + "droplets", + 0.0, + 0.0, + w, + h, + drops, + frame::surface::kit::draw::solid("#3e1b0fb8"), + ), + ); +} + +fn rings(b: &mut Builder<'_>, plate: NodeId, _: (f64, f64), (cx, cy): (f64, f64)) { + let lines: Vec> = (1..22) + .map(|i| parts::ring(cx, cy, 6.0 + 5.0 * f64::from(i))) + .collect(); + look::carve(b, plate, &lines, 1.6); +} + +// ── the pedals ─────────────────────────────────────────────────────────── + +/// Four knobs two by two: centre and legend y. +const TWO_BY_TWO: [((f64, f64), f64); 4] = [ + ((0.252, 0.13), 0.228), + ((0.748, 0.13), 0.228), + ((0.252, 0.34), 0.44), + ((0.748, 0.34), 0.44), +]; + +/// A 0..100 % control left where the reference leaves it. +fn down_pct(addr: &str, name: &str) -> frame::surface::proto::ParamInfo { + parts::pct(addr, name, DOWN * 100.0) +} + +/// A ±12 dB control left where the reference leaves it. +fn down_db(addr: &str, name: &str) -> frame::surface::proto::ParamInfo { + parts::db(addr, name, -12.0, 12.0, -12.0 + 24.0 * DOWN) +} + +pub(super) fn comp() -> Face { + let k = [ + ("comp", "Comp"), + ("eq", "EQ"), + ("volume", "Volume"), + ("mix", "Mix"), + ]; + let knobs: Vec<(&str, &str, (f64, f64), f64)> = k + .iter() + .zip(TWO_BY_TWO) + .map(|((id, l), (at, y))| (*id, *l, at, y)) + .collect(); + let scene = build(&Pedal { + title: "Comp", + ns: "comp", + name: "COMP", + color: "#ece6dc", + knobs: &knobs, + carve: grooves, + pulse: None, + extra: None, + }); + let params = vec![ + down_pct("comp/comp", "Comp"), + down_pct("comp/eq", "EQ"), + down_db("comp/volume", "Volume"), + down_pct("comp/mix", "Mix"), + parts::switch("comp/on", "Bypass", true), + ]; + look::face("01-comp-standard-156x286", scene, params) +} + +pub(super) fn od1() -> Face { + let k = [ + ("volume", "Volume"), + ("gain", "Gain"), + ("tone", "Tone"), + ("mid", "Mid Boost"), + ]; + let knobs: Vec<(&str, &str, (f64, f64), f64)> = k + .iter() + .zip(TWO_BY_TWO) + .map(|((id, l), (at, y))| (*id, *l, at, y)) + .collect(); + let scene = build(&Pedal { + title: "OD1", + ns: "od1", + name: "OD1", + color: "#e7e1d7", + knobs: &knobs, + carve: hexes, + pulse: None, + extra: None, + }); + let params = vec![ + down_db("od1/volume", "Volume"), + down_pct("od1/gain", "Gain"), + down_pct("od1/tone", "Tone"), + down_pct("od1/mid", "Mid Boost"), + parts::switch("od1/on", "Bypass", true), + ]; + look::face("02-od1-standard-156x286", scene, params) +} + +pub(super) fn od2() -> Face { + let knobs = [ + ("volume", "Volume", (0.26, 0.135), 0.24), + ("gain", "Gain", (0.74, 0.135), 0.24), + ("cut", "Cut", (0.5, 0.33), 0.443), + ]; + let scene = build(&Pedal { + title: "OD2", + ns: "od2", + name: "OD2", + color: "#edc8ae", + knobs: &knobs, + carve: blocks, + pulse: None, + extra: None, + }); + let params = vec![ + down_db("od2/volume", "Volume"), + down_pct("od2/gain", "Gain"), + down_pct("od2/cut", "Cut"), + parts::switch("od2/on", "Bypass", true), + ]; + look::face("03-od2-standard-156x286", scene, params) +} + +fn mode_switch(b: &mut Builder<'_>, lid: &Lid) { + look::brass_toggle3(b, lid.id, "mod/mode", "Mode", 18.0, lid.at(0.5, 0.354)); + let t = Type::new(6.8, INK, 700.0).track(0.04); + parts::centred(b, lid.id, "Tremolo", lid.at(0.24, 0.34), t); + parts::centred(b, lid.id, "Vibrato", lid.at(0.76, 0.34), t); + parts::centred(b, lid.id, "Chorus", lid.at(0.5, 0.43), t); +} + +pub(super) fn modulation() -> Face { + let knobs = [ + ("speed", "Speed", (0.26, 0.13), 0.228), + ("intensity", "Intensity", (0.74, 0.13), 0.228), + ]; + let scene = build(&Pedal { + title: "MOD", + ns: "mod", + name: "MOD", + color: "#ddd3b5", + knobs: &knobs, + carve: rings, + pulse: Some("param(mod/speed)"), + extra: Some(mode_switch), + }); + let params = vec![ + ui::param( + "mod/speed", + "Speed", + Range::log(0.1, 12.0, Unit::Hz), + parts::log_at(0.1, 12.0, DOWN), + ) + .featured(), + down_pct("mod/intensity", "Intensity"), + // In the toggle's order: right, down, left. The reference's lever + // leans right. + parts::pick("mod/mode", "Mode", &["Vibrato", "Chorus", "Tremolo"], 0), + parts::switch("mod/on", "Bypass", true), + ]; + look::face("04-mod-standard-156x286", scene, params) +} diff --git a/examples/plugin-faces/src/suite_boutique_clean/post.rs b/examples/plugin-faces/src/suite_boutique_clean/post.rs new file mode 100644 index 00000000..cbe604d1 --- /dev/null +++ b/examples/plugin-faces/src/suite_boutique_clean/post.rs @@ -0,0 +1,863 @@ +//! The post-FX stand: two rack units (1140 wide, heights from the stand's +//! own aspect). +//! +//! - `10-echo-rack-1140x350`: *ECHO* — a pale grey tape echo in four +//! bays: tall E C H O letters over a VU printed on the panel and the +//! delay-time / tempo display; the three active-head lamps over the big +//! MODE SELECTOR, the FREE / DAW / TAP sync lever; eight knobs (TIME, +//! FEEDBACK, TAPE-MOD, MIX; TAPE DRIVE, BASS and TREBLE in orange, WIDTH) +//! and the lit TAP key; the brush mark, the emblem and the orange POWER. +//! - `11-reverb-rack-1140x210`: *REVERB* — dark grey: tall letters, a +//! display (mode, pre-delay, decay, filters, mix — each bar a slim slider +//! of its param), the HALL / PLATE toggle, five pill faders and POWER. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::meters::scale::Card; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{Binding, Control, ControlKind, Orientation, SlotRole, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::look::{self, KnobLook}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; +use crate::suite_funk_rhythm::parts::{self, RACK_W, Type}; + +const ORANGE: &str = "#ee8a1e"; +const INK: &str = "#1f2023"; +const DIVS: &[&str] = &["1/16", "1/8T", "1/8", "1/8D", "1/4T", "1/4", "1/4D", "1/2"]; + +fn panel( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + fill: &[(f64, &str)], +) -> NodeId { + let p = look::slab( + name, + (x, y, w, h), + 3.0, + vec![ + draw::linear(180.0, fill), + draw::linear( + 90.0, + &[(0.0, "#ffffff00"), (0.5, "#ffffff10"), (1.0, "#ffffff00")], + ), + ], + ); + b.add( + parent, + draw::with_effects( + p, + vec![ + draw::inner_shadow("#ffffff80", 0.0, 1.0, 0.5), + draw::drop_shadow("#00000040", 0.0, 1.0, 2.0), + ], + ), + ) +} + +fn tall_letters( + b: &mut Builder<'_>, + parent: NodeId, + word: &str, + cx: f64, + y0: f64, + step: f64, + t: Type<'_>, +) { + for (i, ch) in word.chars().enumerate() { + parts::centred(b, parent, &ch.to_string(), (cx, y0 + step * i as f64), t); + } +} + +/// The VU printed on the panel: a `Gauge` on the input level whose needle +/// turns about (`px`, `py`), scale radius `r`. The scale is a real VU +/// card's law (`meters::scale::Card::Vu`: −20 crowded at the left, 0 VU at +/// about 70 % of the travel), and the binding carries the same law, so +/// the needle lands on the printed marks. Under the dB scale, the +/// percentage-of-0-VU scale (0 … 100 %). +fn vu(b: &mut Builder<'_>, parent: NodeId, (px, py): (f64, f64), r: f64) { + let card = Card::Vu; + let sweep = 100.0; + let (floor, ceil) = card.range(); + let side = 2.0 * (r + 26.0); + let c = side / 2.0; + let root = b.add(parent, draw::frame("VU", px - c, py - c, side, side)); + let at = |db: f64| (card.fraction(db) - 0.5) * sweep; + // The arc, its ticks and numerals, orange past 0. + let arc: Vec<(f64, f64)> = (0..=60) + .map(|i| draw::polar(c, c, r, at(floor + (ceil - floor) * f64::from(i) / 60.0))) + .collect(); + b.add(root, ui::strokes("arc", &[arc], 1.4, draw::solid(INK))); + let hot: Vec<(f64, f64)> = (0..=16) + .map(|i| draw::polar(c, c, r + 4.0, at(3.0 * f64::from(i) / 16.0))) + .collect(); + b.add(root, ui::strokes("over", &[hot], 8.0, draw::solid(ORANGE))); + let numeral = Type::new(12.5, INK, 400.0).family(draw::family::SAIRA); + for (db, s) in [ + (-20.0, "-20"), + (-10.0, "10"), + (-7.0, "7"), + (-5.0, "5"), + (-3.0, "3"), + (-2.0, "2"), + (-1.0, "1"), + (0.0, "0"), + (1.0, "1"), + (2.0, "2"), + (3.0, "3+"), + ] { + let a = at(db); + let col = if db >= 0.0 { ORANGE } else { INK }; + b.add( + root, + ui::strokes( + "tick", + &[vec![draw::polar(c, c, r, a), draw::polar(c, c, r + 9.0, a)]], + 1.6, + draw::solid(col), + ), + ); + let (x, y) = draw::polar(c, c, r + 19.0, a); + parts::centred( + b, + root, + s, + (x, y), + Type { + color: col, + ..numeral + }, + ); + } + // The inner percentage scale: 100 % = 0 VU, placed by the same law. + for pc in [20.0_f64, 40.0, 60.0, 80.0, 100.0] { + let db = 20.0 * (pc / 100.0).log10(); + let a = at(db); + b.add( + root, + ui::strokes( + "pc tick", + &[vec![ + draw::polar(c, c, r - 2.0, a), + draw::polar(c, c, r - 6.0, a), + ]], + 1.0, + draw::solid(INK), + ), + ); + let (x, y) = draw::polar(c, c, r - 13.0, a); + let label = if pc >= 100.0 { + "100%".to_string() + } else { + format!("{pc:.0}") + }; + parts::centred(b, root, &label, (x, y), Type::new(6.5, INK, 500.0)); + } + parts::centred( + b, + root, + "VU", + (c, c - r * 0.5), + Type::new(24.0, INK, 400.0) + .family(draw::family::SAIRA) + .width(85.0), + ); + // The needle, drawn up at mid travel, turning about the pivot. + let needle = b.add(root, draw::frame("needle", 0.0, 0.0, side, side)); + b.add( + needle, + draw::with_effects( + draw::rect( + "blade", + c - 1.2, + c - r - 4.0, + 2.4, + r + 4.0, + draw::solid("#1a1a1c"), + ), + vec![draw::drop_shadow("#00000040", 2.0, 3.0, 2.0)], + ), + ); + let base = draw::rounded( + draw::rect( + "pivot cover", + c - 16.0, + c - 18.0, + 32.0, + 46.0, + draw::linear( + 90.0, + &[(0.0, "#2a2a2c"), (0.5, "#3c3c40"), (1.0, "#141416")], + ), + ), + 12.0, + ); + b.add( + root, + draw::with_effects(base, vec![draw::drop_shadow("#00000060", 0.0, 3.0, 4.0)]), + ); + let mut binding = Binding::stream("echo/in"); + binding.curve = card.curve(); + let mut ctl = Control::new(ControlKind::Gauge { + sweep_degrees: sweep, + floor_db: floor, + ceiling_db: ceil, + }) + .slot(SlotRole::Needle, needle.0); + ctl.binding = Some(binding); + ctl.reference_dbfs = Some(frame::surface::proto::VU_REFERENCE_DBFS); + b.controls.insert(root, ctl); +} + +fn screws(b: &mut Builder<'_>, parent: NodeId, pts: &[(f64, f64)]) { + for (x, y) in pts { + b.add( + parent, + draw::circle( + "screw", + *x, + *y, + 6.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#f4f4f4"), (1.0, "#8a8b8d")]), + ), + ); + b.add( + parent, + draw::rect("cross", x - 4.0, y - 0.6, 8.0, 1.2, draw::solid("#3a3a3c")), + ); + b.add( + parent, + draw::rect("cross", x - 0.6, y - 4.0, 1.2, 8.0, draw::solid("#3a3a3c")), + ); + } +} + +fn glow_key( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + d: f64, + at: (f64, f64), + color: &str, + momentary: bool, +) -> NodeId { + let mut opts = vec![format!("color={color}")]; + if momentary { + opts.push("momentary".into()); + } + let p = Place { + options: Some(opts), + ..Place::new(addr) + .size(d) + .bind(ui::bind(addr)) + .touch(TOUCH_MIN) + }; + parts::part(b, parent, "button.glow_key", p, &["bezel"], at) +} + +pub(super) fn echo() -> Face { + let (w, h) = (RACK_W, 350.0); + let grey = [(0.0, "#e6e7e8"), (1.0, "#cfd0d2")]; + let scene = canvas("Echo", |b, root| { + parts::page(b, root, w, h); + // The top lip of the stand, and the unit. + let unit = panel( + b, + root, + "Unit", + (0.0, 0.0, w, h), + &[(0.0, "#bfc0c2"), (1.0, "#a9aaac")], + ); + let lip = draw::path( + "lip", + 0.0, + 0.0, + w, + 30.0, + draw::polygon(&[(0.0, 0.0), (w, 0.0), (w - 26.0, 28.0), (26.0, 28.0)]), + draw::linear(180.0, &[(0.0, "#d8d9db"), (1.0, "#8e8f91")]), + ); + b.add(unit, lip); + let t = Type::new(10.5, INK, 500.0).track(0.08).upper(); + // Bay 1: letters, VU, display. + let bay1 = panel(b, unit, "Bay 1", (28.0, 30.0, 354.0, 314.0), &grey); + tall_letters( + b, + bay1, + "ECHO", + 42.0, + 58.0, + 70.0, + Type::new(62.0, INK, 300.0) + .family(draw::family::SAIRA) + .width(88.0), + ); + vu(b, bay1, (212.0, 147.0), 100.0); + let disp = ui::display( + b, bay1, "Display", 118.0, 234.0, 204.0, 50.0, "#9aa0a8", "glass", + ); + ui::lit_text( + b, + disp, + "DELAY TIME", + 10.0, + 15.0, + 100.0, + 9.5, + "#c9ccd2", + TextAlign::Left, + 500.0, + ); + ui::lit_text( + b, + disp, + "TEMPO", + 104.0, + 15.0, + 92.0, + 9.5, + "#c9ccd2", + TextAlign::Right, + 500.0, + ); + ui::lit_readout( + b, + disp, + "Delay time", + ui::bind("echo/time"), + 10.0, + 36.0, + 80.0, + 9.5, + "#e8eaee", + TextAlign::Left, + ); + ui::lit_readout( + b, + disp, + "Tempo", + ui::bind("echo/bpm"), + 104.0, + 36.0, + 92.0, + 9.5, + ORANGE, + TextAlign::Right, + ); + ui::glass(b, disp); + screws(b, bay1, &[(14.0, 14.0), (14.0, 300.0)]); + // Bay 2: heads, selector, sync. + let bay2 = panel(b, unit, "Bay 2", (385.0, 30.0, 194.0, 314.0), &grey); + parts::centred( + b, + bay2, + "Active Heads", + (97.0, 30.0), + Type::new(8.0, INK, 500.0).track(0.08).upper(), + ); + parts::art( + b, + bay2, + &[ + vec![(48.0, 44.0), (48.0, 30.0), (60.0, 30.0)], + vec![(146.0, 44.0), (146.0, 30.0), (134.0, 30.0)], + ], + 1.0, + INK, + ); + for (i, x) in [54.0, 97.0, 140.0].iter().enumerate() { + parts::status_led( + b, + bay2, + &format!("echo/head{}", i + 1), + 8.0, + (*x, 54.0), + "#fffbe8", + "light", + None, + ); + } + let dots: Vec<(f64, f64)> = (0..5) + .map(|i| draw::polar(97.0, 150.0, 70.0, -110.0 + 55.0 * f64::from(i))) + .collect(); + for (x, y) in &dots { + b.add(bay2, draw::circle("detent", *x, *y, 3.4, draw::solid(INK))); + } + let arc: Vec<(f64, f64)> = (0..=30) + .map(|i| draw::polar(97.0, 150.0, 62.0, -110.0 + 220.0 * f64::from(i) / 30.0)) + .collect(); + parts::art(b, bay2, &[arc], 1.2, INK); + // The mode selector: a big white knurled dial stepping through the + // five detents (220° of travel). + let dial = KnobLook { + preset: "white_knurl", + opts: &[ + "color=#eeede9", + "index=none", + "diamond=0", + "ribs=90", + "knurl_band=0.5", + "rib_depth=0.02", + "cap_r=0.97", + "height=0.5", + "dome=0.03", + "edge=0.1", + "ticks=none", + "fallback=knurled", + ], + }; + let s = look::place_knob( + b, + bay2, + &dial, + "echo/mode", + "Mode", + 92.0, + (97.0, 150.0), + INK, + ); + if let Some(c) = b.controls.get_mut(&s) { + c.kind = ControlKind::Knob { + sweep_degrees: 220.0, + }; + } + parts::centred( + b, + bay2, + "Mode", + (97.0, 143.0), + Type::new(8.5, "#3a3b3e", 600.0).track(0.08).upper(), + ); + parts::centred( + b, + bay2, + "Selector", + (97.0, 156.0), + Type::new(8.5, "#3a3b3e", 600.0).track(0.08).upper(), + ); + for (x, s) in [(52.0, "Free"), (97.0, "Daw"), (142.0, "Tap")] { + parts::centred( + b, + bay2, + s, + (x, 236.0), + Type::new(8.5, INK, 500.0).track(0.06).upper(), + ); + } + parts::art( + b, + bay2, + &[ + vec![(52.0, 244.0), (52.0, 252.0), (60.0, 256.0)], + vec![(97.0, 244.0), (97.0, 252.0)], + vec![(142.0, 244.0), (142.0, 252.0), (134.0, 256.0)], + ], + 1.0, + INK, + ); + let lever = Place::new("Sync") + .size(20.0) + .bind(ui::bind("echo/sync")) + .sweep(90.0) + .options(&["up", &format!("color={ORANGE}")]) + .touch(TOUCH_MIN); + parts::part( + b, + bay2, + "switch.brass_toggle3", + lever, + &["bushing"], + (97.0, 284.0), + ); + parts::centred(b, bay2, "Sync", (97.0, 304.0), t); + // Bay 3: eight knobs and TAP. + let bay3 = panel(b, unit, "Bay 3", (581.0, 30.0, 405.0, 314.0), &grey); + let knobs: [(&str, &str, &str, f64, f64); 8] = [ + ("echo/time", "Time", "black", 65.0, 88.0), + ("echo/feedback", "Feedback", "black", 161.0, 88.0), + ("echo/tape_mod", "Tape-Mod", "black", 247.0, 88.0), + ("echo/mix", "Mix", "black", 332.0, 88.0), + ("echo/drive", "Tape Drive", "black", 65.0, 176.0), + ("echo/bass", "Bass", "orange", 161.0, 176.0), + ("echo/treble", "Treble", "orange", 247.0, 176.0), + ("echo/width", "Width", "black", 332.0, 176.0), + ]; + let black = KnobLook { + preset: "knurled", + opts: &[ + "color=#1c1c1e", + "pointer=#ee8a1e", + "index=dot", + "cap_r=0.9", + "height=0.6", + "dome=0.08", + "edge=0.1", + "ribs=48", + "rib_depth=0.03", + "knurl_band=0.7", + "index_w=0.09", + "index_in=0.66", + "index_out=0.66", + "coat=0.5", + "rough=0.25", + "ticks=none", + ], + }; + let orange = KnobLook { + preset: "knurled", + opts: &[ + "color=#ee8a1e", + "pointer=#ffd9a0", + "index=dot", + "cap_r=0.9", + "height=0.6", + "dome=0.05", + "edge=0.08", + "ribs=24", + "rib_depth=0.06", + "knurl_band=1", + "index_w=0.08", + "index_in=0.66", + "index_out=0.66", + "coat=0.4", + "rough=0.3", + "ticks=none", + ], + }; + for (addr, legend, v, x, y) in knobs { + let look = if v == "orange" { &orange } else { &black }; + look::knob(b, bay3, look, addr, 42.0, (x, y), 11, INK, false); + parts::centred( + b, + bay3, + legend, + (x, y + 42.0), + Type::new(9.0, INK, 500.0).track(0.08).upper(), + ); + } + glow_key(b, bay3, "echo/tap", 34.0, (74.0, 262.0), "#fff2c0", true); + parts::centred(b, bay3, "Tap", (74.0, 304.0), t); + // Bay 4: mark, emblem, power. + let bay4 = panel(b, unit, "Bay 4", (988.0, 30.0, 124.0, 314.0), &grey); + look::brush_mark(b, bay4, (62.0, 62.0), 26.0, "#8a8c90"); + look::emblem(b, bay4, (62.0, 176.0), 22.0, INK); + parts::status_led( + b, + bay4, + "echo/on", + 7.0, + (62.0, 218.0), + "#fffbe8", + "light", + None, + ); + glow_key(b, bay4, "echo/on", 34.0, (62.0, 262.0), ORANGE, false); + parts::centred(b, bay4, "Power", (62.0, 304.0), t); + screws(b, bay4, &[(110.0, 14.0), (110.0, 300.0)]); + }); + let params = vec![ + // Every knob at noon, as the reference shows them. + parts::pick("echo/time", "Delay Time", DIVS, 1), + parts::pct("echo/feedback", "Feedback", 50.0), + parts::pct("echo/tape_mod", "Tape-Mod", 50.0), + parts::pct("echo/mix", "Mix", 50.0), + parts::pct("echo/drive", "Tape Drive", 50.0), + parts::db("echo/bass", "Bass", -12.0, 12.0, 0.0), + parts::db("echo/treble", "Treble", -12.0, 12.0, 0.0), + parts::pct("echo/width", "Width", 50.0), + parts::pick("echo/mode", "Mode", &["1", "2", "3", "1+2", "1+2+3"], 0), + parts::pick("echo/sync", "Sync", &["Free", "DAW", "Tap"], 1), + ui::param( + "echo/bpm", + "Tempo", + frame::surface::proto::Range::linear(40.0, 240.0, frame::surface::proto::Unit::Bpm), + 120.0, + ) + .featured(), + parts::switch("echo/head1", "Head 1", true), + parts::switch("echo/head2", "Head 2", false), + parts::switch("echo/head3", "Head 3", false), + parts::switch("echo/tap", "Tap", false), + parts::switch("echo/on", "Power", true), + ]; + look::face_with( + "10-echo-rack-1140x350", + scene, + params, + vec![ui::level_stream("echo/in", "Input", 2)], + ) +} + +/// A thin horizontal slider drawn as a display bar: its `Fill` grows with +/// the param. +fn bar(b: &mut Builder<'_>, parent: NodeId, addr: &str, (x, cy): (f64, f64), w: f64) { + let h = 8.0; + let root = b.add( + parent, + draw::frame(&format!("{addr} bar"), x, cy - h / 2.0, w, h), + ); + b.add( + root, + draw::stroked( + draw::rect("frame", 0.0, 0.0, w, h, draw::solid("#00000000")), + "#e8eaee", + 1.0, + ), + ); + let fill = b.add( + root, + ui::glowing( + draw::rect("fill", 1.5, 1.5, w - 3.0, h - 3.0, draw::solid("#e8eaee")), + "#e8eaee", + 0.3, + 3.0, + ), + ); + let mut c = Control::new(ControlKind::Fader { + orientation: Orientation::Horizontal, + }) + .slot(SlotRole::Fill, fill.0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(root, c); +} + +pub(super) fn reverb() -> Face { + let (w, h) = (RACK_W, 210.0); + let dark = [(0.0, "#737476"), (1.0, "#5c5d60")]; + let scene = canvas("Reverb", |b, root| { + parts::page(b, root, w, h); + let unit = panel( + b, + root, + "Unit", + (0.0, 0.0, w, h), + &[(0.0, "#58595c"), (1.0, "#46474a")], + ); + let pale = "#d9dadc"; + let t = Type::new(9.5, pale, 500.0).track(0.08).upper(); + let bay1 = panel(b, unit, "Bay 1", (28.0, 6.0, 551.0, 198.0), &dark); + tall_letters( + b, + bay1, + "REVERB", + 42.0, + 38.0, + 26.0, + Type::new(21.0, pale, 400.0) + .family(draw::family::SAIRA) + .width(88.0), + ); + let disp = ui::display( + b, bay1, "Display", 118.0, 26.0, 300.0, 80.0, "#9aa0a8", "glass", + ); + let lit = "#e8eaee"; + ui::lit_text( + b, + disp, + "MODE", + 12.0, + 12.0, + 60.0, + 8.0, + "#b9bcc2", + TextAlign::Left, + 500.0, + ); + ui::lit_readout( + b, + disp, + "Mode", + ui::bind("verb/mode"), + 12.0, + 32.0, + 90.0, + 13.0, + lit, + TextAlign::Left, + ); + ui::lit_text( + b, + disp, + "MIX", + 12.0, + 62.0, + 30.0, + 8.0, + "#b9bcc2", + TextAlign::Left, + 500.0, + ); + bar(b, disp, "verb/mix", (38.0, 62.0), 40.0); + ui::lit_readout( + b, + disp, + "Mix", + ui::bind("verb/mix"), + 80.0, + 62.0, + 34.0, + 8.0, + lit, + TextAlign::Left, + ); + let rows = [ + ("PRE-DELAY", "verb/predelay", 14.0), + ("DECAY", "verb/decay", 38.0), + ]; + for (label, addr, y) in rows { + ui::lit_text( + b, + disp, + label, + 124.0, + y, + 80.0, + 8.0, + "#b9bcc2", + TextAlign::Left, + 500.0, + ); + bar(b, disp, addr, (206.0, y), 40.0); + ui::lit_readout( + b, + disp, + label, + ui::bind(addr), + 250.0, + y, + 46.0, + 8.0, + lit, + TextAlign::Left, + ); + } + ui::lit_text( + b, + disp, + "FILTER", + 124.0, + 62.0, + 40.0, + 8.0, + "#b9bcc2", + TextAlign::Left, + 500.0, + ); + let low = ui::lit_readout( + b, + disp, + "Low cut", + ui::bind("verb/low"), + 158.0, + 62.0, + 46.0, + 8.0, + lit, + TextAlign::Left, + ); + look::set_format(b, low, ValueFormat::default().mark(20.0, "OFF")); + bar(b, disp, "verb/high", (206.0, 62.0), 40.0); + let high = ui::lit_readout( + b, + disp, + "High cut", + ui::bind("verb/high"), + 250.0, + 62.0, + 46.0, + 8.0, + lit, + TextAlign::Left, + ); + look::set_format(b, high, ValueFormat::default().mark(20_000.0, "OFF")); + ui::glass(b, disp); + look::brush_mark(b, bay1, (180.0, 162.0), 28.0, ORANGE); + parts::centred(b, bay1, "Hall", (476.0, 34.0), t); + parts::centred(b, bay1, "Plate", (476.0, 112.0), t); + let tg = Place::new("Hall / Plate") + .size(20.0) + .variant("black") + .bind(ui::bind("verb/mode")) + .legend("") + .options(&["legend_right"]) + .touch(TOUCH_MIN); + parts::part( + b, + bay1, + "switch.toggle_nut", + tg, + &["bushing"], + (476.0, 73.0), + ); + // The tone pointer glyph. + b.add( + bay1, + draw::circle("dot", 520.0, 172.0, 7.0, draw::solid(pale)), + ); + b.add( + bay1, + draw::circle("dot", 506.0, 172.0, 3.5, draw::solid(pale)), + ); + // Faders. + let bay2 = panel(b, unit, "Bay 2", (581.0, 6.0, 405.0, 198.0), &dark); + let faders = [ + ("verb/predelay", "Pre-Delay", "#ece6dc", 74.0), + ("verb/decay", "Decay", "#ece6dc", 140.0), + ("verb/low", "", ORANGE, 207.0), + ("verb/high", "", ORANGE, 274.0), + ("verb/mix", "Mix", "#ece6dc", 335.0), + ]; + for (addr, legend, color, x) in faders { + let p = Place { + options: Some(vec![format!("color={color}")]), + ..Place::new(addr).size(118.0).bind(ui::bind(addr)) + }; + let f = frame::surface::kit::place(b, bay2, "lever.pill", &p) + .unwrap_or_else(|e| panic!("{e}")); + parts::centre_on(b, f, (x, 88.0)); + if legend.is_empty() { + let lo = addr.ends_with("low"); + let pts = if lo { + vec![(x - 9.0, 180.0), (x - 5.0, 174.0), (x + 9.0, 174.0)] + } else { + vec![(x - 9.0, 174.0), (x + 5.0, 174.0), (x + 9.0, 180.0)] + }; + parts::art(b, bay2, &[pts], 1.4, pale); + } else { + parts::centred(b, bay2, legend, (x, 178.0), t); + } + } + let bay3 = panel(b, unit, "Bay 3", (988.0, 6.0, 124.0, 198.0), &dark); + look::signature(b, bay3, (62.0, 48.0), 17.0, "#aeb0b4"); + parts::status_led( + b, + bay3, + "verb/on", + 7.0, + (62.0, 98.0), + "#fffbe8", + "dark", + None, + ); + glow_key(b, bay3, "verb/on", 32.0, (62.0, 136.0), ORANGE, false); + parts::centred(b, bay3, "Power", (62.0, 178.0), t); + }); + let params = vec![ + parts::pick("verb/mode", "Mode", &["Plate", "Hall"], 0), + parts::ms("verb/predelay", "Pre-Delay", 1.0, 500.0, 200.0), + ui::param( + "verb/decay", + "Decay", + frame::surface::proto::Range::log(0.2, 20.0, frame::surface::proto::Unit::Seconds), + 2.0, + ) + .featured(), + parts::hz("verb/low", "Low Cut", 20.0, 1000.0, 150.0), + parts::hz("verb/high", "High Cut", 1000.0, 20000.0, 3700.0), + parts::pct("verb/mix", "Mix", 75.0), + parts::switch("verb/on", "Power", true), + ]; + look::face("11-reverb-rack-1140x210", scene, params) +} diff --git a/examples/plugin-faces/src/suite_djent_forge.rs b/examples/plugin-faces/src/suite_djent_forge.rs new file mode 100644 index 00000000..c95783dd --- /dev/null +++ b/examples/plugin-faces/src/suite_djent_forge.rs @@ -0,0 +1,129 @@ +//! Suite: **Djent Forge** (`suite-djent-forge`) — a modern progressive +//! metal amp-sim suite recreated unit by unit as frame surfaces, drawn +//! after the real plugin's in-window screenshots. +//! +//! | file | unit | form | +//! |---|---|---| +//! | `01-pre-standard-compressor` | Skyline Compressor: silver powder coat, blue lettering, GR dot meter | Pedal Standard 156 × 286 | +//! | `02-pre-standard-tape-drift` | Tape Drift echo: sage green, LED time readout, MS/NOTE toggle | Pedal Standard | +//! | `03-pre-standard-twin-octave` | Twin Octave: champagne, OCT−1 / OCT+1 / DIRECT | Pedal Standard | +//! | `04-pre-standard-tight-drive` | Tight Drive: navy sparkle, lit GATE dial | Pedal Standard | +//! | `05-pre-micro-havoc` | Havoc: oxblood micro box, one MIX knob | Pedal Micro 92 × 286 | +//! | `06-amp-head-ridge-clean` | Ridge Clean: black head, blue-lit ridge | Head 1200 × 507 | +//! | `07-amp-head-mech-rhythm` | Mech Rhythm: white machined head, orange-lit core | Head 1200 × 551 | +//! | `08-amp-head-slat-lead` | Slat Lead: black slatted cage over lit valves | Head 1200 × 595 | +//! | `09-cab-2x12` | Stack cab (2×2 speakers) with two mics and the mic / room controls | Cab 900 × 900 | +//! | `10-eq-tabletop-nine-band` | Ivory Nine-Band graphic EQ | Tabletop 1000 × 290 | +//! | `11-post-wide-treadle-modulator` | Treadle Modulator: rose-gold rocker + range display | Pedal Wide 222 × 286 | +//! | `12-post-bigbox-stereo-echo` | Stereo Echo: twin 16-segment readouts, ENGAGE / TAP | Pedal BigBox 404 × 286 | +//! | `13-post-standard-shimmer-verb` | Shimmer Verb: midnight blue, SHIMMER / NORMAL | Pedal Standard | +//! | `14-fx-wide-treadle-beam` | Beam pitch treadle with a patch list | Pedal Wide 222 × 286 | +//! | `15-fx-tabletop-fracture` | Fracture grain glitch: waveform screen, modes, grain dials | Tabletop 800 × 479 | +//! +//! Real names are never printed: every unit carries an invented name set in +//! the original's lettering style, and the maker's mark is an invented +//! one ("AZIMUTH circuits"). + +pub mod amps; +pub mod post; +pub mod pre; +pub mod rack; + +use crate::face::Face; + +/// The suite's plugin id. +pub const PLUGIN: &str = "suite-djent-forge"; + +/// The blue the pedals' lettering is printed in. +pub const BLUE: &str = "#1f5fa3"; +/// The lit blue of the pedals' status LEDs. +pub const LED_BLUE: &str = "#7cc4ff"; + +/// The component every knob of the suite is placed as: the GPU-rendered +/// `knob.3d` (its vector fallback is `knob.render`). +pub const KNOB_ID: &str = "knob.3d"; + +/// The pedals' knob: a tall glossy black cap of seven deep flutes, a white +/// index line from the middle of its top toward the edge, `d` across. +#[must_use] +pub const fn pedal_knob(d: f64) -> rk::KnobStyle<'static> { + rk::KnobStyle::new(KNOB_ID, "fluted", d, "#ffffff").opts(PEDAL_KNOB) +} + +/// [`pedal_knob`]'s `knob.3d` options. +pub const PEDAL_KNOB: &[&str] = &[ + "color=#0b0b0c", + "pointer=#f4f4f2", + "ticks=none", + "ribs=7", + "rib_depth=0.11", + "cap_r=0.97", + "height=0.6", + "dome=0.1", + "edge=0.16", + "rough=0.3", + "coat=0.45", + "spec=1.0", + "index=line", + "index_w=0.09", + "index_in=0.12", + "index_out=0.72", +]; + +/// A knob of the suite in any `knob.3d` preset, colour and extra options +/// (`color=`, `profile=`, `skirt=`, `index=`, `ticks=`, …). +#[must_use] +pub const fn knob( + cap: &'static str, + d: f64, + ink: &'static str, + opts: &'static [&'static str], +) -> rk::KnobStyle<'static> { + rk::KnobStyle::new(KNOB_ID, cap, d, ink).opts(opts) +} + +/// The suite's lettering. +pub mod type_ { + use crate::rigkit::Letter; + use frame::surface::kit::draw::family; + + /// Model names across a pedal: wide engraved capitals, widely spaced. + #[must_use] + pub const fn name(size: f64, color: &str) -> Letter<'_> { + Letter::new(size, 400.0, color) + .family(family::MICHROMA) + .track(0.14) + } + /// Control legends: a semi-condensed bold technical sans. + #[must_use] + pub const fn legend(size: f64, color: &str) -> Letter<'_> { + Letter::new(size, 650.0, color) + .family(family::SAIRA) + .stretch(0.92) + .track(0.02) + } + /// The maker's mark: heavy geometric tech capitals. + #[must_use] + pub const fn mark(size: f64, color: &str) -> Letter<'_> { + Letter::new(size, 900.0, color).family(family::ORBITRON) + } + /// Fine print ("designed in …"): the engraved face, small and spaced. + #[must_use] + pub const fn fine(size: f64, color: &str) -> Letter<'_> { + Letter::new(size, 400.0, color) + .family(family::MICHROMA) + .track(0.2) + } +} + +use crate::rigkit as rk; + +/// Every unit of the suite, in signal order. +#[must_use] +pub fn faces() -> Vec { + let mut v = pre::faces(); + v.extend(amps::faces()); + v.extend(rack::faces()); + v.extend(post::faces()); + v +} diff --git a/examples/plugin-faces/src/suite_djent_forge/amps.rs b/examples/plugin-faces/src/suite_djent_forge/amps.rs new file mode 100644 index 00000000..8780e69b --- /dev/null +++ b/examples/plugin-faces/src/suite_djent_forge/amps.rs @@ -0,0 +1,1483 @@ +//! Djent Forge amps: three heads, each drawn straight on as the plugin's +//! amp page shows it — the faceplate *is* the control surface. Form: Head +//! (1200 wide; the height follows each head's own aspect, handle and feet +//! included). +//! +//! - **Ridge Clean** (1200 × 507): a black tolex head, a brushed dark grille +//! over a recess lit blue from below round a dark pyramid ridge; a black +//! strip with chrome end bars, BOOST and VOICE toggles, seven ticked +//! knobs, a power toggle with three white pilot dots. +//! - **Mech Rhythm** (1200 × 551): a white machined head with engraved +//! panel lines and an orange-lit mechanism (a turbine, a belt, pulleys) +//! showing through a cut-out; white cup knobs with orange index dots and +//! an orange power rocker. +//! - **Slat Lead** (1200 × 595): a dark cast head with a cage of vertical +//! slats over glowing valves; flat slotted knobs with their names printed +//! on them and a square orange power button. + +use frame::document::proto::NodeId; +use frame::surface::kit::{Place, TOUCH_MIN, draw, motion}; +use frame::surface::proto::{ButtonMode, Control, ControlKind, SlotRole}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, KnobStyle}; + +pub fn faces() -> Vec { + vec![ridge_clean(), mech_rhythm(), slat_lead()] +} + +/// The seven controls every head carries, in panel order. +const TONE_STACK: [(&str, &str, &str); 7] = [ + ("gain", "Gain", "Gain"), + ("bass", "Bass", "Bass"), + ("middle", "Middle", "Mid"), + ("treble", "Treble", "Treble"), + ("presence", "Presence", "Pres"), + ("master", "Master", "Master"), + ("output", "Output", "Output"), +]; + +fn tone_params(ns: &str) -> Vec { + let defaults = [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]; + TONE_STACK + .iter() + .zip(defaults) + .map(|((addr, name, _), d)| { + let p = rk::dial10(&format!("{ns}/{addr}"), name, d); + if matches!(*addr, "gain" | "master") { + p.featured() + } else { + p + } + }) + .collect() +} + +// ── shared hardware ────────────────────────────────────────────────────── + +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as f64 / f64::from(u32::MAX >> 1) + } +} + +/// A scatter of fine specks over (`x, y, w, h`): a cast or tolex surface's +/// tooth. +fn specks( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + seed: u64, + density: f64, + light: f64, + dark: f64, +) { + let mut rng = Lcg(seed); + for (c, a) in [("#ffffff", light), ("#000000", dark)] { + if a <= 0.0 { + continue; + } + let mut d = String::new(); + for _ in 0..((w * h) / density) as usize { + let (px, py) = (x + rng.next() * w, y + rng.next() * h); + let s = 0.5 + rng.next() * 0.9; + d.push_str(&format!( + "M {px:.1} {py:.1} h {s:.1} v {s:.1} h {:.1} Z ", + -s + )); + } + b.add( + parent, + draw::path( + "specks", + 0.0, + 0.0, + x + w, + y + h, + d, + draw::solid(&rk::hexa(c, a)), + ), + ); + } +} + +/// Brushed streaks running down (`x, y, w, h`). +fn brushed( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + seed: u64, + alpha: f64, +) { + let mut rng = Lcg(seed); + for (c, a, step) in [ + ("#ffffff", alpha, 2.2), + ("#000000", alpha * 1.3, 3.1), + ("#ffffff", alpha * 0.7, 9.0), + ] { + let mut d = String::new(); + let mut px = x + rng.next() * step; + while px < x + w { + let t = 0.4 + rng.next() * 0.9; + let (y0, y1) = (y + rng.next() * h * 0.3, y + h - rng.next() * h * 0.3); + d.push_str(&format!( + "M {px:.1} {y0:.1} h {t:.1} V {y1:.1} h {:.1} Z ", + -t + )); + px += step * (0.5 + rng.next()); + } + b.add( + parent, + draw::path( + "brushing", + 0.0, + 0.0, + x + w, + y + h, + d, + draw::solid(&rk::hexa(c, a)), + ), + ); + } +} + +/// A panel screw head. +fn screw(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64), r: f64, light: bool) { + let (hi, lo) = if light { + ("#f4f5f6", "#8e9196") + } else { + ("#77797d", "#141416") + }; + b.add( + parent, + draw::with_effects( + draw::circle( + "screw", + cx, + cy, + r, + draw::radial_at(0.38, 0.32, 0.9, &[(0.0, hi), (1.0, lo)]), + ), + vec![draw::drop_shadow("#00000066", 0.0, 0.6, 1.0)], + ), + ); + b.add( + parent, + draw::circle( + "screw hole", + cx, + cy, + r * 0.42, + draw::solid(if light { "#55585d" } else { "#050506" }), + ), + ); +} + +/// An input jack: a chrome nut and the plug's black barrel, and its cable +/// hanging off the panel's lower left. +fn jack_and_cable( + b: &mut Builder<'_>, + parent: NodeId, + (cx, cy): (f64, f64), + r: f64, + (w, h): (f64, f64), + cable: &str, + light_cable: bool, +) { + let nut = draw::circle( + "jack nut", + cx, + cy, + r, + draw::conic( + 20.0, + &[ + (0.0, "#f2f2f0"), + (0.25, "#77787a"), + (0.5, "#e2e2e0"), + (0.75, "#5f6062"), + (1.0, "#f2f2f0"), + ], + ), + ); + b.add( + parent, + draw::with_effects(nut, vec![draw::drop_shadow("#00000080", 0.0, 1.5, 2.5)]), + ); + b.add( + parent, + draw::circle( + "jack barrel", + cx, + cy, + r * 0.62, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#6d6e72"), (1.0, "#101012")]), + ), + ); + // The cable: a heavy line out and down, with a lighter core. + let pts = [ + (cx, cy), + (cx - r * 0.6, cy + r * 3.0), + (cx - r * 2.4, cy + r * 5.2), + (cx - r * 5.5, h - 2.0), + ]; + rk::curve(b, parent, "cable", (w, h), &pts, r * 0.95, cable); + rk::curve( + b, + parent, + "cable sheen", + (w, h), + &pts, + r * 0.22, + if light_cable { + "#ffffff66" + } else { + "#ffffff26" + }, + ); +} + +/// A foot under the head: a turned cylinder. +fn foot(b: &mut Builder<'_>, parent: NodeId, (x, y, w, h): (f64, f64, f64, f64), cone: bool) { + if cone { + let d = format!( + "M {x:.1} {y:.1} L {:.1} {y:.1} L {:.1} {:.1} L {:.1} {:.1} Z", + x + w, + x + w * 0.58, + y + h, + x + w * 0.42, + y + h + ); + b.add( + parent, + draw::path( + "foot", + 0.0, + 0.0, + x + w, + y + h, + d, + draw::linear( + 90.0, + &[(0.0, "#0c0c0d"), (0.5, "#3a3b3e"), (1.0, "#0c0c0d")], + ), + ), + ); + } else { + b.add( + parent, + draw::rounded( + draw::rect( + "foot", + x, + y, + w, + h, + draw::linear( + 90.0, + &[ + (0.0, "#1a1b1d"), + (0.35, "#8a8b8e"), + (0.55, "#3e3f42"), + (1.0, "#141516"), + ], + ), + ), + 2.0, + ), + ); + } +} + +/// A two-position mini toggle for a head (chrome bat on a nut), its legend +/// above at `ly`. +#[allow(clippy::too_many_arguments)] +fn head_toggle( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + size: f64, + legend: Option<(f64, Ink<'_>)>, +) -> NodeId { + let t = rk::mini_toggle(b, parent, name, addr, (cx, cy), size, "chrome"); + if let Some((ly, ink)) = legend { + rk::label(b, t, name, (cx, ly), ink); + } + t +} + +// ── 06 · Ridge Clean ───────────────────────────────────────────────────── + +/// Ridge Clean — Head, 1200 × 507. See the module docs. +#[allow(clippy::too_many_lines)] +fn ridge_clean() -> Face { + let ns = "dfclean"; + let a = |s: &str| format!("{ns}/{s}"); + let (w, h) = (1200.0, 507.0); + let scene = canvas("Ridge Clean", |b, root| { + parts::fit_root(b, root, w, h); + // Handle. + let hd = "M 433 31 L 445 12 Q 452 3 464 3 L 736 3 Q 748 3 755 12 L 767 31 L 748 31 Q 740 20 730 20 L 470 20 Q 460 20 452 31 Z"; + b.add( + root, + draw::with_effects( + draw::path( + "handle", + 0.0, + 0.0, + w, + h, + hd.into(), + draw::linear(180.0, &[(0.0, "#3a3b3e"), (1.0, "#0f1011")]), + ), + vec![draw::drop_shadow("#00000080", 0.0, 2.0, 4.0)], + ), + ); + // Feet, then the cabinet. + foot(b, root, (80.0, 474.0, 36.0, 30.0), true); + foot(b, root, (1084.0, 474.0, 36.0, 30.0), true); + let cab = draw::with_effects( + draw::rounded( + draw::rect( + "cabinet", + 3.0, + 31.0, + 1194.0, + 444.0, + draw::linear( + 180.0, + &[(0.0, "#2b2c2f"), (0.5, "#1c1d1f"), (1.0, "#141416")], + ), + ), + 24.0, + ), + vec![ + draw::drop_shadow("#00000099", 0.0, 8.0, 18.0), + draw::inner_shadow("#ffffff1c", 0.0, 2.0, 2.0), + ], + ); + b.add(root, draw::stroked(cab, "#050506", 1.5)); + specks( + b, + root, + (10.0, 36.0, 1180.0, 432.0), + 0xc1ea, + 60.0, + 0.03, + 0.10, + ); + // The front recess. + let front = parts::well( + b, + root, + "front", + (35.0, 63.0, 1130.0, 379.0), + draw::solid("#0d0e10"), + 6.0, + ); + // Upper grille: brushed dark metal. + b.add( + front, + draw::rect( + "grille", + 0.0, + 0.0, + 1130.0, + 140.0, + draw::linear(180.0, &[(0.0, "#2e2f33"), (1.0, "#1f2023")]), + ), + ); + brushed(b, front, (0.0, 0.0, 1130.0, 140.0), 0xb12e, 0.05); + b.add( + front, + draw::rect( + "grille edge", + 0.0, + 139.0, + 1130.0, + 2.0, + draw::solid("#0a0a0b"), + ), + ); + // The lit recess: blue light rising from below behind the ridge. + let glow = draw::rect( + "backlight", + 0.0, + 141.0, + 1130.0, + 132.0, + draw::linear( + 180.0, + &[ + (0.0, "#101216"), + (0.45, "#151b25"), + (0.8, "#23364f"), + (1.0, "#5a88c0"), + ], + ), + ); + let glow = b.add(front, parts::emit(glow, "#5c8fd0", 0.45, 30.0)); + motion::add(b, glow, motion::pulse(0.25, 0.8)); + brushed(b, front, (0.0, 141.0, 1130.0, 132.0), 0xb13e, 0.035); + // The ridge: a dark pyramid, its edges lit. + let (ax, ay, bl, br, by) = (565.0, 144.0, 295.0, 835.0, 273.0); + let ridge = format!("M {ax} {ay} L {br} {by} L {bl} {by} Z"); + b.add( + front, + draw::path( + "ridge", + 0.0, + 0.0, + 1130.0, + 379.0, + ridge, + draw::linear(180.0, &[(0.0, "#2a2b2e"), (1.0, "#121315")]), + ), + ); + let edge = parts::lines_node( + "ridge edge", + &[vec![(bl, by), (ax, ay), (br, by)]], + 1.6, + "#d5e6ff", + ); + b.add(front, parts::emit(edge, "#8fbaf0", 0.9, 10.0)); + // The control strip, with chrome end bars. + let strip = draw::rect( + "strip", + 0.0, + 273.0, + 1130.0, + 106.0, + draw::linear(180.0, &[(0.0, "#1e1f22"), (1.0, "#141517")]), + ); + b.add( + front, + draw::with_effects(strip, vec![draw::inner_shadow("#ffffff14", 0.0, 1.0, 1.0)]), + ); + specks( + b, + front, + (0.0, 273.0, 1130.0, 106.0), + 0x57a1, + 40.0, + 0.03, + 0.08, + ); + for x in [26.0, 1085.0] { + b.add( + front, + draw::rect( + "end bar", + x, + 273.0, + 19.0, + 106.0, + draw::linear( + 90.0, + &[ + (0.0, "#6b6d71"), + (0.4, "#d8dadd"), + (0.6, "#9a9ca0"), + (1.0, "#55575b"), + ], + ), + ), + ); + } + // Controls, in root coordinates (the strip's middle at y 393). + let ink = Ink::new(8.2, "#e9eaec").weight(700.0).track(0.12); + let cy = 393.0; + jack_and_cable(b, root, (138.0, cy), 13.0, (w, h), "#1c1d20", false); + head_toggle( + b, + root, + "Boost", + &a("boost"), + (190.0, cy), + 16.0, + Some((362.0, ink)), + ); + head_toggle( + b, + root, + "Voice", + &a("voice"), + (243.0, cy), + 16.0, + Some((362.0, ink)), + ); + let style = super::knob( + "flat", + 38.0, + "#d8d9dc", + &[ + "color=#17181b", + "ticks=lines", + "skirt=none", + "cap_r=0.92", + "height=0.55", + "edge=0.06", + "rough=0.35", + "coat=0.3", + "pointer=#b9bbbf", + "index=line", + "index_w=0.07", + "index_in=0.62", + "index_out=0.96", + ], + ); + for (i, (addr, name, _)) in TONE_STACK.iter().enumerate() { + let x = 323.0 + 92.3 * i as f64; + rk::knob_lt(b, root, &style, &a(addr), name, (x, cy + 2.0), 355.0, ink); + } + // Power, with its three pilot dots. + head_toggle( + b, + root, + "Power", + &a("power"), + (1027.0, cy), + 16.0, + Some((362.0, ink)), + ); + for (dx, dy) in [(0.0, -4.0), (-4.0, 3.0), (4.0, 3.0)] { + rk::led(b, root, (1002.0 + dx, cy + dy), 4.2, "#f4f7ff", true); + } + }); + let mut params = vec![ + rk::toggle(&a("boost"), "Boost", false), + rk::toggle(&a("voice"), "Voice", false), + ]; + params.extend(tone_params(ns)); + params.push(rk::toggle(&a("power"), "Power", true).featured()); + Face { + file: "06-amp-head-ridge-clean", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 07 · Mech Rhythm ───────────────────────────────────────────────────── + +/// Mech Rhythm — Head, 1200 × 551. See the module docs. +#[allow(clippy::too_many_lines)] +fn mech_rhythm() -> Face { + let ns = "dfrhythm"; + let a = |s: &str| format!("{ns}/{s}"); + let (w, h) = (1200.0, 551.0); + const ORANGE: &str = "#ff7a2e"; + let scene = canvas("Mech Rhythm", |b, root| { + parts::fit_root(b, root, w, h); + // The handle: a flat bent strap. + let hd = "M 405 30 L 420 12 Q 426 4 438 4 L 545 4 L 556 10 L 644 10 L 655 4 L 762 4 Q 774 4 780 12 L 795 30 L 783 30 L 770 16 L 660 16 L 649 22 L 551 22 L 540 16 L 430 16 L 417 30 Z"; + b.add( + root, + draw::with_effects( + draw::stroked( + draw::path( + "handle", + 0.0, + 0.0, + w, + h, + hd.into(), + draw::linear(180.0, &[(0.0, "#fbfbfc"), (1.0, "#b9bcc2")]), + ), + "#9ea2a9", + 0.8, + ), + vec![draw::drop_shadow("#00000040", 0.0, 2.0, 3.0)], + ), + ); + foot(b, root, (90.0, 522.0, 69.0, 27.0), false); + foot(b, root, (1032.0, 522.0, 72.0, 27.0), false); + let body = draw::rounded( + draw::rect( + "cabinet", + 3.0, + 30.0, + 1194.0, + 495.0, + draw::linear( + 180.0, + &[(0.0, "#f1f2f3"), (0.5, "#e2e3e5"), (1.0, "#cfd1d4")], + ), + ), + 26.0, + ); + b.add( + root, + draw::stroked( + draw::with_effects( + body, + vec![ + draw::drop_shadow("#00000040", 0.0, 8.0, 16.0), + draw::inner_shadow("#ffffffb0", 0.0, 2.0, 1.0), + ], + ), + "#a9acb2", + 1.0, + ), + ); + // The inset face. + let face = draw::rect( + "face", + 29.0, + 57.0, + 1141.0, + 444.0, + draw::linear(180.0, &[(0.0, "#dcdde0"), (1.0, "#d2d4d7")]), + ); + b.add( + root, + draw::with_effects( + draw::rounded(face, 8.0), + vec![draw::inner_shadow("#0000002e", 0.0, 2.0, 5.0)], + ), + ); + specks( + b, + root, + (29.0, 57.0, 1141.0, 444.0), + 0x3ec4, + 45.0, + 0.12, + 0.05, + ); + // The dark cavities at the ends of the strip's shelf. + for (x0, x1) in [(29.0, 140.0), (1050.0, 1170.0)] { + b.add( + root, + draw::rect( + "cavity", + x0, + 327.0, + x1 - x0, + 66.0, + draw::linear(180.0, &[(0.0, "#2b2d31"), (1.0, "#15161a")]), + ), + ); + } + // The mechanism window, lit orange from inside. + let win = + "M 542 243 L 657 243 L 672 262 L 798 276 L 849 363 L 351 363 L 402 276 L 527 262 Z"; + let inside = draw::path( + "mechanism", + 0.0, + 0.0, + w, + h, + win.into(), + draw::radial_at( + 0.5, + 0.75, + 0.7, + &[ + (0.0, "#ffb07a"), + (0.35, ORANGE), + (0.75, "#9a3b12"), + (1.0, "#3a1a0c"), + ], + ), + ); + let lit = b.add(root, parts::emit(inside, ORANGE, 0.7, 40.0)); + motion::add(b, lit, motion::flicker(0.08)); + // The machined housings inside, the light spilling round their edges. + let steel = |a: &str, z: &str| draw::linear(180.0, &[(0.0, a), (1.0, z)]); + for (pts, a0, a1) in [ + ( + "M 410 282 L 520 270 L 540 300 L 540 363 L 372 363 Z", + "#8d9096", + "#4d5056", + ), + ( + "M 790 282 L 680 270 L 660 300 L 660 363 L 828 363 Z", + "#8d9096", + "#4d5056", + ), + ( + "M 562 243 L 638 243 L 646 300 L 554 300 Z", + "#a9acb1", + "#6d7076", + ), + ( + "M 470 350 L 730 350 L 700 363 L 500 363 Z", + "#5d6066", + "#34363a", + ), + ] { + let n = draw::path("housing", 0.0, 0.0, w, h, pts.into(), steel(a0, a1)); + b.add( + root, + draw::with_effects(n, vec![draw::drop_shadow("#ff7a2e99", 0.0, 0.0, 6.0)]), + ); + } + for (x0, x1) in [(360.0, 520.0), (680.0, 840.0)] { + rk::line(b, root, &[(x0, 292.0), (x1, 292.0)], 1.0, "#ffb27a"); + } + for bar in ["M 506 290 Q 520 266 566 262", "M 694 290 Q 680 266 634 262"] { + let mut n = draw::path("strut", 0.0, 0.0, w, h, bar.into(), draw::solid("#dcdde0")); + n.fills = Vec::new(); + n.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid("#dcdde0")], + weight: 5.0, + cap: frame::document::proto::StrokeCap::Round, + ..frame::document::proto::Stroke::default() + }; + b.add(root, n); + } + // The top notch, lit the same. + let notch = "M 480 57 L 717 57 L 690 111 L 507 111 Z"; + let n = draw::path( + "notch", + 0.0, + 0.0, + w, + h, + notch.into(), + draw::radial_at( + 0.5, + 0.2, + 0.8, + &[(0.0, "#ffa36a"), (0.5, "#c9571e"), (1.0, "#4a200d")], + ), + ); + b.add(root, parts::emit(n, ORANGE, 0.5, 20.0)); + for x in [578.0, 618.0] { + b.add( + root, + draw::circle( + "pivot", + x, + 80.0, + 9.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#e8e9eb"), (1.0, "#7d8086")]), + ), + ); + b.add( + root, + draw::circle("pivot pin", x, 80.0, 3.0, draw::solid("#3a3c40")), + ); + } + rk::line(b, root, &[(598.0, 111.0), (598.0, 243.0)], 1.2, "#e88a4e"); + // The belt, its pulleys, the turbine. + b.add( + root, + draw::rounded( + draw::rect( + "belt", + 384.0, + 300.0, + 426.0, + 48.0, + draw::linear( + 180.0, + &[(0.0, "#c7c9cd"), (0.5, "#9c9fa5"), (1.0, "#6d7076")], + ), + ), + 24.0, + ), + ); + for x in [410.0, 786.0] { + b.add( + root, + draw::circle( + "pulley", + x, + 324.0, + 21.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#e2e3e6"), (1.0, "#8b8e94")]), + ), + ); + b.add( + root, + draw::circle("pulley hub", x, 324.0, 6.0, draw::solid("#5a5d63")), + ); + } + for (x, rot) in [(500.0, 35.0), (700.0, -35.0)] { + b.add( + root, + draw::rotated( + draw::rounded( + draw::rect( + "link", + x - 22.0, + 316.0, + 44.0, + 16.0, + draw::linear(180.0, &[(0.0, "#b7b9be"), (1.0, "#6c6f75")]), + ), + 8.0, + ), + rot, + ), + ); + } + b.add( + root, + draw::with_effects( + draw::circle( + "turbine ring", + 597.0, + 324.0, + 44.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#f0f1f3"), (1.0, "#8f9298")]), + ), + vec![draw::drop_shadow("#00000059", 0.0, 2.0, 4.0)], + ), + ); + for i in 0..12 { + let (x, y) = draw::polar(597.0, 324.0, 39.0, f64::from(i) * 30.0); + b.add( + root, + draw::circle("bolt", x, y, 2.2, draw::solid("#55585e")), + ); + } + b.add( + root, + draw::circle("turbine well", 597.0, 324.0, 32.0, draw::solid("#1a1b1e")), + ); + let blades: String = (0..28) + .map(|i| { + let a0 = f64::from(i) * 360.0 / 28.0; + let (p0, p1) = ( + draw::polar(597.0, 324.0, 14.0, a0), + draw::polar(597.0, 324.0, 30.0, a0 + 9.0), + ); + let (p2, p3) = ( + draw::polar(597.0, 324.0, 30.0, a0 + 13.0), + draw::polar(597.0, 324.0, 14.0, a0 + 5.0), + ); + format!("{} ", draw::polygon(&[p0, p1, p2, p3])) + }) + .collect(); + let fan = b.add( + root, + draw::path( + "turbine blades", + 0.0, + 0.0, + w, + h, + blades, + draw::solid("#b9bcc2"), + ), + ); + motion::add(b, fan, motion::spin("0.15")); + b.add( + root, + draw::circle( + "turbine hub", + 597.0, + 324.0, + 12.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#f4f5f6"), (1.0, "#80838a")]), + ), + ); + // The engraved panel lines and the screws. + let lines = [ + vec![ + (29.0, 144.0), + (91.0, 144.0), + (123.0, 84.0), + (243.0, 84.0), + (258.0, 57.0), + ], + vec![ + (1170.0, 144.0), + (1110.0, 144.0), + (1078.0, 84.0), + (957.0, 84.0), + (942.0, 57.0), + ], + vec![(111.0, 363.0), (1089.0, 363.0)], + vec![(29.0, 363.0), (72.0, 327.0)], + vec![(1170.0, 363.0), (1128.0, 327.0)], + ]; + for l in &lines { + rk::line(b, root, l, 1.1, "#9fa3aa"); + } + for (x, y) in [ + (50.0, 74.0), + (220.0, 74.0), + (978.0, 74.0), + (1148.0, 74.0), + (70.0, 134.0), + (1128.0, 134.0), + (70.0, 302.0), + (1128.0, 302.0), + (70.0, 432.0), + (1128.0, 432.0), + (48.0, 482.0), + (1147.0, 482.0), + (520.0, 257.0), + (674.0, 257.0), + ] { + screw(b, root, (x, y), 4.8, true); + } + // The shelf the controls sit on. + let shelf = draw::rect( + "strip", + 111.0, + 363.0, + 978.0, + 138.0, + draw::linear(180.0, &[(0.0, "#e6e7e9"), (1.0, "#d8dadd")]), + ); + b.add( + root, + draw::with_effects( + shelf, + vec![ + draw::inner_shadow("#ffffffa0", 0.0, 1.5, 0.0), + draw::drop_shadow("#00000026", 0.0, -1.0, 2.0), + ], + ), + ); + // Controls. + let ink = Ink::new(8.6, "#3b3e44").weight(700.0).track(0.12); + let (cy, ly) = (453.0, 412.0); + jack_and_cable(b, root, (131.0, cy), 13.0, (w, h), "#8d9096", true); + rk::print(b, root, "Input", (131.0, ly), ink); + rk::line(b, root, &[(131.0, 419.0), (131.0, 428.0)], 1.2, ORANGE); + head_toggle( + b, + root, + "Voice", + &a("voice"), + (200.0, cy), + 16.0, + Some((ly, ink)), + ); + rk::line(b, root, &[(200.0, 419.0), (200.0, 428.0)], 1.2, ORANGE); + let style = super::knob( + "stepped_cream", + 50.0, + "#3b3e44", + &[ + "color=#f2f2f1", + "pointer=#ef7a2c", + "ticks=none", + "shadow=0.8", + "rough=0.4", + "height=0.3", + "step_r=0.66", + "step_h=0.05", + "index_w=0.07", + "index_in=0.84", + "index_out=0.84", + "fallback=domed", + ], + ); + for (i, (addr, name, _)) in TONE_STACK.iter().enumerate() { + rk::knob_lt( + b, + root, + &style, + &a(addr), + name, + (287.0 + 103.5 * i as f64, cy), + ly, + ink, + ); + } + let power = lit_rocker(b, root, &a("power"), (1078.0, cy - 3.0), (19.0, 46.0)); + rk::label(b, power, "Power", (1078.0, ly), ink); + }); + let mut params = vec![rk::toggle(&a("voice"), "Voice", false)]; + params.extend(tone_params(ns)); + params.push(rk::toggle(&a("power"), "Power", true).featured()); + Face { + file: "07-amp-head-mech-rhythm", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 08 · Slat Lead ─────────────────────────────────────────────────────── + +/// A valve seen through the cage: its glass, the plate, the glowing +/// heater; the heater flickers. +fn valve(b: &mut Builder<'_>, parent: NodeId, (cx, top, bottom): (f64, f64, f64), wd: f64) { + let glass = draw::rounded( + draw::rect( + "valve glass", + cx - wd / 2.0, + top, + wd, + bottom - top, + draw::linear( + 90.0, + &[ + (0.0, "#ffffff40"), + (0.12, "#5a4a4088"), + (0.35, "#ffffff2a"), + (0.7, "#2a201a90"), + (0.9, "#ffffff38"), + (1.0, "#ffffff10"), + ], + ), + ), + wd / 2.0, + ); + b.add(parent, glass); + b.add( + parent, + draw::rect( + "valve plate", + cx - wd * 0.28, + top + (bottom - top) * 0.22, + wd * 0.56, + (bottom - top) * 0.5, + draw::linear( + 90.0, + &[(0.0, "#3b3230"), (0.5, "#7d6a60"), (1.0, "#3b3230")], + ), + ), + ); + let heat = draw::rounded( + draw::rect( + "heater", + cx - wd * 0.12, + top + (bottom - top) * 0.28, + wd * 0.24, + (bottom - top) * 0.42, + draw::linear( + 90.0, + &[(0.0, "#c24a12"), (0.5, "#ffc38a"), (1.0, "#c24a12")], + ), + ), + 2.0, + ); + let heat = b.add(parent, parts::emit(heat, "#ff7a2e", 1.0, wd * 1.2)); + motion::add(b, heat, motion::flicker(0.12)); +} + +/// Slat Lead — Head, 1200 × 595. See the module docs. +#[allow(clippy::too_many_lines)] +fn slat_lead() -> Face { + let ns = "dflead"; + let a = |s: &str| format!("{ns}/{s}"); + let (w, h) = (1200.0, 595.0); + let scene = canvas("Slat Lead", |b, root| { + parts::fit_root(b, root, w, h); + // Handle: a round bar in an arch. + let hd = "M 406 43 Q 418 6 470 4 L 730 4 Q 782 6 794 43 L 784 43 Q 772 16 730 14 L 470 14 Q 428 16 416 43 Z"; + b.add( + root, + draw::with_effects( + draw::path( + "handle", + 0.0, + 0.0, + w, + h, + hd.into(), + draw::linear(180.0, &[(0.0, "#6a6b6e"), (1.0, "#2a2b2d")]), + ), + vec![draw::drop_shadow("#00000066", 0.0, 2.0, 3.0)], + ), + ); + foot(b, root, (141.0, 559.0, 66.0, 30.0), false); + foot(b, root, (993.0, 559.0, 66.0, 30.0), false); + let body = draw::rounded( + draw::rect( + "cabinet", + 4.0, + 43.0, + 1192.0, + 518.0, + draw::linear(180.0, &[(0.0, "#46474a"), (1.0, "#303134")]), + ), + 18.0, + ); + b.add( + root, + draw::stroked( + draw::with_effects( + body, + vec![ + draw::drop_shadow("#00000073", 0.0, 8.0, 16.0), + draw::inner_shadow("#ffffff26", 0.0, 2.0, 1.0), + ], + ), + "#1a1b1d", + 1.5, + ), + ); + specks( + b, + root, + (8.0, 46.0, 1184.0, 512.0), + 0x1ead, + 30.0, + 0.05, + 0.12, + ); + // The cage: a dark cavity with valves, the slats over it. + let cage = parts::well( + b, + root, + "cage", + (21.0, 58.0, 1159.0, 377.0), + draw::solid("#3a3b3e"), + 4.0, + ); + specks(b, cage, (0.0, 0.0, 1159.0, 377.0), 0x51a7, 30.0, 0.05, 0.12); + let n = 23; + let (x0, pitch, sw, sy0, sy1) = (35.0, 49.55, 17.0, 35.0, 338.0); + // The cavity behind the slots. + b.add( + cage, + draw::rect( + "cavity", + x0 - 4.0, + sy0, + pitch * f64::from(n) - 20.0, + sy1 - sy0, + draw::linear( + 180.0, + &[(0.0, "#070707"), (0.5, "#1a120e"), (1.0, "#070707")], + ), + ), + ); + // Valves: pairs of power tubes and small preamp tubes. + for (cx, t, bt, wd) in [ + (263.0, 142.0, 318.0, 34.0), + (332.0, 110.0, 318.0, 34.0), + (447.0, 150.0, 318.0, 30.0), + (505.0, 108.0, 318.0, 34.0), + (660.0, 108.0, 318.0, 34.0), + (716.0, 150.0, 318.0, 30.0), + (842.0, 110.0, 318.0, 34.0), + (900.0, 142.0, 318.0, 34.0), + ] { + valve(b, cage, (cx, t, bt), wd); + } + // The bars between the slots (drawn over the cavity). + let mut bars = String::new(); + let mut x = 0.0; + for i in 0..=n { + let slot_x = x0 + pitch * f64::from(i); + let end = if i < n { slot_x - sw / 2.0 } else { 1159.0 }; + bars.push_str(&format!( + "M {x:.1} 0 L {end:.1} 0 L {end:.1} 377 L {x:.1} 377 Z " + )); + x = slot_x + sw / 2.0; + } + bars.push_str(&format!( + "M 0 0 L 1159 0 L 1159 {sy0} L 0 {sy0} Z M 0 {sy1} L 1159 {sy1} L 1159 377 L 0 377 Z" + )); + let bars = draw::path( + "slats", + 0.0, + 0.0, + 1159.0, + 377.0, + bars, + draw::linear( + 90.0, + &[(0.0, "#3d3e41"), (0.5, "#454649"), (1.0, "#3a3b3e")], + ), + ); + b.add( + cage, + draw::with_effects(bars, vec![draw::drop_shadow("#000000a0", 0.0, 0.0, 3.0)]), + ); + specks(b, cage, (0.0, 0.0, 1159.0, 377.0), 0x51a8, 36.0, 0.04, 0.08); + // Each slot's rounded, bevelled edge. + for i in 0..n { + let cx = x0 + pitch * f64::from(i); + let slot = draw::rounded( + draw::rect( + "slot edge", + cx - sw / 2.0, + sy0 - 12.0, + sw, + sy1 - sy0 + 24.0, + draw::solid("#00000000"), + ), + sw / 2.0, + ); + b.add(cage, draw::stroked(slot, "#28292b", 1.4)); + } + for (x, y) in [(15.0, 13.0), (1144.0, 13.0), (15.0, 364.0), (1144.0, 364.0)] { + screw(b, cage, (x, y), 5.0, false); + } + // The control strip. + let strip = parts::well( + b, + root, + "strip", + (21.0, 439.0, 1159.0, 108.0), + draw::linear(180.0, &[(0.0, "#3d3e41"), (1.0, "#343538")]), + 4.0, + ); + specks(b, strip, (0.0, 0.0, 1159.0, 108.0), 0x57b2, 30.0, 0.05, 0.1); + for (x, y) in [(14.0, 12.0), (1145.0, 12.0), (14.0, 96.0), (1145.0, 96.0)] { + screw(b, strip, (x, y), 4.2, false); + } + let cy = 492.0; + jack_and_cable(b, root, (69.0, cy), 18.0, (w, h), "#1c1d20", false); + // Voice: a small red-tipped bat, its dots and legend. + let voice = rk::mini_toggle( + b, + root, + "Voice", + &a("voice"), + (132.0, cy - 6.0), + 11.0, + "black", + ); + rk::label( + b, + voice, + "Voice", + (132.0, 531.0), + Ink::new(10.0, "#d4d5d8").weight(400.0).track(0.02).plain(), + ); + b.add( + root, + draw::circle("dot", 146.0, cy - 20.0, 1.8, draw::solid("#e6e7ea")), + ); + b.add( + root, + draw::stroked( + draw::circle("dot", 146.0, cy + 10.0, 1.8, draw::solid("#00000000")), + "#e6e7ea", + 0.8, + ), + ); + let style = KnobStyle::new("knob.slotted", "black", 74.0, "#d9dadc"); + for (i, (addr, name, short)) in TONE_STACK.iter().enumerate() { + let p = Place::new(*name) + .size(style.d) + .variant("black") + .legend(*short) + .keep_case() + .bind(rk::bind(&a(addr))) + .touch(TOUCH_MIN); + let k = parts::put(b, root, "knob.slotted", &p, 214.0 + 128.2 * i as f64, cy); + turn_legend(b, k, short, style.d); + } + // Power: a square orange button with a lit eye. + power_button(b, root, &a("power"), (1114.0, cy), 58.0); + rk::print( + b, + root, + "0/I", + (1128.0, 531.0), + Ink::new(10.0, "#d4d5d8").weight(400.0).track(0.02).plain(), + ); + }); + let mut params = vec![rk::toggle(&a("voice"), "Voice", false)]; + params.extend(tone_params(ns)); + params.push(rk::toggle(&a("power"), "Power", true).featured()); + Face { + file: "08-amp-head-slat-lead", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +/// Re-cut a slotted knob's own legend as lettering turned to read bottom +/// to top along the slot (the renderer doesn't turn a text run), in its +/// turning body, as the control's `Label`. +fn turn_legend(b: &mut Builder<'_>, knob: NodeId, s: &str, d: f64) { + let Some(ctl) = b.controls.get(&knob) else { + return; + }; + let body = ctl.slots_of(SlotRole::Needle).next().map(NodeId); + let old = ctl.slots_of(SlotRole::Label).next().map(NodeId); + let (Some(body), Some(old)) = (body, old) else { + return; + }; + let _ = b + .scene + .apply(frame::document::proto::Op::Remove { id: old }); + let c = b.scene.node(body).map_or(0.0, |n| n.size.width / 2.0); + let k = d / 48.0; + let size = (8.5 * k).max(7.0); + let id = rk::lettering::letter_turned( + b, + body, + s, + (c - 5.0 * k, c), + -90.0, + rk::Letter::new(size, 450.0, "#d9dadc").track(0.02), + ); + if let Some(ctl) = b.controls.get_mut(&knob) { + ctl.slots.retain(|s| s.role != SlotRole::Label); + ctl.slots.push(frame::surface::proto::Slot { + role: SlotRole::Label, + node: id.0, + }); + } +} + +/// A tall orange rocker lit from inside while on (the white head's power). +fn lit_rocker( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + (cx, cy): (f64, f64), + (rw, rh): (f64, f64), +) -> NodeId { + let bw = rw.max(TOUCH_MIN); + let bh = rh.max(TOUCH_MIN); + let root = b.add( + parent, + draw::frame("Power", cx - bw / 2.0, cy - bh / 2.0, bw, bh), + ); + let (x, y) = ((bw - rw) / 2.0, (bh - rh) / 2.0); + let bezel = draw::rounded( + draw::rect( + "bezel", + x - 2.0, + y - 2.0, + rw + 4.0, + rh + 4.0, + draw::solid("#2a2b2e"), + ), + 4.0, + ); + b.add( + root, + draw::with_effects(bezel, vec![draw::drop_shadow("#00000066", 0.0, 2.0, 3.0)]), + ); + b.add( + root, + draw::rounded( + draw::rect( + "rocker", + x, + y, + rw, + rh, + draw::linear( + 180.0, + &[(0.0, "#7a2a10"), (0.3, "#c24a1a"), (1.0, "#e0612a")], + ), + ), + 3.0, + ), + ); + let glow = draw::rounded( + draw::rect( + "lamp", + x + 1.5, + y + rh * 0.35, + rw - 3.0, + rh * 0.6, + draw::radial_at( + 0.5, + 0.6, + 0.7, + &[(0.0, "#ffe2c8"), (0.4, "#ff9a5a"), (1.0, "#e0612a")], + ), + ), + 3.0, + ); + let glow = b.add(root, parts::emit(glow, "#ff8a45", 0.8, rw)); + b.add( + root, + draw::rounded( + draw::rect( + "rocker top", + x + 2.0, + y + 2.0, + rw - 4.0, + rh * 0.28, + draw::linear(180.0, &[(0.0, "#3a1a0c"), (1.0, "#8a3514")]), + ), + 2.0, + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, glow.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// A square orange power button whose centre lights while on. +fn power_button( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + (cx, cy): (f64, f64), + s: f64, +) -> NodeId { + let root = b.add( + parent, + draw::frame("Power", cx - s / 2.0, cy - s / 2.0, s, s), + ); + let cap = draw::rounded( + draw::rect( + "cap", + 0.0, + 0.0, + s, + s, + draw::linear( + 180.0, + &[(0.0, "#ff8a55"), (0.5, "#e8562a"), (1.0, "#b8401c")], + ), + ), + s * 0.14, + ); + b.add( + root, + draw::with_effects( + cap, + vec![ + draw::drop_shadow("#00000080", 0.0, 3.0, 5.0), + draw::inner_shadow("#ffffff55", 0.0, 1.5, 1.0), + ], + ), + ); + b.add( + root, + draw::circle( + "dish", + s / 2.0, + s / 2.0, + s * 0.36, + draw::radial_at(0.55, 0.6, 0.7, &[(0.0, "#f2703d"), (1.0, "#c44a20")]), + ), + ); + let eye = draw::circle( + "eye", + s / 2.0, + s / 2.0, + s * 0.1, + draw::radial(&[(0.0, "#ffffff"), (0.5, "#ffd0b0"), (1.0, "#ff8a55")]), + ); + let eye = b.add(root, parts::emit(eye, "#ffb08a", 0.9, s * 0.4)); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, eye.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} diff --git a/examples/plugin-faces/src/suite_djent_forge/post.rs b/examples/plugin-faces/src/suite_djent_forge/post.rs new file mode 100644 index 00000000..53459791 --- /dev/null +++ b/examples/plugin-faces/src/suite_djent_forge/post.rs @@ -0,0 +1,1846 @@ +//! Djent Forge post-FX and special FX. +//! +//! - **Treadle Modulator** — Pedal, Wide (222 × 286): a rose-gold box with +//! a rubber-treaded rocker on the left (a vertical `Fader`, heel to toe) +//! and a small black screen on the right: CHORUS / FLANGER, the MIX, +//! DEPTH and RATE ranges the rocker sweeps (two handles each, H and T), +//! the auto speed; PEDAL / AUTO toggle, AUTO RANDOM and AUTO SPEED knobs, +//! the LED and ENGAGE stomp. +//! - **Stereo Echo** — Pedal, BigBox (404 × 286): black, two outlined +//! panels — MIX and FEEDBACK round SYNC / MODE keys over a red +//! sixteen-segment display, then low cut, TIME, MOD, DUCKER, high cut — +//! and ENGAGE / TAP TEMPO stomps with red LEDs beside the name. +//! - **Shimmer Verb** — Pedal, Standard (156 × 286): midnight blue, MIX, +//! PRE DELAY, DECAY, low and high cut round a SHIMMER / NORMAL toggle. +//! - **Beam Treadle** — Pedal, Wide (222 × 286): a violet pitch treadle +//! with a patch list on its screen, PEDAL / ENVELOPE, SPEED and +//! SENSITIVITY, a lit TRIGGER button. +//! - **Fracture** — Tabletop, 800 × 479: a big grain-glitch unit with a +//! screen (waveform, loop length, read point, mode, grain / mix / sync +//! sections of green-arc dials) above ENGAGE and TEMPO stomps. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::materials::{self, Material, light}; +use frame::surface::kit::{Place, draw}; +use frame::surface::proto::{ + Axis, Binding, ButtonMode, Control, ControlKind, HandleAxis, Orientation, SlotRole, ValueFormat, +}; +use frame::surface::widgets::Builder; + +use super::type_::{fine, legend, mark, name}; +use super::{LED_BLUE, PLUGIN, pedal_knob}; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Enclosure, KnobStyle, Letter, lcd}; + +pub fn faces() -> Vec { + vec![ + modulator(), + stereo_echo(), + shimmer_verb(), + beam(), + fracture(), + ] +} + +const KNOB: KnobStyle<'static> = pedal_knob(40.0); +const WHITE: &str = "#f2f2ef"; + +// ── shared parts ───────────────────────────────────────────────────────── + +/// The rubber tread on a rocker: a dark pad, two straight centre grooves +/// and the curved side grooves of a tyre. +fn tread(b: &mut Builder<'_>, parent: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + // The pad: moulded rubber standing a little off the plate. + let under = draw::rounded( + draw::rect("tread shadow", x, y, w, h, draw::solid("#141416")), + 3.0, + ); + b.add(parent, draw::with_effects(under, light::stand(2.5, 1.0))); + let pad = materials::fill( + b, + parent, + (x, y, w, h), + 3.0, + &Material::Rubber { + color: "#2b2c2f".into(), + }, + ); + let cx = w / 2.0; + let mut grooves: Vec> = Vec::new(); + // Straight channels down the middle. + for dx in [-0.1, -0.045, 0.0, 0.045, 0.1] { + grooves.push(vec![(cx + dx * w, -2.0), (cx + dx * w, h + 2.0)]); + } + let pitch = h / 5.2; + for side in [-1.0_f64, 1.0] { + // The shoulder groove, and short blades across the shoulder. + grooves.push( + (0..=12) + .map(|k| { + let v = f64::from(k) / 12.0; + ( + cx + side * w * (0.39 + 0.012 * (v * 12.0).sin()), + -2.0 + (h + 4.0) * v, + ) + }) + .collect(), + ); + let mut sy = -pitch * 0.2; + while sy < h + 2.0 { + grooves.push(vec![ + (cx + side * w * 0.395, sy), + (cx + side * w * 0.52, sy + 1.2), + ]); + sy += pitch * 0.5; + } + // The long blades: steep off the channels, flattening as they + // sweep out to the shoulder. + let mut ys = -pitch * 2.0; + while ys < h + 2.0 { + let len = pitch * 1.9; + grooves.push( + (0..=14) + .map(|k| { + let u = f64::from(k) / 14.0; + ( + cx + side * w * (0.14 + 0.25 * u), + ys + len * (1.0 - (1.0 - u) * (1.0 - u)), + ) + }) + .collect(), + ); + ys += pitch; + } + } + let lit: Vec> = grooves + .iter() + .map(|g| g.iter().map(|(px, py)| (px + 0.4, py + 1.2)).collect()) + .collect(); + b.add( + pad, + parts::stroke_path( + "groove light", + (0.0, 0.0, w, h), + &lit, + 0.9, + "#ffffff1f", + false, + ), + ); + b.add( + pad, + parts::stroke_path("grooves", (0.0, 0.0, w, h), &grooves, 1.7, "#050506", false), + ); +} + +/// The rocker a treadle unit is played with: a raised plate with two tread +/// pads and the name between; the plate is a vertical `Fader` (heel at the +/// bottom, toe at the top), a small lit marker on its edge showing where. +fn rocker( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (x, y, w, h): (f64, f64, f64, f64), + plate: &str, + marker: &str, +) -> NodeId { + let mut f = draw::frame(name, x, y, w, h); + f.fills = vec![draw::linear( + 180.0, + &[ + (0.0, &parts::mixed(plate, "#ffffff", 0.18)), + (1.0, &parts::mixed(plate, "#000000", 0.12)), + ], + )]; + let root = b.add( + parent, + draw::with_effects( + draw::stroked(draw::rounded(f, 4.0), "#00000066", 0.8), + vec![ + draw::drop_shadow("#00000080", 0.0, 4.0, 6.0), + draw::inner_shadow("#ffffff40", 0.0, 1.0, 1.0), + ], + ), + ); + tread(b, root, (5.0, 5.0, w - 10.0, h * 0.37)); + tread(b, root, (5.0, h * 0.6, w - 10.0, h * 0.37 + 1.0)); + // The travel: a lane down the plate's right edge. + let lane = b.add(root, draw::frame("travel", w - 4.0, 6.0, 3.0, h - 12.0)); + let thumb = b.add( + lane, + parts::emit( + draw::rounded( + draw::rect("position", 0.0, 0.0, 3.0, 10.0, draw::solid(marker)), + 1.5, + ), + marker, + 0.6, + 6.0, + ), + ); + let mut c = Control::new(ControlKind::Fader { + orientation: Orientation::Vertical, + }) + .slot(SlotRole::Thumb, thumb.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// A black screen with a thin light bezel, returning its frame. +fn screen(b: &mut Builder<'_>, parent: NodeId, name: &str, r: (f64, f64, f64, f64)) -> NodeId { + let s = parts::screen( + b, + parent, + name, + r, + parts::Screen { + glass: ("#050506", "#0d0e10"), + ink: "#e6e6e6", + mesh: false, + }, + ); + if let Some(n) = b.scene.node_mut(s) { + n.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid("#8c8f96")], + weight: 0.8, + ..frame::document::proto::Stroke::default() + }; + } + s +} + +/// A mini toggle with a tight box `touch` wide, centred on (`cx`, `cy`). +fn toggle( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + size: f64, + touch: f64, +) -> NodeId { + let p = Place::new(name) + .size(size) + .variant("black") + .bind(rk::bind(addr)) + .legend("") + .touch(touch); + let t = parts::put(b, parent, "switch.toggle_nut", &p, cx, cy); + parts::strip_legend(b, t); + t +} + +/// A lever lying in a dark oval slot, thrown left or right (the treadles' +/// mode switch): the chrome lever sits right while on. +fn slide_toggle( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), +) -> NodeId { + let (w, h) = (24.0, 12.0); + let root = b.add(parent, draw::frame(name, cx - w / 2.0, cy - h / 2.0, w, h)); + let slot = draw::rounded( + draw::rect( + "slot", + 1.0, + 2.0, + w - 2.0, + h - 4.0, + draw::linear(180.0, &[(0.0, "#050506"), (1.0, "#25262a")]), + ), + (h - 4.0) / 2.0, + ); + b.add( + root, + draw::with_effects( + draw::stroked(slot, "#8a8c92", 0.6), + vec![draw::inner_shadow("#000000", 0.0, 1.0, 2.0)], + ), + ); + let lever = |x: f64| { + draw::rounded( + draw::rect( + "lever", + x, + 3.2, + 9.0, + h - 6.4, + draw::linear( + 180.0, + &[(0.0, "#f0f0ee"), (0.5, "#8d8e90"), (1.0, "#d0d0ce")], + ), + ), + 2.5, + ) + }; + b.add(root, draw::with_opacity(lever(3.0), 0.35)); + let on = b.add(root, lever(w - 12.0)); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, on.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// A screen's pill switch lying on its side: the thumb to the right when on. +fn screen_switch( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + (w, h): (f64, f64), +) -> NodeId { + let root = b.add(parent, draw::frame(name, cx - w / 2.0, cy - h / 2.0, w, h)); + let (tw, th) = (w * 0.8, h * 0.5); + b.add( + root, + draw::rounded( + draw::rect( + "track", + (w - tw) / 2.0, + (h - th) / 2.0, + tw, + th, + draw::solid("#d7d8dc"), + ), + th / 2.0, + ), + ); + let off = b.add( + root, + draw::circle( + "thumb off", + (w - tw) / 2.0 + th / 2.0, + h / 2.0, + th * 0.36, + draw::solid("#4a4b50"), + ), + ); + let _ = off; + let on = b.add( + root, + draw::circle( + "thumb", + (w + tw) / 2.0 - th / 2.0, + h / 2.0, + th * 0.36, + draw::solid("#2a2b2e"), + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, on.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// A range the rocker sweeps: a hairline lane with the swept span lit +/// between two handles, H (heel) and T (toe), each a `Handle` on its own +/// param; the row prints its name and the span. +#[allow(clippy::too_many_arguments)] +fn range_row( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (heel, toe): (&str, &str), + (lo, hi): (f64, f64), + (x, y0, w): (f64, f64, f64), + accent: &str, + (heel_text, toe_text): (&str, &str), +) { + let box_ = draw::stroked( + draw::rounded( + draw::rect("row", x - 3.0, y0, w + 6.0, 19.0, draw::solid("#0d0e10")), + 2.0, + ), + "#2e3035", + 0.6, + ); + b.add(parent, box_); + // The span, as the screen prints it: NAME heel-toe, set as one line — + // the heel's readout starts after the name, the dash and the toe's + // after the heel as it prints at rest. + let ty = y0 + 6.6; + let label = Letter::new(6.0, 500.0, "#c3c5ca"); + let value = Letter::new(6.0, 600.0, "#ffffff"); + rk::letter_aligned(b, parent, name, (x + 1.0, ty), label, TextAlign::Left); + let hx = x + 1.0 + rk::lettering::set_width(name, &label) + 2.0; + let hz = lo > 0.0; + // (A unit prints after a space; the screen sets % tight, so the % + // is printed after the toe as it reads at rest.) + let (fmt_h, fmt_t) = if hz { + ( + ValueFormat::decimals(1).unit(""), + ValueFormat::decimals(1).unit("Hz"), + ) + } else { + ( + ValueFormat::decimals(0).unit(""), + ValueFormat::decimals(0).unit(""), + ) + }; + let hw = rk::lettering::set_width(heel_text, &value); + lcd::readout( + b, + parent, + &format!("{name} heel value"), + rk::bind(heel), + (hx - 1.0, ty, hw + 8.0), + value, + TextAlign::Left, + None, + fmt_h, + ); + let dx = hx + hw + 0.4; + rk::letter_aligned(b, parent, "-", (dx, ty), value, TextAlign::Left); + let tx = dx + rk::lettering::set_width("-", &value) + 0.4; + lcd::readout( + b, + parent, + &format!("{name} toe value"), + rk::bind(toe), + (tx - 1.0, ty, x + w - tx + 1.0), + value, + TextAlign::Left, + None, + fmt_t, + ); + if !hz { + let px = tx + rk::lettering::set_width(toe_text, &value) + 0.4; + rk::letter_aligned(b, parent, "%", (px, ty), value, TextAlign::Left); + } + let area = b.add( + parent, + draw::frame(&format!("{name} range"), x, y0 + 11.5, w, 6.0), + ); + b.add( + area, + draw::rounded( + draw::rect("lane", 0.0, 2.5, w, 1.0, draw::solid("#5a5c62")), + 0.5, + ), + ); + // The swept span between the handles as they rest. + let at = |t: &str| ((t.parse::().unwrap_or(lo) - lo) / (hi - lo)).clamp(0.0, 1.0); + let (u0, u1) = (at(heel_text), at(toe_text)); + b.add( + area, + draw::rect("span", w * u0, 2.3, w * (u1 - u0), 1.4, draw::solid(accent)), + ); + for (addr, letter) in [(heel, "H"), (toe, "T")] { + let hbox = 16.0; + let h = b.add( + area, + draw::frame( + &format!("{name} {letter}"), + 0.0, + 3.0 - hbox / 2.0, + hbox, + hbox, + ), + ); + b.add( + h, + draw::stroked( + draw::circle("dot", hbox / 2.0, hbox / 2.0, 3.6, draw::solid("#f6f6f7")), + "#1a1b1d", + 0.7, + ), + ); + rk::letter( + b, + h, + letter, + (hbox / 2.0, hbox / 2.0), + Letter::new(4.4, 800.0, "#111214"), + ); + let kind = ControlKind::Handle { + x: Some(Binding::param(addr)), + y: None, + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: lo, max: hi }), + y_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + }; + b.controls.insert(h, Control::new(kind)); + } +} + +/// A bank of radio keys over a choice param: one `Select` button per +/// option in (`x, y, w, h`) split evenly, the selected one filled. +#[allow(clippy::too_many_arguments)] +fn segments( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (x, y, w, h): (f64, f64, f64, f64), + options: &[&str], + vertical: bool, + ink: Ink<'_>, + fill: &str, +) { + let n = options.len(); + b.add( + parent, + draw::stroked( + draw::rounded( + draw::rect(&format!("{name} frame"), x, y, w, h, draw::solid("#0b0c0d")), + 2.5, + ), + "#3a3c40", + 0.8, + ), + ); + for (i, o) in options.iter().enumerate() { + let (sx, sy, sw, sh) = if vertical { + let sh = h / n as f64; + (x, y + sh * i as f64, w, sh) + } else { + let sw = w / n as f64; + (x + sw * i as f64, y, sw, h) + }; + let root = b.add(parent, draw::frame(&format!("{name} {o}"), sx, sy, sw, sh)); + let lit = b.add( + root, + draw::rounded( + draw::rect("selected", 1.5, 1.5, sw - 3.0, sh - 3.0, draw::solid(fill)), + 1.5, + ), + ); + rk::print(b, root, o, (sw / 2.0, sh / 2.0), ink); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: n as u32, + }, + }) + .slot(SlotRole::Led, lit.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + } +} + +/// An outlined section with its title set into the top rule. +fn section( + b: &mut Builder<'_>, + parent: NodeId, + title: &str, + (x, y, w, h): (f64, f64, f64, f64), + ink: &str, + size: f64, +) { + let tw = if title.is_empty() { + 0.0 + } else { + title.chars().count() as f64 * size * 0.78 + size * 1.2 + }; + let (tx0, tx1) = (x + size * 2.2, x + size * 2.2 + tw); + let r = size * 0.9; + let d = if title.is_empty() { + vec![vec![ + (x + r, y), + (x + w - r, y), + (x + w, y + r), + (x + w, y + h - r), + (x + w - r, y + h), + (x + r, y + h), + (x, y + h - r), + (x, y + r), + (x + r, y), + ]] + } else { + vec![vec![ + (tx1, y), + (x + w - r, y), + (x + w, y + r), + (x + w, y + h - r), + (x + w - r, y + h), + (x + r, y + h), + (x, y + h - r), + (x, y + r), + (x + r, y), + (tx0, y), + ]] + }; + b.add(parent, parts::lines_node("section", &d, 0.9, ink)); + if !title.is_empty() { + rk::letter_aligned( + b, + parent, + title, + (tx0 + size * 0.6, y), + Letter::new(size, 700.0, ink).track(0.14), + TextAlign::Left, + ); + } +} + +// ── 11 · Treadle Modulator ─────────────────────────────────────────────── + +/// Treadle Modulator — Pedal, Wide (222 × 286). See the module docs. +#[allow(clippy::too_many_lines)] +fn modulator() -> Face { + let ns = "dfmod"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Treadle Modulator", |b, root| { + let e = Enclosure::new("wide", "#a88577", "brushed") + .base("#6e544b") + .jacks("none"); + let p = rk::pedal_body(b, root, &e); + let r = rocker( + b, + p, + "Rocker", + &a("pedal"), + (10.0, 9.0, 108.0, 232.0), + "#b39487", + LED_BLUE, + ); + rk::letter_fit(b, r, "MODULATOR", (54.0, 110.0), 76.0, name(9.0, "#f3eee9")); + let wave: Vec<(f64, f64)> = (0..=40) + .map(|i| { + let u = f64::from(i) / 40.0; + let bump = if (0.42..0.58).contains(&u) { + -((u - 0.42) / 0.16 * std::f64::consts::PI).sin() * 4.0 + } else { + 0.0 + }; + (22.0 + 64.0 * u, 122.0 + bump) + }) + .collect(); + rk::line(b, r, &wave, 0.7, "#efe6e0"); + // The screen. + let s = screen(b, p, "Screen", (126.0, 10.0, 85.0, 133.0)); + let head = Letter::new(5.8, 500.0, "#b9bbc0"); + rk::letter(b, s, "CHORUS", (17.5, 9.0), head); + rk::letter( + b, + s, + "FLANGER", + (68.0, 9.0), + head.color("#ffffff").weight(600.0), + ); + screen_switch( + b, + s, + "Chorus / Flanger", + &a("flanger"), + (41.0, 9.0), + (17.0, 11.0), + ); + range_row( + b, + s, + "MIX", + (&a("mix_heel"), &a("mix_toe")), + (0.0, 100.0), + (6.0, 20.0, 73.0), + "#f2b36b", + ("30", "80"), + ); + range_row( + b, + s, + "DEPTH", + (&a("depth_heel"), &a("depth_toe")), + (0.0, 100.0), + (6.0, 45.5, 73.0), + "#f2b36b", + ("30", "80"), + ); + range_row( + b, + s, + "RATE", + (&a("rate_heel"), &a("rate_toe")), + (0.05, 5.2), + (6.0, 71.0, 73.0), + "#f2b36b", + ("1.5", "5.0"), + ); + rk::letter( + b, + s, + "AUTO SPEED", + (42.5, 106.0), + Letter::new(5.6, 500.0, "#c3c5ca"), + ); + parts::note_picker( + b, + s, + "Auto speed", + &a("auto_div"), + (21.0, 117.0, 43.0), + 6.2, + "#f0f1f3", + "#26282c", + ); + // Mode, the auto knobs, the stomp. + let lg = legend(6.2, WHITE); + slide_toggle(b, p, "Pedal / Auto", &a("auto"), (169.5, 155.5)); + rk::letter(b, p, "PEDAL", (145.5, 155.5), lg); + rk::letter(b, p, "AUTO", (193.0, 155.5), lg); + let small = KNOB.size(28.0).touch(34.0); + rk::knob_l( + b, + p, + &small, + &a("auto_random"), + "Auto", + (146.0, 178.5), + 200.5, + lg, + ); + rk::knob_l( + b, + p, + &small, + &a("auto_speed"), + "Auto", + (192.0, 178.5), + 200.5, + lg, + ); + rk::letter(b, p, "RANDOM", (146.0, 206.5), lg); + rk::letter(b, p, "SPEED", (192.0, 206.5), lg); + rk::stomp( + b, + p, + "Engage", + &a("on"), + (187.0, 238.0), + 32.0, + LED_BLUE, + (-40.0, -2.0), + 14.0, + "bezel", + false, + ); + rk::letter(b, p, "ENGAGE", (187.0, 258.5), lg); + rk::letter_fit( + b, + p, + "DESIGNED IN THE FORGE", + (64.0, 254.0), + 80.0, + fine(4.0, "#eaded7"), + ); + }); + let params = vec![ + parts::param( + &a("pedal"), + "Pedal", + frame::surface::proto::Range { + steps: None, + ..frame::surface::proto::Range::linear( + 0.0, + 100.0, + frame::surface::proto::Unit::Percent, + ) + }, + 0.0, + ) + .featured(), + rk::toggle(&a("flanger"), "Flanger", true), + rk::percent(&a("mix_heel"), "Mix Heel", 30.0), + rk::percent(&a("mix_toe"), "Mix Toe", 80.0), + rk::percent(&a("depth_heel"), "Depth Heel", 30.0), + rk::percent(&a("depth_toe"), "Depth Toe", 80.0), + parts::param( + &a("rate_heel"), + "Rate Heel", + frame::surface::proto::Range::linear(0.05, 5.2, frame::surface::proto::Unit::Hz), + 1.5, + ), + parts::param( + &a("rate_toe"), + "Rate Toe", + frame::surface::proto::Range::linear(0.05, 5.2, frame::surface::proto::Unit::Hz), + 5.0, + ), + rk::choice(&a("auto_div"), "Auto Speed", rk::DIVISIONS, 15), + rk::toggle(&a("auto"), "Auto", false), + rk::percent(&a("auto_random"), "Auto Random", 50.0), + rk::percent(&a("auto_speed"), "Auto Speed Amount", 50.0), + rk::toggle(&a("on"), "Engage", true).featured(), + ]; + Face { + file: "11-post-wide-treadle-modulator", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 12 · Stereo Echo ───────────────────────────────────────────────────── + +/// A red fourteen-segment readout of `cells` characters `w` wide, right +/// aligned, over its unlit ghosts. +fn seg_readout( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (x, cy, w): (f64, f64, f64), + cells: usize, + f: ValueFormat, +) -> NodeId { + let probe = lcd::seg14(100.0, RED); + let ghost: String = std::iter::repeat_n('~', cells).collect(); + let size = 100.0 * w / rk::lettering::set_width(&ghost, &probe); + lcd::readout( + b, + parent, + name, + rk::bind(addr), + (x, cy, w + 4.0), + lcd::seg14(size, RED), + TextAlign::Right, + Some(("#ff4a3d17", cells)), + f, + ) +} + +/// The display's red. +const RED: &str = "#ff4a3d"; + +/// One annunciator: a lamp and its word, lit while `addr` is on option +/// `index` of `count` (a `Select` key: touching it picks that mode). +#[allow(clippy::too_many_arguments)] +fn annunciator( + b: &mut Builder<'_>, + parent: NodeId, + word: &str, + addr: &str, + (index, count): (u32, u32), + (lamp_x, cy): (f64, f64), + lamp_left: bool, +) { + let ink = Letter::new(6.0, 500.0, "#b3b6bc"); + let tw = rk::lettering::set_width(word, &ink); + let (x0, w) = if lamp_left { + (lamp_x - 3.0, tw + 13.0) + } else { + (lamp_x - tw - 10.0, tw + 13.0) + }; + let root = b.add(parent, draw::frame(word, x0, cy - 4.5, w, 9.0)); + let (lx, tx, align) = if lamp_left { + (3.0, 10.0, TextAlign::Left) + } else { + (w - 3.0, w - 10.0, TextAlign::Right) + }; + rk::letter_aligned(b, root, word, (tx, 4.5), ink, align); + b.add(root, parts::dark_lens(lx, 4.5, 1.6, RED)); + let lit = b.add(root, parts::lens(lx, 4.5, 1.6, RED)); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { index, count }, + }) + .slot(SlotRole::Led, lit.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); +} + +/// Stereo Echo — Pedal, BigBox (404 × 286). See the module docs. +#[allow(clippy::too_many_lines)] +fn stereo_echo() -> Face { + let ns = "dfecho"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Stereo Echo", |b, root| { + let e = Enclosure::new("bigbox", "#2a2b2e", "powder") + .base("#18191b") + .jacks("none"); + let p = rk::pedal_body(b, root, &e); + let line = "#8d9096"; + section(b, p, "", (17.0, 12.5, 368.0, 105.0), line, 6.0); + section(b, p, "", (17.0, 120.0, 368.0, 57.5), line, 6.0); + let lg = legend(7.4, WHITE); + let big = KNOB.size(42.0); + rk::knob_l(b, p, &big, &a("mix"), "Mix", (56.0, 31.0), 64.5, lg); + rk::knob_l( + b, + p, + &big, + &a("feedback"), + "Feedback", + (345.0, 31.0), + 64.5, + lg, + ); + // SYNC and MODE: black keys that step their modes. + for (i, (addr, label)) in [("sync", "Sync"), ("mode", "Mode")].iter().enumerate() { + let x = 164.0 + 38.0 * i as f64; + let key = b.add(p, draw::frame(label, x, 20.5, 38.0, 31.0)); + let cap = draw::rounded( + draw::rect( + "cap", + 1.0, + 1.0, + 36.0, + 29.0, + draw::linear(180.0, &[(0.0, "#2c2d30"), (1.0, "#121314")]), + ), + 2.5, + ); + b.add(key, draw::with_effects(cap, light::stand(2.5, 1.0))); + for n in light::edge("cap edge", (1.0, 1.0, 36.0, 29.0), 2.5, 1.2, 0.8, true) { + b.add(key, n); + } + rk::letter( + b, + key, + &label.to_uppercase(), + (19.0, 13.0 + 2.0 * i as f64), + Letter::new(5.8, 500.0, "#d8d9dc"), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Cycle, + }); + c.binding = Some(rk::bind(&a(addr))); + b.controls.insert(key, c); + rk::line(b, p, &[(x + 19.0, 56.0), (x + 19.0, 71.0)], 0.8, line); + } + // The display: division left, time right, the annunciators between + // — each lamp lit by its param's position. + let d = parts::screen( + b, + p, + "Display", + (29.0, 75.0, 345.0, 36.0), + parts::Screen { + glass: ("#060303", "#100404"), + ink: RED, + mesh: false, + }, + ); + seg_readout( + b, + d, + "Division", + &a("div"), + (8.0, 18.0, 102.0), + 5, + ValueFormat::labels(rk::DIVISIONS), + ); + seg_readout( + b, + d, + "Time", + &a("time"), + (233.0, 18.0, 102.0), + 5, + ValueFormat::decimals(1).unit(""), + ); + for (i, (l, r)) in [ + ("FREE MS", "STEREO"), + ("DAW BPM", "PHASE INV"), + ("TAP BPM", "PING PONG"), + ] + .iter() + .enumerate() + { + let y = 10.0 + 8.8 * i as f64; + annunciator(b, d, l, &a("sync"), (i as u32, 3), (165.0, y), false); + annunciator(b, d, r, &a("mode"), (i as u32, 3), (180.5, y), true); + } + // The lower row. + let knobs = [ + ("low_cut", ""), + ("time", "Time"), + ("mod", "Mod"), + ("ducker", "Ducker"), + ("high_cut", ""), + ]; + for (i, (addr, label)) in knobs.iter().enumerate() { + let x = 56.0 + 72.25 * i as f64; + rk::knob_l(b, p, &big, &a(addr), label, (x, 146.0), 177.5, lg); + } + // The low- and high-cut glyphs and the rule the legends sit in. + rk::line( + b, + p, + &[(46.0, 182.0), (51.0, 177.5), (66.0, 177.5)], + 0.9, + WHITE, + ); + rk::line( + b, + p, + &[(337.0, 177.5), (352.0, 177.5), (357.0, 182.0)], + 0.9, + WHITE, + ); + for (x0, x1) in [ + (76.0, 113.0), + (145.0, 187.0), + (214.0, 255.0), + (290.0, 328.0), + ] { + rk::line(b, p, &[(x0, 177.5), (x1, 177.5)], 0.6, "#9da0a6"); + } + // Foot: engage and tap, their LEDs, the name. + rk::stomp( + b, + p, + "Engage", + &a("on"), + (48.0, 224.0), + 40.0, + RED, + (52.0, 0.0), + 15.0, + "bezel", + false, + ); + rk::stomp( + b, + p, + "Tap Tempo", + &a("tap"), + (356.0, 224.0), + 40.0, + RED, + (-54.0, 0.0), + 15.0, + "bezel", + true, + ); + rk::letter(b, p, "ENGAGE", (48.0, 252.0), lg); + rk::letter(b, p, "TAP TEMPO", (356.0, 252.0), lg); + rk::letter_fit( + b, + p, + "STEREO ECHO", + (202.0, 217.5), + 108.0, + name(12.0, "#dcdddf").track(0.08), + ); + let y = 232.0; + rk::line( + b, + p, + &[ + (146.0, y), + (196.0, y), + (199.0, y + 3.0), + (205.0, y - 3.0), + (208.0, y), + (258.0, y), + ], + 0.8, + "#c9cbcf", + ); + rk::letter_fit( + b, + p, + "DESIGNED IN THE FORGE", + (202.0, 250.0), + 114.0, + fine(6.0, "#dcdddf"), + ); + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 50.0).featured(), + rk::percent(&a("feedback"), "Feedback", 50.0).featured(), + rk::choice(&a("sync"), "Sync", &["Free", "DAW", "Tap"], 0), + rk::choice(&a("mode"), "Mode", &["Stereo", "Phase Inv", "Ping Pong"], 0), + rk::choice(&a("div"), "Division", rk::DIVISIONS, 20), + rk::ms(&a("time"), "Time", 20.0, 2400.0, 240.0).featured(), + rk::hz(&a("low_cut"), "Low Cut", 20.0, 1000.0, 30.0), + rk::percent(&a("mod"), "Mod", 50.0), + rk::percent(&a("ducker"), "Ducker", 50.0), + rk::hz(&a("high_cut"), "High Cut", 1000.0, 20000.0, 14000.0), + rk::toggle(&a("on"), "Engage", true).featured(), + rk::toggle(&a("tap"), "Tap Tempo", false), + ]; + Face { + file: "12-post-bigbox-stereo-echo", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 13 · Shimmer Verb ──────────────────────────────────────────────────── + +/// Shimmer Verb — Pedal, Standard (156 × 286). See the module docs. +fn shimmer_verb() -> Face { + let ns = "dfverb"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Shimmer Verb", |b, root| { + let e = Enclosure::new("standard", "#44577a", "powder").base("#2e3b53"); + let p = rk::pedal_body(b, root, &e); + let (w, _) = e.size(); + let lg = legend(7.0, WHITE); + let k = KNOB.size(38.0).touch(40.0); + rk::knob_l(b, p, &k, &a("mix"), "Mix", (31.0, 31.0), 57.5, lg); + rk::knob_l( + b, + p, + &k, + &a("pre_delay"), + "Pre Delay", + (78.0, 31.0), + 57.5, + lg, + ); + rk::knob_l(b, p, &k, &a("decay"), "Decay", (125.0, 31.0), 57.5, lg); + rk::knob_l(b, p, &k, &a("low_cut"), "", (31.0, 84.0), 0.0, lg); + rk::knob_l(b, p, &k, &a("high_cut"), "", (125.0, 84.0), 0.0, lg); + rk::line( + b, + p, + &[(22.0, 119.0), (28.0, 113.0), (42.0, 113.0)], + 0.8, + WHITE, + ); + rk::line( + b, + p, + &[(114.0, 113.0), (128.0, 113.0), (134.0, 119.0)], + 0.8, + WHITE, + ); + toggle( + b, + p, + "Shimmer / Normal", + &a("shimmer"), + (78.0, 90.0), + 8.0, + 26.0, + ); + let sm = legend(5.6, WHITE); + rk::letter(b, p, "SHIMMER", (78.0, 75.0), sm); + rk::letter(b, p, "NORMAL", (78.0, 111.0), sm); + rk::letter_fit( + b, + p, + "REVERB", + (w / 2.0, 134.5), + 78.0, + name(11.0, WHITE).track(0.18), + ); + rk::stomp( + b, + p, + "Bypass", + &a("on"), + (w / 2.0, 190.0), + 40.0, + LED_BLUE, + (0.0, -33.0), + 17.0, + "bezel", + false, + ); + // The mark: rings in brackets on a rule. + let y = 230.0; + rk::line(b, p, &[(34.0, y), (64.0, y)], 0.8, "#dfe4ee"); + rk::line(b, p, &[(92.0, y), (122.0, y)], 0.8, "#dfe4ee"); + for r in [1.6, 3.2, 4.8] { + b.add( + p, + draw::stroked( + draw::circle("ring", w / 2.0, y, r, draw::solid("#00000000")), + "#dfe4ee", + 0.7, + ), + ); + } + for (i, r) in [7.0, 9.5, 12.0].iter().enumerate() { + let _ = i; + let arc_l: Vec<(f64, f64)> = (0..=10) + .map(|j| draw::polar(w / 2.0, y, *r, 230.0 + 80.0 * f64::from(j) / 10.0)) + .collect(); + let arc_r: Vec<(f64, f64)> = (0..=10) + .map(|j| draw::polar(w / 2.0, y, *r, 50.0 + 80.0 * f64::from(j) / 10.0)) + .collect(); + rk::line(b, p, &arc_l, 0.7, "#dfe4ee"); + rk::line(b, p, &arc_r, 0.7, "#dfe4ee"); + } + rk::letter_fit( + b, + p, + "DESIGNED IN THE FORGE", + (w / 2.0, 250.0), + w * 0.74, + fine(4.6, "#dfe4ee"), + ); + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 50.0).featured(), + rk::ms(&a("pre_delay"), "Pre Delay", 1.0, 500.0, 22.4), + parts::param( + &a("decay"), + "Decay", + frame::surface::proto::Range::log(0.2, 20.0, frame::surface::proto::Unit::Seconds), + 2.0, + ) + .featured(), + rk::hz(&a("low_cut"), "Low Cut", 20.0, 1000.0, 141.0), + rk::hz(&a("high_cut"), "High Cut", 1000.0, 20000.0, 4470.0), + rk::toggle(&a("shimmer"), "Shimmer", false), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "13-post-standard-shimmer-verb", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 14 · Beam Treadle ──────────────────────────────────────────────────── + +/// The pitch patches the treadle's screen lists. +const PATCHES: &[&str] = &[ + "Dissonant Dive", + "Dual Dive", + "Inv 5th Chord", + "Inverted Atonal", + "Inverted Dive", + "Inverted Dive 2", + "Lead Octave", +]; + +/// Beam Treadle — Pedal, Wide (222 × 286). See the module docs. +#[allow(clippy::too_many_lines)] +fn beam() -> Face { + let ns = "dfbeam"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Beam Treadle", |b, root| { + let e = Enclosure::new("wide", "#5d5487", "brushed") + .base("#3d3760") + .jacks("none"); + let p = rk::pedal_body(b, root, &e); + let r = rocker( + b, + p, + "Rocker", + &a("pedal"), + (10.0, 10.0, 105.0, 231.0), + "#6d64a0", + "#a6c8ff", + ); + rk::letter_fit( + b, + r, + "BEAM", + (52.5, 110.0), + 46.0, + name(11.0, "#ece9f6").track(0.22), + ); + for dy in [0.0, 2.2, 4.4] { + rk::line( + b, + r, + &[(42.0, 121.0 + dy), (63.0, 121.0 + dy)], + 0.6, + "#ece9f6", + ); + } + rk::line(b, r, &[(20.0, 123.2), (38.0, 123.2)], 0.6, "#ece9f6"); + rk::line(b, r, &[(67.0, 123.2), (85.0, 123.2)], 0.6, "#ece9f6"); + // The patch list: one radio row per patch, the chosen one lit, set + // as large as the screen's own UI type. + let s = screen(b, p, "Screen", (126.0, 11.0, 85.0, 102.0)); + let n = PATCHES.len(); + let row_ink = Letter::new(6.0, 500.0, "#ececf0"); + for (i, patch) in PATCHES.iter().enumerate() { + let y = 1.5 + 12.1 * i as f64; + let row = b.add( + s, + draw::frame(&format!("Patch {patch}"), 1.5, y, 79.0, 11.6), + ); + let lit = b.add( + row, + draw::rounded( + draw::rect("selected", 0.0, 0.4, 79.0, 10.8, draw::solid("#30344f")), + 1.5, + ), + ); + rk::letter_aligned( + b, + row, + &format!("PITCH - {patch}"), + (2.5, 5.8), + row_ink, + TextAlign::Left, + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: n as u32, + }, + }) + .slot(SlotRole::Led, lit.0); + c.binding = Some(rk::bind(&a("patch"))); + b.controls.insert(row, c); + } + b.add( + s, + draw::rounded( + draw::rect("scroll", 81.8, 2.0, 1.4, 26.0, draw::solid("#9a9da6")), + 0.7, + ), + ); + b.add( + s, + draw::rect("footer rule", 0.0, 87.0, 85.0, 0.6, draw::solid("#3a3c42")), + ); + let foot = Letter::new(6.4, 500.0, "#a9acb4"); + rk::letter_aligned(b, s, "MIX", (40.0, 94.5), foot, TextAlign::Right); + lcd::readout( + b, + s, + "Mix", + rk::bind(&a("mix")), + (42.0, 94.5, 30.0), + foot.color("#ffffff").weight(600.0), + TextAlign::Left, + None, + ValueFormat::decimals(0).unit("%"), + ); + let lg = legend(6.2, WHITE); + slide_toggle(b, p, "Pedal / Envelope", &a("envelope"), (168.0, 126.5)); + rk::letter(b, p, "PEDAL", (144.5, 126.5), lg); + rk::letter(b, p, "ENVELOPE", (198.0, 126.5), lg); + let small = KNOB.size(28.0).touch(34.0); + rk::knob_l( + b, + p, + &small, + &a("speed"), + "Speed", + (144.5, 153.0), + 173.0, + lg, + ); + rk::knob_l( + b, + p, + &small, + &a("sensitivity"), + "Sensitivity", + (190.5, 153.0), + 173.0, + lg, + ); + rk::line( + b, + p, + &[ + (208.0, 141.0), + (212.0, 141.0), + (212.0, 172.0), + (208.0, 172.0), + ], + 0.7, + WHITE, + ); + rk::line(b, p, &[(157.0, 173.0), (171.0, 173.0)], 0.7, WHITE); + // TRIGGER: a lit round key. + let trig = b.add(p, draw::frame("Trigger", 155.5, 180.0, 24.0, 24.0)); + b.add( + trig, + draw::with_effects( + draw::circle( + "bezel", + 12.0, + 12.0, + 10.0, + draw::conic( + 20.0, + &[ + (0.0, "#f2f2f0"), + (0.25, "#6c6d6f"), + (0.5, "#dcdcda"), + (0.75, "#4c4d50"), + (1.0, "#f2f2f0"), + ], + ), + ), + light::stand(1.5, 0.9), + ), + ); + let lens = draw::circle( + "lens", + 12.0, + 12.0, + 8.0, + draw::radial(&[(0.0, "#ffffff"), (0.5, "#c4d8ff"), (1.0, "#6f8fe0")]), + ); + let lit = b.add(trig, parts::emit(lens, "#9ab8ff", 0.8, 12.0)); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, lit.0); + c.binding = Some(rk::bind(&a("trigger"))); + b.controls.insert(trig, c); + rk::letter(b, p, "TRIGGER", (167.5, 208.0), lg); + rk::stomp( + b, + p, + "Engage", + &a("on"), + (186.0, 237.0), + 34.0, + LED_BLUE, + (-40.0, -4.0), + 14.0, + "bezel", + false, + ); + rk::letter(b, p, "ENGAGE", (186.0, 258.5), lg); + rk::letter_fit( + b, + p, + "DESIGNED IN THE FORGE", + (64.0, 254.0), + 80.0, + fine(4.0, "#dcd8ec"), + ); + }); + let params = vec![ + parts::param( + &a("pedal"), + "Pedal", + frame::surface::proto::Range::linear(0.0, 100.0, frame::surface::proto::Unit::Percent), + 0.0, + ) + .featured(), + rk::choice(&a("patch"), "Patch", PATCHES, 0).featured(), + rk::percent(&a("mix"), "Mix", 100.0), + rk::toggle(&a("envelope"), "Envelope", false), + rk::percent(&a("speed"), "Speed", 50.0), + rk::percent(&a("sensitivity"), "Sensitivity", 50.0), + rk::toggle(&a("trigger"), "Trigger", true), + rk::toggle(&a("on"), "Engage", true).featured(), + ]; + Face { + file: "14-fx-wide-treadle-beam", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 15 · Fracture ──────────────────────────────────────────────────────── + +/// A static grain waveform: a mirrored green envelope across the lane, the +/// part past the read head dimmed, and the grains as dots. +fn waveform(b: &mut Builder<'_>, parent: NodeId, (x0, x1, cy): (f64, f64, f64), head: f64) { + let mut seed: u32 = 0x9a1f_00d1; + let mut rnd = || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f64::from(seed >> 8) / f64::from(1_u32 << 24) + }; + let n = 260; + let mut top = Vec::new(); + for i in 0..=n { + let u = f64::from(i) / f64::from(n); + let x = x0 + (x1 - x0) * u; + let block = if (u * 9.0).fract() < 0.04 { 0.25 } else { 1.0 }; + let env = (u * std::f64::consts::PI).sin().powf(0.15) * (0.75 + 0.25 * rnd()) * 8.0 * block; + top.push((x, cy - env)); + } + let mut d = format!("M {x0:.1} {cy:.1} "); + for (x, y) in &top { + d.push_str(&format!("L {x:.1} {y:.1} ")); + } + for (x, y) in top.iter().rev() { + d.push_str(&format!("L {x:.1} {:.1} ", 2.0 * cy - y)); + } + d.push('Z'); + let full = draw::path( + "waveform", + 0.0, + 0.0, + x1 + 10.0, + cy + 20.0, + d.clone(), + draw::solid("#89c893"), + ); + b.add(parent, parts::emit(full, "#89c893", 0.3, 6.0)); + // The unplayed part, dimmed: a veil over the waveform past the head. + b.add( + parent, + draw::rect( + "unplayed", + head + 1.0, + cy - 8.0, + x1 - head + 4.0, + 16.0, + draw::solid("#0a0b0cb0"), + ), + ); + for (gx, gy) in [ + (0.07, -14.0), + (0.15, -12.0), + (0.2, 11.0), + (0.23, -12.0), + (0.26, -13.0), + (0.36, -15.0), + (0.38, 11.0), + (0.42, -22.0), + (0.47, -11.0), + (0.59, -12.0), + ] { + let x = x0 + (x1 - x0) * gx; + let dim = gx > 0.25 && gx < 0.28; + b.add( + parent, + parts::emit( + draw::circle( + "grain", + x, + cy + gy, + 1.7, + draw::solid(if dim { "#8a7a4a" } else { "#ffd98a" }), + ), + "#ffd98a", + 0.5, + 4.0, + ), + ); + } + b.add( + parent, + draw::rect( + "read head", + head, + cy - 38.0, + 1.0, + 78.0, + draw::solid("#f4f4f4"), + ), + ); +} + +/// Fracture — Tabletop, 800 × 479. See the module docs. +#[allow(clippy::too_many_lines)] +fn fracture() -> Face { + let ns = "dffrac"; + let a = |s: &str| format!("{ns}/{s}"); + let (w, h) = (800.0, 479.0); + let scene = canvas("Fracture", |b, root| { + parts::fit_root(b, root, w, h); + let case = draw::rounded( + draw::rect( + "case", + 2.0, + 2.0, + w - 4.0, + h - 6.0, + draw::linear( + 180.0, + &[(0.0, "#3a3b3e"), (0.8, "#27282b"), (1.0, "#1c1d1f")], + ), + ), + 10.0, + ); + b.add( + root, + draw::stroked( + draw::with_effects( + case, + vec![ + draw::drop_shadow("#000000a0", 0.0, 8.0, 16.0), + draw::inner_shadow("#ffffff26", 0.0, 1.5, 1.0), + ], + ), + "#0c0c0d", + 1.2, + ), + ); + b.add( + root, + draw::rect( + "lip", + 6.0, + h - 30.0, + w - 12.0, + 26.0, + draw::linear(180.0, &[(0.0, "#1a1b1d"), (1.0, "#2e2f32")]), + ), + ); + // The screen. + let sc = b.add( + root, + draw::stroked( + draw::rounded( + draw::rect("screen", 23.0, 19.0, 758.0, 324.0, draw::solid("#0c0d0e")), + 3.0, + ), + "#b9bcc2", + 1.4, + ), + ); + let _ = sc; + let scr = b.add(root, draw::frame("screen area", 23.0, 19.0, 758.0, 324.0)); + waveform(b, scr, (25.0, 740.0, 55.0), 496.0); + let ink = Ink::new(9.5, "#c6c8cc").weight(500.0).track(0.02).plain(); + let key = Ink::new(9.0, "#e6e7ea").weight(500.0).track(0.02).plain(); + let green = "#3e6b45"; + rk::print(b, scr, "LOOP LENGTH", (151.0, 113.0), ink); + segments( + b, + scr, + "Loop length", + &a("loop"), + (198.0, 104.0, 235.0, 20.0), + &["1/2 BAR", "1 BAR", "2 BARS", "4 BARS"], + false, + key, + green, + ); + rk::print(b, scr, "READ POINT", (509.0, 113.0), ink); + // Read point: a field that fills as it moves, its value printed. + let rp = b.add( + scr, + draw::stroked( + draw::rounded(draw::frame("Read point", 552.0, 104.0, 93.0, 20.0), 2.5), + "#3a3c40", + 0.8, + ), + ); + let fill = b.add( + rp, + draw::rect("fill", 1.5, 1.5, 90.0, 17.0, draw::solid(green)), + ); + let mut t = parts::text_node("", 6.0, 10.0, 70.0, &key, TextAlign::Left); + t.name = "value".into(); + let v = b.add(rp, t); + let mut c = Control::new(ControlKind::Fader { + orientation: Orientation::Horizontal, + }) + .slot(SlotRole::Fill, fill.0) + .slot(SlotRole::Value, v.0); + c.binding = Some(rk::bind(&a("read"))); + c.format = Some(ValueFormat::labels(rk::DIVISIONS)); + b.controls.insert(rp, c); + // Mode. + let head = "#d8d9dc"; + section(b, scr, "MODE", (17.0, 134.0, 722.0, 54.0), head, 9.5); + segments( + b, + scr, + "Mode", + &a("mode"), + (42.0, 152.0, 673.0, 19.0), + &["SEQUENCE", "PITCH DELAY", "INTERFERE", "TEXTURIZER"], + false, + key, + green, + ); + // Grain, mix, sync. + section(b, scr, "GRAIN", (17.0, 207.0, 436.0, 106.0), head, 9.5); + section(b, scr, "MIX", (471.0, 207.0, 187.0, 106.0), head, 9.5); + section(b, scr, "SYNC", (676.0, 207.0, 63.0, 106.0), head, 9.5); + let dial = |b: &mut Builder<'_>, addr: &str, label: &str, x: f64, f: ValueFormat| { + let pl = Place { + options: Some(vec!["legend_first".into()]), + ..Place::new(label) + .size(36.0) + .variant("green") + .legend(label) + .bind(rk::bind(addr)) + .touch(40.0) + .format(f) + }; + parts::put(b, scr, "knob.plugin_ring", &pl, x, 245.0) + }; + dial( + b, + &a("length"), + "Length", + 74.0, + ValueFormat::labels(rk::DIVISIONS), + ); + dial( + b, + &a("occurrence"), + "Occurrence", + 160.0, + ValueFormat::labels(rk::DIVISIONS), + ); + dial( + b, + &a("pitch"), + "Pitch Amount", + 262.0, + ValueFormat::decimals(0).unit("%"), + ); + dial( + b, + &a("chaos"), + "Chaos", + 396.0, + ValueFormat::decimals(0).unit("%"), + ); + dial( + b, + &a("filter"), + "Filter", + 529.0, + ValueFormat::decimals(0).unit("%"), + ); + dial( + b, + &a("mix"), + "Mix", + 602.0, + ValueFormat::decimals(0).unit("%"), + ); + segments( + b, + scr, + "Direction", + &a("direction"), + (311.0, 229.0, 33.0, 62.0), + &["↑", "↑↓", "↓"], + true, + key, + green, + ); + segments( + b, + scr, + "Sync", + &a("sync"), + (688.0, 225.0, 40.0, 42.0), + &["DAW", "TAP"], + true, + key, + green, + ); + let bpm = parts::readout( + b, + scr, + "Tempo", + rk::bind(&a("bpm")), + (682.0, 288.0, 52.0), + 9.5, + "#8f9297", + TextAlign::Center, + ); + parts::format(b, bpm, ValueFormat::decimals(1).unit("")); + // The foot: stomps, LEDs, the name. + let red = "#ff4a3d"; + rk::stomp( + b, + root, + "Engage", + &a("on"), + (63.0, 395.0), + 50.0, + red, + (64.0, -4.0), + 14.0, + "bezel", + false, + ); + rk::stomp( + b, + root, + "Tempo", + &a("tap"), + (735.0, 395.0), + 50.0, + red, + (-63.0, -4.0), + 14.0, + "bezel", + true, + ); + let lg = legend(9.5, WHITE); + rk::letter(b, root, "ENGAGE", (63.0, 433.0), lg); + rk::letter(b, root, "TEMPO", (735.0, 433.0), lg); + // FRACTURE, its strokes broken as the unit's lettering is. + let t = rk::letter( + b, + root, + "FRACTURE", + (400.0, 397.0), + mark(27.0, "#dfe0e2").weight(500.0).track(0.14), + ); + let _ = t; + for (x, y, cw) in [ + (292.0, 393.0, 6.0), + (345.0, 401.0, 5.0), + (417.0, 390.0, 6.0), + (470.0, 404.0, 5.0), + (505.0, 392.0, 4.0), + ] { + b.add( + root, + draw::rect("break", x, y, cw, 2.0, draw::solid("#2b2c2f")), + ); + } + rk::letter( + b, + root, + "DESIGNED IN THE FORGE", + (400.0, 432.0), + fine(6.4, "#b9bcc2"), + ); + }); + let params = vec![ + rk::choice( + &a("loop"), + "Loop Length", + &["1/2 Bar", "1 Bar", "2 Bars", "4 Bars"], + 0, + ), + rk::choice(&a("read"), "Read Point", rk::DIVISIONS, 19), + rk::choice( + &a("mode"), + "Mode", + &["Sequence", "Pitch Delay", "Interfere", "Texturizer"], + 0, + ) + .featured(), + rk::choice(&a("length"), "Grain Length", rk::DIVISIONS, 19), + rk::choice(&a("occurrence"), "Occurrence", rk::DIVISIONS, 19), + rk::percent(&a("pitch"), "Pitch Amount", 50.0), + rk::choice( + &a("direction"), + "Pitch Direction", + &["Up", "Both", "Down"], + 0, + ), + rk::percent(&a("chaos"), "Chaos", 50.0).featured(), + rk::percent(&a("filter"), "Filter", 50.0), + rk::percent(&a("mix"), "Mix", 50.0).featured(), + rk::choice(&a("sync"), "Sync", &["DAW", "Tap"], 0), + parts::param( + &a("bpm"), + "Tempo", + frame::surface::proto::Range::linear(40.0, 240.0, frame::surface::proto::Unit::Bpm), + 120.0, + ), + rk::toggle(&a("on"), "Engage", true).featured(), + rk::toggle(&a("tap"), "Tempo Tap", false), + ]; + Face { + file: "15-fx-tabletop-fracture", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_djent_forge/pre.rs b/examples/plugin-faces/src/suite_djent_forge/pre.rs new file mode 100644 index 00000000..ad65a16b --- /dev/null +++ b/examples/plugin-faces/src/suite_djent_forge/pre.rs @@ -0,0 +1,488 @@ +//! Djent Forge pre-FX: five pedals in one row, all one height. Four are the +//! Standard 125B class (156 × 286), the last a Micro (92 × 286). +//! +//! What they share, as the plugin draws them: a powder-coated lid, glossy +//! black fluted knobs with a white line, bold semi-condensed legends, the +//! model name in wide engraved capitals across the middle, a chrome-rimmed +//! blue LED over a knurled stomp switch, and under the switch either the +//! maker's mark (invented, in heavy geometric capitals) or a thin printed +//! rule over a "designed in" line. Rows sit where the reference's do. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::Place; +use frame::surface::widgets::Builder; + +use super::type_::{fine, legend, mark, name}; +use super::{BLUE, LED_BLUE, PLUGIN, pedal_knob}; +use crate::delay::parts; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Enclosure, KnobStyle, lcd}; + +pub fn faces() -> Vec { + vec![ + compressor(), + tape_drift(), + twin_octave(), + tight_drive(), + havoc(), + ] +} + +/// The pedals' knob, 38 across (the Micro's is bigger). +const KNOB: KnobStyle<'static> = pedal_knob(38.0); + +/// Where the reference's rows fall on a 286-tall box: the knob legends +/// under the top and lower rows, the name, the LED, the switch, and the +/// mark or rule under it — the LED and switch sit high enough that the +/// mark has the bottom band to itself. +const LEGEND_TOP: f64 = 62.0; +const LEGEND_LOW: f64 = 115.5; +const NAME_Y: f64 = 134.5; +const LED_Y: f64 = 157.0; +const SWITCH_Y: f64 = 190.0; +const MARK_Y: f64 = 232.0; +const RULE_Y: f64 = 233.0; + +/// The maker's mark, as the silver and navy boxes print it: a heavy +/// geometric word over a second line spread letter by letter to its width. +fn maker_mark(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, w: f64, color: &str) { + let (_, size) = rk::letter_fit( + b, + parent, + "AZIMUTH", + (cx, cy), + w, + mark(20.0, color).track(0.01), + ); + let l = mark(5.0, color).weight(500.0); + let chars: Vec = "CIRCUITS".chars().collect(); + let n = chars.len() as f64; + for (i, ch) in chars.iter().enumerate() { + let x = cx - w / 2.0 + 2.5 + (w - 5.0) * i as f64 / (n - 1.0); + rk::letter(b, parent, &ch.to_string(), (x, cy + size * 0.5 + 6.0), l); + } +} + +/// The LED over the stomp switch every box shares. +fn stomp_band(b: &mut Builder<'_>, parent: NodeId, w: f64, addr: &str, d: f64) { + rk::stomp( + b, + parent, + "Bypass", + addr, + (w / 2.0, SWITCH_Y), + d, + LED_BLUE, + (0.0, LED_Y - SWITCH_Y), + 17.0, + "bezel", + false, + ); +} + +/// The printed rule with a small notch over the "designed by" line (the +/// green and champagne boxes). +fn rule_and_line( + b: &mut Builder<'_>, + parent: NodeId, + w: f64, + y: f64, + color: &str, + notch_down: bool, +) { + let (x0, x1, cx) = (w * 0.2, w * 0.8, w / 2.0); + let k = if notch_down { 1.0 } else { -1.0 }; + rk::line( + b, + parent, + &[ + (x0, y), + (cx - 7.0, y), + (cx, y + 5.0 * k), + (cx + 7.0, y), + (x1, y), + ], + 0.8, + color, + ); + rk::letter_fit( + b, + parent, + "DESIGNED IN THE FORGE", + (cx, y + 16.0), + w * 0.7, + fine(4.6, color), + ); +} + +// ── 01 · Skyline Compressor ────────────────────────────────────────────── + +/// Skyline Compressor — Pedal, Standard (156 × 286). Silver powder coat, +/// blue lettering; DETAIL, MIX, VOLUME, COMP round an INTENSITY dome +/// button; a row of ten blue LEDs reads gain reduction (−1 … −20 dB, lit +/// from the right). +fn compressor() -> Face { + let ns = "dfcomp"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Skyline Compressor", |b, root| { + let e = Enclosure::new("standard", "#b9bbbe", "powder").base("#8e9094"); + let p = rk::pedal_body(b, root, &e); + let (w, _) = e.size(); + let lg = legend(7.4, BLUE); + let knob_at = [ + ("detail", "Detail", (30.0, 35.0), LEGEND_TOP), + ("mix", "Mix", (w - 30.0, 35.0), LEGEND_TOP), + ("volume", "Volume", (30.0, 89.0), LEGEND_LOW), + ("comp", "Comp", (w - 30.0, 89.0), LEGEND_LOW), + ]; + for (addr, label, at, ly) in knob_at { + rk::knob_l(b, p, &KNOB, &a(addr), label, at, ly, lg); + } + // The GR meter: a black slot with ten lamps, printed thresholds over it. + let (mx0, mx1, my) = (46.0, 112.0, 17.5); + draw_slot(b, p, (mx0 - 2.0, my - 4.4, mx1 - mx0 + 4.0, 8.8)); + rk::gr_dots( + b, + p, + "Compression", + &a("gr"), + (mx0, mx1, my), + 4.8, + &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 10.0, 15.0, 20.0], + LED_BLUE, + true, + ); + for (i, l) in ["-20", "-10", "-7", "-5", "-3", "-1"].iter().enumerate() { + rk::letter( + b, + p, + l, + (mx0 + 4.0 + (mx1 - mx0 - 8.0) * i as f64 / 5.0, 8.6), + legend(5.2, BLUE), + ); + } + rk::letter(b, p, "COMPRESSION", (w / 2.0, 26.6), lg); + let dome = Place::new("Intensity") + .size(22.0) + .variant("chrome") + .bind(rk::bind(&a("intensity"))) + .touch(frame::surface::kit::TOUCH_MIN); + let dome = parts::put(b, p, "switch.push_dome", &dome, w / 2.0, 62.0); + rk::label_l(b, dome, "INTENSITY", (w / 2.0, 81.0), lg); + + rk::letter_fit( + b, + p, + "COMPRESSOR", + (w / 2.0, NAME_Y), + 117.0, + name(11.0, BLUE), + ); + stomp_band(b, p, w, &a("on"), 40.0); + maker_mark(b, p, w / 2.0, MARK_Y, w * 0.76, BLUE); + }); + let params = vec![ + rk::percent(&a("detail"), "Detail", 50.0), + rk::percent(&a("mix"), "Mix", 50.0).featured(), + rk::db(&a("volume"), "Volume", -12.0, 12.0, 0.0).featured(), + rk::percent(&a("comp"), "Comp", 50.0).featured(), + rk::toggle(&a("intensity"), "Intensity", false), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "01-pre-standard-compressor", + plugin: PLUGIN, + scene, + params, + streams: vec![rk::gr_stream(&a("gr"), "Compression")], + } +} + +/// A recessed black slot (the GR meter's window). +fn draw_slot(b: &mut Builder<'_>, parent: NodeId, r: (f64, f64, f64, f64)) -> NodeId { + let fill = frame::surface::kit::draw::linear(180.0, &[(0.0, "#050506"), (1.0, "#1c1d20")]); + parts::well(b, parent, "meter slot", r, fill, r.3 / 2.0) +} + +/// The legend ink every box but the silver one prints in: white. +const WHITE: &str = "#f4f4f1"; + +/// The five-knob layout the green box uses and the others vary: two +/// knobs up top at the edges, a row of three below. +const TOP_L: (f64, f64) = (28.0, 34.0); +const TOP_R: (f64, f64) = (128.0, 34.0); +const LOW_L: (f64, f64) = (28.0, 88.0); +const LOW_M: (f64, f64) = (78.0, 88.0); +const LOW_R: (f64, f64) = (128.0, 88.0); + +// ── 02 · Tape Drift ────────────────────────────────────────────────────── + +/// Tape Drift — Pedal, Standard (156 × 286). A tape-echo box in sage green +/// powder coat: MIX and FEED up top either side of a small dark LCD that +/// reads the time ("2000 MS") in fourteen segments over their unlit +/// ghosts, a MS / NOTE mini toggle under it, TIME, MOD and TONE below; a +/// printed rule and "designed in" line under the switch. +fn tape_drift() -> Face { + let ns = "dftape"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Tape Drift", |b, root| { + let e = Enclosure::new("standard", "#77886a", "powder").base("#4f5c46"); + let p = rk::pedal_body(b, root, &e); + let (w, _) = e.size(); + let lg = legend(7.4, WHITE); + rk::knob_l(b, p, &KNOB, &a("mix"), "Mix", TOP_L, LEGEND_TOP, lg); + rk::knob_l(b, p, &KNOB, &a("feedback"), "Feed", TOP_R, LEGEND_TOP, lg); + rk::knob_l(b, p, &KNOB, &a("time"), "Time", LOW_L, LEGEND_LOW, lg); + rk::knob_l( + b, + p, + &KNOB.touch(38.0), + &a("mod"), + "Mod", + LOW_M, + LEGEND_LOW, + lg, + ); + rk::knob_l(b, p, &KNOB, &a("tone"), "Tone", LOW_R, LEGEND_LOW, lg); + // The time readout: a small dark LCD, 14-segment characters. + let win = parts::screen( + b, + p, + "Time display", + (53.0, 9.5, 50.0, 19.0), + parts::Screen { + glass: ("#0a0b0c", "#17181a"), + ink: "#9a9c9e", + mesh: false, + }, + ); + let seg = lcd::seg14(7.4, "#d2d4d6").track(0.04); + lcd::readout( + b, + win, + "Time", + rk::bind(&a("time")), + (1.0, 9.5, 48.0), + seg, + TextAlign::Center, + None, + frame::surface::proto::ValueFormat::decimals(0).unit(" MS"), + ); + // MS / NOTE: the time runs free or locks to a note. + let tm = Place::new("Time mode") + .size(9.0) + .variant("black") + .bind(rk::bind(&a("sync"))) + .legend("") + .touch(18.0); + let tm = parts::put(b, p, "switch.toggle_nut", &tm, 78.0, 49.0); + parts::strip_legend(b, tm); + let small = legend(5.8, WHITE); + rk::letter_aligned(b, p, "• MS", (87.0, 42.0), small, TextAlign::Left); + rk::letter_aligned(b, p, "• NOTE", (87.0, 58.0), small, TextAlign::Left); + rk::letter_fit( + b, + p, + "TAPE DRIFT", + (w / 2.0, NAME_Y), + 96.0, + name(11.0, WHITE), + ); + stomp_band(b, p, w, &a("on"), 40.0); + rule_and_line(b, p, w, RULE_Y, "#eef0ea", false); + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 50.0).featured(), + rk::percent(&a("feedback"), "Feed", 50.0).featured(), + parts::param( + &a("time"), + "Time", + frame::surface::proto::Range::linear( + 40.0, + 3960.0, + frame::surface::proto::Unit::Milliseconds, + ), + 2000.0, + ) + .featured(), + rk::percent(&a("mod"), "Mod", 50.0), + rk::percent(&a("tone"), "Tone", 50.0), + rk::toggle(&a("sync"), "Note Sync", false), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "02-pre-standard-tape-drift", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 03 · Twin Octave ───────────────────────────────────────────────────── + +/// Twin Octave — Pedal, Standard (156 × 286). An octave box in champagne +/// powder coat: OCT−1 and OCT+1 up top at the edges, DIRECT alone below +/// between them; the same rule and line under the switch. +fn twin_octave() -> Face { + let ns = "dfoct"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Twin Octave", |b, root| { + let e = Enclosure::new("standard", "#bfb094", "powder").base("#8e836d"); + let p = rk::pedal_body(b, root, &e); + let (w, _) = e.size(); + let lg = legend(7.4, WHITE); + rk::knob_l(b, p, &KNOB, &a("sub"), "Oct-1", TOP_L, LEGEND_TOP, lg); + rk::knob_l(b, p, &KNOB, &a("up"), "Oct+1", TOP_R, LEGEND_TOP, lg); + rk::knob_l(b, p, &KNOB, &a("direct"), "Direct", LOW_M, LEGEND_LOW, lg); + rk::letter_fit( + b, + p, + "TWIN OCTAVE", + (w / 2.0, NAME_Y), + 128.0, + name(11.0, WHITE), + ); + stomp_band(b, p, w, &a("on"), 40.0); + rule_and_line(b, p, w, RULE_Y, "#f6f2ea", true); + }); + let params = vec![ + rk::percent(&a("sub"), "Octave Down", 50.0).featured(), + rk::percent(&a("up"), "Octave Up", 50.0).featured(), + rk::percent(&a("direct"), "Direct", 50.0).featured(), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "03-pre-standard-twin-octave", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 04 · Tight Drive ───────────────────────────────────────────────────── + +/// Tight Drive — Pedal, Standard (156 × 286). A tight-bass overdrive in +/// navy sparkle: VOL and BRIGHT up top pulled in from the edges, ATTACK and +/// DRIVE lower at the edges, a small white GATE knob lit from inside +/// between them; the model name in two lines of wide blue capitals and the +/// maker's mark in blue. +fn tight_drive() -> Face { + let ns = "dfdrive"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Tight Drive", |b, root| { + let e = Enclosure::new("standard", "#23262e", "powder:coarse").base("#15171c"); + let p = rk::pedal_body(b, root, &e); + let (w, _) = e.size(); + let ink = "#2a78c8"; + let lg = legend(7.4, ink); + rk::knob_l(b, p, &KNOB, &a("volume"), "Vol", (51.0, 25.0), 53.0, lg); + rk::knob_l(b, p, &KNOB, &a("bright"), "Bright", (103.0, 25.0), 53.0, lg); + rk::knob_l(b, p, &KNOB, &a("attack"), "Attack", (29.0, 76.0), 104.0, lg); + rk::knob_l(b, p, &KNOB, &a("drive"), "Drive", (126.0, 74.0), 104.0, lg); + // The gate: a small translucent knob with a blue lamp under it. + let glow = frame::surface::kit::draw::circle( + "gate glow", + 78.0, + 88.5, + 14.0, + frame::surface::kit::draw::radial(&[ + (0.0, "#8fd0ffcc"), + (0.55, "#3f8fe066"), + (1.0, "#3f8fe000"), + ]), + ); + b.add(p, parts::emit(glow, "#6fb8ff", 0.8, 14.0)); + let gate = super::knob( + "fluted", + 21.0, + "#ffffff", + &[ + "color=#e9f1ff", + "pointer=#1a2a44", + "ticks=none", + "ribs=9", + "rib_depth=0.1", + "cap_r=0.96", + "height=0.6", + "rough=0.3", + "coat=0.5", + "spec=1.4", + "index=line", + "index_in=0.3", + "index_out=0.9", + ], + ); + rk::knob_l(b, p, &gate, &a("gate"), "Gate", (78.0, 88.5), 71.0, lg); + let title = name(9.6, ink).track(0.2); + rk::letter(b, p, "TIGHT", (w / 2.0, 124.5), title); + rk::letter(b, p, "DRIVE", (w / 2.0, 138.5), title); + stomp_band(b, p, w, &a("on"), 40.0); + maker_mark(b, p, w / 2.0, MARK_Y, w * 0.76, "#1f6ab8"); + }); + let params = vec![ + rk::db(&a("volume"), "Volume", -24.0, 24.0, 0.0).featured(), + rk::percent(&a("bright"), "Bright", 50.0), + rk::percent(&a("attack"), "Attack", 36.0), + rk::percent(&a("drive"), "Drive", 42.0).featured(), + rk::percent(&a("gate"), "Gate", 0.0), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "04-pre-standard-tight-drive", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 05 · Havoc ─────────────────────────────────────────────────────────── + +/// Havoc — Pedal, Micro (92 × 286). A narrow oxblood box with one big MIX +/// knob, the name in wide capitals, and a printed jagged squiggle over a +/// two-line "designed in" credit under the switch. +fn havoc() -> Face { + let ns = "dfhavoc"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("Havoc", |b, root| { + let e = Enclosure::new("micro", "#7a3531", "powder").base("#4f2320"); + let p = rk::pedal_body(b, root, &e); + let (w, _) = e.size(); + rk::knob_l( + b, + p, + &pedal_knob(58.0), + &a("mix"), + "Mix", + (w / 2.0, 42.0), + 94.0, + legend(8.4, WHITE), + ); + rk::letter_fit(b, p, "HAVOC", (w / 2.0, NAME_Y), 58.0, name(11.0, WHITE)); + stomp_band(b, p, w, &a("on"), 36.0); + // The squiggle: a flat line that breaks into a few jagged teeth. + let y = RULE_Y; + let mut pts = vec![(14.0, y), (34.0, y)]; + for (i, dx) in [0.0, 3.0, 6.0, 9.0, 12.0, 15.0, 18.0].iter().enumerate() { + pts.push((36.0 + dx, if i % 2 == 0 { y - 4.5 } else { y + 0.5 })); + } + pts.extend([(57.0, y), (78.0, y)]); + rk::line(b, p, &pts, 0.8, "#f1e8e4"); + let small = fine(4.3, "#f1e8e4").track(0.2); + rk::letter(b, p, "DESIGNED", (w / 2.0, y + 12.0), small); + rk::letter(b, p, "IN THE FORGE", (w / 2.0, y + 20.5), small); + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 50.0).featured(), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "05-pre-micro-havoc", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_djent_forge/rack.rs b/examples/plugin-faces/src/suite_djent_forge/rack.rs new file mode 100644 index 00000000..07cf451a --- /dev/null +++ b/examples/plugin-faces/src/suite_djent_forge/rack.rs @@ -0,0 +1,956 @@ +//! Djent Forge cab and EQ. +//! +//! - **2×12 Stack Cab** — Cab, 900 × 900: the dark cast 2×2 cabinet the +//! plugin's cab page shows, two mics on the cones, and the page's two mic +//! columns (mic choice, on, position, distance, level, pan, phase) and the +//! room send, in the plugin's own light chrome. +//! - **Ivory Nine-Band** — Tabletop, 1000 × 290: a white machined graphic +//! EQ on turned feet — HPF and LPF cup knobs reading OFF, nine pearl +//! sliders 65 Hz – 16 kHz reading dB, an orange power toggle and pilot. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{ + Axis, ButtonMode, Control, ControlKind, HandleAxis, SlotRole, ValueFormat, +}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk}; + +pub fn faces() -> Vec { + vec![cab(), eq()] +} + +/// The mics the cab page offers (generic names). +const MICS: &[&str] = &[ + "Dynamic A", + "Dyn A Off-Axis", + "Dynamic B", + "Condenser A", + "Condenser B", + "Ribbon A", + "Ribbon B", +]; + +/// The chrome's ink on its light stage. +const CHROME_INK: &str = "#23262c"; + +/// The EQ's filter knobs (`knob.3d` `stepped_cream`): a white cylinder with +/// a lower step round its top, an orange dot on the rim for the index. +const CUP: &[&str] = &[ + "color=#f3f3f2", + "pointer=#ef6a2c", + "ticks=none", + "rough=0.42", + "height=0.34", + "step_r=0.72", + "step_h=0.05", + "edge=0.07", + "index_w=0.075", + "index_in=0.87", + "index_out=0.87", + "shadow=0.8", + "fallback=domed", +]; + +/// A drop-down that picks one of a choice param's options: a rounded box +/// printing the choice, a chevron; a `Selector` (wheel / arrows step it). +fn dropdown( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (x, y, w, h): (f64, f64, f64, f64), + options: &[&str], + dark: bool, +) -> NodeId { + let (fill, ink) = if dark { + ("#111214", "#f2f2f4") + } else { + ("#e3e6f0", CHROME_INK) + }; + let mut f = draw::frame(name, x, y, w, h); + f.fills = vec![draw::solid(fill)]; + f.clips_content = true; + let root = b.add( + parent, + draw::stroked( + draw::rounded(f, 6.0), + if dark { "#000000" } else { "#b8bdd0" }, + 1.0, + ), + ); + let t = parts::text_node( + "", + 12.0, + h / 2.0, + w - 34.0, + &Ink::new(11.5, ink).weight(500.0).track(0.0).plain(), + TextAlign::Left, + ); + let mut t = t; + t.name = "value".into(); + let value = b.add(root, t); + let (cx, cy) = (w - 16.0, h / 2.0); + rk::line( + b, + root, + &[(cx - 5.0, cy - 2.5), (cx, cy + 2.5), (cx + 5.0, cy - 2.5)], + 1.5, + ink, + ); + let mut c = Control::new(ControlKind::Selector).slot(SlotRole::Value, value.0); + c.binding = Some(rk::bind(addr)); + c.format = Some(ValueFormat::labels(options)); + b.controls.insert(root, c); + root +} + +/// A square outlined button that latches (mic on/off, phase): its fill +/// shows while latched. +fn latch( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (x, y, w, h): (f64, f64, f64, f64), + text: &str, +) -> NodeId { + let root = b.add( + parent, + draw::stroked( + draw::rounded(draw::frame(name, x, y, w, h), 6.0), + "#7d86a8", + 1.2, + ), + ); + let on = b.add( + root, + draw::rounded( + draw::rect( + "latched", + 1.0, + 1.0, + w - 2.0, + h - 2.0, + draw::solid("#b9c0dc"), + ), + 5.0, + ), + ); + if !text.is_empty() { + rk::print( + b, + root, + text, + (w / 2.0, h / 2.0), + Ink::new(12.0, CHROME_INK).weight(600.0).track(0.06), + ); + } + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, on.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// The chrome's pill switch: a dark track, the thumb at the right when on. +fn pill( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), +) -> NodeId { + let (w, h) = (TOUCH_MIN, TOUCH_MIN); + let root = b.add(parent, draw::frame(name, cx - w / 2.0, cy - h / 2.0, w, h)); + b.add( + root, + draw::stroked( + draw::rounded( + draw::rect( + "track", + 6.0, + h / 2.0 - 9.0, + 32.0, + 18.0, + draw::solid("#dfe3f0"), + ), + 9.0, + ), + CHROME_INK, + 2.0, + ), + ); + b.add( + root, + draw::stroked( + draw::circle("thumb off", 15.0, h / 2.0, 5.0, draw::solid("#00000000")), + CHROME_INK, + 1.6, + ), + ); + let on = b.add( + root, + draw::rounded( + draw::rect( + "on", + 6.0, + h / 2.0 - 9.0, + 32.0, + 18.0, + draw::solid(CHROME_INK), + ), + 9.0, + ), + ); + let _ = on; + let thumb = b.add( + root, + draw::circle("thumb", 29.0, h / 2.0, 6.0, draw::solid("#ffffff")), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, on.0) + .slot(SlotRole::Led, thumb.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +fn ring( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + legend: &str, + (cx, cy): (f64, f64), + d: f64, + f: Option, +) -> NodeId { + let mut p = Place::new(legend) + .size(d) + .variant("light") + .legend(legend) + .bind(rk::bind(addr)) + .touch(TOUCH_MIN); + if let Some(f) = f { + p = p.format(f); + } + parts::put(b, parent, "knob.plugin_ring", &p, cx, cy) +} + +/// A 12-inch speaker seen through the baffle: the cut-out's rounded lip, +/// the surround, the cone, the dust cap. +fn speaker(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64), r: f64) { + b.add( + parent, + draw::with_effects( + draw::circle( + "cut-out", + cx, + cy, + r * 1.12, + draw::radial(&[(0.0, "#0e0f10"), (0.85, "#1a1b1d"), (1.0, "#2e3033")]), + ), + vec![draw::inner_shadow("#000000", 0.0, 4.0, 10.0)], + ), + ); + b.add( + parent, + draw::circle( + "basket", + cx, + cy, + r, + draw::conic( + 30.0, + &[ + (0.0, "#2a2b2e"), + (0.25, "#4a4c50"), + (0.5, "#1c1d1f"), + (0.75, "#3e4044"), + (1.0, "#2a2b2e"), + ], + ), + ), + ); + b.add( + parent, + draw::circle( + "surround", + cx, + cy, + r * 0.93, + draw::radial(&[ + (0.0, "#101112"), + (0.86, "#141517"), + (0.93, "#3a3c40"), + (1.0, "#0c0c0d"), + ]), + ), + ); + b.add( + parent, + draw::circle( + "cone", + cx, + cy, + r * 0.82, + draw::radial_at( + 0.5, + 0.5, + 0.5, + &[ + (0.0, "#26272a"), + (0.4, "#3a3c40"), + (0.75, "#4c4e53"), + (1.0, "#1e1f22"), + ], + ), + ), + ); + b.add( + parent, + draw::circle( + "cone light", + cx - r * 0.2, + cy - r * 0.25, + r * 0.6, + draw::radial(&[(0.0, "#ffffff18"), (1.0, "#ffffff00")]), + ), + ); + b.add( + parent, + draw::with_effects( + draw::circle( + "dust cap", + cx, + cy, + r * 0.32, + draw::radial_at( + 0.4, + 0.35, + 0.8, + &[(0.0, "#5e6166"), (0.6, "#2c2d31"), (1.0, "#141517")], + ), + ), + vec![draw::drop_shadow("#00000099", 0.0, 3.0, 6.0)], + ), + ); +} + +/// A mic laid across a cone, aimed in from the side, on a `Handle`: its +/// grille follows the mic's position (from the dust cap out to the rim) +/// and distance (down the cone, away from the speaker's centre line). The +/// body runs out towards `side` (-1 left, +1 right) past the rim. +fn mic( + b: &mut Builder<'_>, + parent: NodeId, + ns: &str, + which: &str, + (cx, cy): (f64, f64), + r: f64, + side: f64, +) { + let a = |s: &str| format!("{ns}/{which}_{s}"); + let (aw, ah) = (r * 0.85, r * 0.42); + let ax = if side < 0.0 { cx - aw } else { cx }; + let area = b.add( + parent, + draw::frame(&format!("Mic {which} travel"), ax, cy - 4.0, aw, ah), + ); + let t = 44.0; + let h = b.add( + area, + draw::frame( + &format!("Mic {} on cone", which.to_uppercase()), + 0.0, + 0.0, + t, + t, + ), + ); + let (c, len, thick) = (t / 2.0, 110.0, 16.0); + // The body, the clip ring and the cable end, running out from the head. + let (bx0, bx1) = if side < 0.0 { + (c - len, c - 8.0) + } else { + (c + 8.0, c + len) + }; + let body = draw::rounded( + draw::rect( + "mic body", + bx0, + c - thick / 2.0, + bx1 - bx0, + thick, + draw::linear( + 180.0, + &[ + (0.0, "#5b5e64"), + (0.3, "#26272b"), + (0.7, "#101113"), + (1.0, "#2a2b2f"), + ], + ), + ), + thick / 2.0, + ); + b.add( + h, + draw::with_effects(body, vec![draw::drop_shadow("#000000a6", 6.0, 10.0, 9.0)]), + ); + let ring_x = if side < 0.0 { c - 30.0 } else { c + 26.0 }; + b.add( + h, + draw::rect( + "mic ring", + ring_x, + c - thick / 2.0 - 0.5, + 4.0, + thick + 1.0, + draw::linear( + 180.0, + &[(0.0, "#d8dadd"), (0.5, "#6a6c70"), (1.0, "#b8babd")], + ), + ), + ); + // The grille: a shorter, lighter barrel of mesh at the tip. + let (gx0, gw) = if side < 0.0 { + (c - 9.0, 13.0) + } else { + (c - 4.0, 13.0) + }; + let grille = draw::rounded( + draw::rect( + "mic grille", + gx0, + c - 7.5, + gw, + 15.0, + draw::linear( + 180.0, + &[(0.0, "#9ea1a6"), (0.45, "#4c4e53"), (1.0, "#6d7075")], + ), + ), + 4.5, + ); + b.add( + h, + draw::with_effects(grille, vec![draw::drop_shadow("#00000099", 4.0, 7.0, 6.0)]), + ); + let mesh: Vec> = (0..5) + .map(|k| { + let x = gx0 + 2.0 + 2.2 * f64::from(k); + vec![(x, c - 6.5), (x, c + 6.5)] + }) + .collect(); + b.add( + h, + parts::stroke_path("mic mesh", (0.0, 0.0, t, t), &mesh, 0.6, "#2a2b2e", false), + ); + let kind = ControlKind::Handle { + x: Some(rk::bind(&a("position"))), + y: Some(rk::bind(&a("distance"))), + wheel: None, + x_map: HandleAxis::new(if side < 0.0 { + Axis::Linear { + min: 100.0, + max: 0.0, + } + } else { + Axis::Linear { + min: 0.0, + max: 100.0, + } + }), + y_map: HandleAxis::new(Axis::Linear { + min: 100.0, + max: 0.0, + }), + }; + b.controls.insert(h, Control::new(kind)); +} + +/// One mic's column of controls, its left edge at `x`. +#[allow(clippy::too_many_lines)] +fn mic_column(b: &mut Builder<'_>, root: NodeId, ns: &str, which: &str, x: f64, dark_menu: bool) { + let a = |s: &str| format!("{ns}/{which}_{s}"); + let label = if which == "a" { "Mic A" } else { "Mic B" }; + rk::print( + b, + root, + label, + (x + 80.0, 150.0), + Ink::new(11.0, CHROME_INK).weight(700.0).track(0.12), + ); + dropdown( + b, + root, + &format!("{label} choice"), + &a("mic"), + (x, 170.0, 116.0, 36.0), + MICS, + dark_menu, + ); + let on = latch( + b, + root, + &format!("{label} on"), + &a("on"), + (x + 122.0, 170.0, 40.0, 36.0), + "", + ); + // The power glyph: a broken ring and a bar. + let (gx, gy, gr) = (20.0, 18.0, 8.0); + let arc: Vec<(f64, f64)> = (0..=24) + .map(|i| draw::polar(gx, gy, gr, 35.0 + 290.0 * f64::from(i) / 24.0)) + .collect(); + rk::line(b, on, &arc, 1.6, CHROME_INK); + rk::line( + b, + on, + &[(gx, gy - gr - 1.5), (gx, gy - 1.0)], + 1.6, + CHROME_INK, + ); + ring( + b, + root, + &a("position"), + "Position", + (x + 38.0, 262.0), + 58.0, + None, + ); + ring( + b, + root, + &a("distance"), + "Distance", + (x + 124.0, 262.0), + 58.0, + None, + ); + ring( + b, + root, + &a("level"), + "Mic Level", + (x + 38.0, 380.0), + 58.0, + Some(ValueFormat::decimals(1).signed()), + ); + // Pan reads C at the centre. + ring( + b, + root, + &a("pan"), + "Pan", + (x + 124.0, 380.0), + 58.0, + Some(ValueFormat::decimals(0).mark(0.0, "C")), + ); + rk::line(b, root, &[(x, 470.0), (x + 162.0, 470.0)], 1.0, "#aab1c8"); + latch( + b, + root, + &format!("{label} phase"), + &a("phase"), + (x + 36.0, 800.0, 90.0, 32.0), + "PHASE Ø", + ); +} + +fn mic_params(ns: &str, which: &str, default_mic: usize) -> Vec { + let a = |s: &str| format!("{ns}/{which}_{s}"); + let label = if which == "a" { "Mic A" } else { "Mic B" }; + vec![ + rk::choice(&a("mic"), &format!("{label} Model"), MICS, default_mic).featured(), + rk::toggle(&a("on"), &format!("{label} On"), true), + rk::percent(&a("position"), &format!("{label} Position"), 50.0), + rk::percent(&a("distance"), &format!("{label} Distance"), 0.0), + rk::db(&a("level"), &format!("{label} Level"), -24.0, 12.0, 0.0), + { + let r = frame::surface::proto::Range { + steps: Some(201), + ..frame::surface::proto::Range::linear( + -100.0, + 100.0, + frame::surface::proto::Unit::Percent, + ) + }; + parts::param(&a("pan"), &format!("{label} Pan"), r, 0.0).bipolar() + }, + rk::toggle(&a("phase"), &format!("{label} Phase"), false), + ] +} + +// ── 09 · 2×12 Stack Cab ────────────────────────────────────────────────── + +/// 2×12 Stack Cab — Cab, 900 × 900. See the module docs. +fn cab() -> Face { + let ns = "dfcab"; + let a = |s: &str| format!("{ns}/{s}"); + let (w, h) = (900.0, 900.0); + let scene = canvas("Stack Cab", |b, root| { + parts::fit_root(b, root, w, h); + let stage = draw::rounded( + draw::rect( + "stage", + 0.0, + 0.0, + w, + h, + draw::linear(180.0, &[(0.0, "#d3d9ea"), (1.0, "#c3cadf")]), + ), + 18.0, + ); + b.add(root, stage); + // The cabinet: dark cast, a raised baffle, four speakers. + let cab = draw::rounded( + draw::rect( + "cabinet", + 205.0, + 200.0, + 490.0, + 580.0, + draw::linear(180.0, &[(0.0, "#3d3e41"), (1.0, "#2b2c2f")]), + ), + 10.0, + ); + b.add( + root, + draw::stroked( + draw::with_effects( + cab, + vec![ + draw::drop_shadow("#00000066", 0.0, 10.0, 20.0), + draw::inner_shadow("#ffffff1f", 0.0, 2.0, 1.0), + ], + ), + "#141516", + 1.5, + ), + ); + let baffle = parts::well( + b, + root, + "baffle", + (222.0, 217.0, 456.0, 546.0), + draw::linear(180.0, &[(0.0, "#3a3b3e"), (1.0, "#2f3033")]), + 4.0, + ); + for (x, y) in [ + (12.0, 12.0), + (444.0, 12.0), + (12.0, 534.0), + (444.0, 534.0), + (12.0, 273.0), + (444.0, 273.0), + ] { + b.add( + baffle, + draw::circle( + "screw", + x, + y, + 3.2, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#8a8c90"), (1.0, "#1a1b1d")]), + ), + ); + } + for (cx, cy) in [ + (116.0, 146.0), + (340.0, 146.0), + (116.0, 400.0), + (340.0, 400.0), + ] { + speaker(b, baffle, (cx, cy), 98.0); + } + mic(b, baffle, ns, "a", (116.0, 146.0), 98.0, -1.0); + mic(b, baffle, ns, "b", (340.0, 146.0), 98.0, 1.0); + // The page's controls: the two mic columns and the room send. + mic_column(b, root, ns, "a", 22.0, true); + mic_column(b, root, ns, "b", 716.0, false); + rk::print( + b, + root, + "Room Send", + (w / 2.0 - 18.0, 70.0), + Ink::new(13.0, CHROME_INK).weight(700.0).track(0.06), + ); + pill(b, root, "Room send", &a("room"), (w / 2.0 + 62.0, 70.0)); + ring( + b, + root, + &a("room_level"), + "Level", + (w / 2.0, 118.0), + 40.0, + Some(ValueFormat::decimals(1).signed()), + ); + // A room column echoes the level under mic B, as the page does. + rk::print( + b, + root, + "Cab", + (w / 2.0, 885.0), + Ink::new(9.0, "#6b7392").weight(600.0).track(0.3), + ); + }); + let mut params = mic_params(ns, "a", 1); + params.extend(mic_params(ns, "b", 6)); + params.push(rk::toggle(&a("room"), "Room Send", false)); + params.push(rk::db(&a("room_level"), "Room Level", -24.0, 12.0, 0.0)); + Face { + file: "09-cab-2x12", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 10 · Ivory Nine-Band ───────────────────────────────────────────────── + +/// The bands, as the unit prints them. +const BANDS: [(&str, &str, f64); 9] = [ + ("b65", "65 Hz", 65.0), + ("b125", "125 Hz", 125.0), + ("b250", "250 Hz", 250.0), + ("b500", "500 Hz", 500.0), + ("b1k", "1 kHz", 1000.0), + ("b2k", "2 kHz", 2000.0), + ("b4k", "4 kHz", 4000.0), + ("b8k", "8 kHz", 8000.0), + ("b16k", "16 kHz", 16000.0), +]; + +/// Ivory Nine-Band — Tabletop, 1000 × 290. See the module docs. +#[allow(clippy::too_many_lines)] +fn eq() -> Face { + let ns = "dfeq"; + let a = |s: &str| format!("{ns}/{s}"); + let (w, h) = (1000.0, 290.0); + let scene = canvas("Ivory Nine-Band", |b, root| { + parts::fit_root(b, root, w, h); + for x in [74.0, 840.0] { + b.add( + root, + draw::rounded( + draw::rect( + "foot", + x, + 246.0, + 86.0, + 40.0, + draw::linear( + 90.0, + &[ + (0.0, "#1a1b1d"), + (0.35, "#8a8b8e"), + (0.55, "#3e3f42"), + (1.0, "#141516"), + ], + ), + ), + 3.0, + ), + ); + } + let body = draw::rounded( + draw::rect( + "case", + 0.0, + 0.0, + w, + 254.0, + draw::linear( + 180.0, + &[(0.0, "#f4f5f6"), (0.5, "#e6e7e9"), (1.0, "#cfd1d5")], + ), + ), + 36.0, + ); + b.add( + root, + draw::stroked( + draw::with_effects( + body, + vec![ + draw::drop_shadow("#00000040", 0.0, 10.0, 18.0), + draw::inner_shadow("#ffffffc0", 0.0, 2.0, 1.0), + ], + ), + "#b3b6bc", + 1.0, + ), + ); + let face = draw::rounded( + draw::rect( + "face", + 29.0, + 31.0, + 942.0, + 193.0, + draw::linear(180.0, &[(0.0, "#eeeff1"), (1.0, "#e3e4e7")]), + ), + 8.0, + ); + b.add( + root, + draw::with_effects( + face, + vec![ + draw::inner_shadow("#00000026", 0.0, 1.5, 3.0), + draw::drop_shadow("#ffffffa0", 0.0, 1.0, 0.0), + ], + ), + ); + for (x, y) in [(48.0, 47.0), (955.0, 47.0), (48.0, 207.0), (955.0, 207.0)] { + b.add( + root, + draw::circle( + "screw", + x, + y, + 5.0, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#ffffff"), (1.0, "#9a9da3")]), + ), + ); + rk::line(b, root, &[(x - 3.0, y), (x + 3.0, y)], 1.0, "#6d7076"); + rk::line(b, root, &[(x, y - 3.0), (x, y + 3.0)], 1.0, "#6d7076"); + } + for x in [72.0, 928.0] { + for y in [0.0, 247.0] { + rk::line(b, root, &[(x, y + 3.0), (x, y + 10.0)], 1.0, "#8d9096"); + rk::line( + b, + root, + &[(x + 4.0, y + 3.0), (x + 4.0, y + 10.0)], + 1.0, + "#8d9096", + ); + } + } + let ink = Ink::new(11.5, "#6b6e75").weight(500.0).track(0.0).plain(); + // HPF and LPF: cup knobs with a value readout over them. + // Each reads OFF at its stop (HPF all the way down, LPF all the way up). + let filt = [ + ("hpf", "HPF", 160.0, "M -26 10 L -14 -2 L 26 -2", 20.0), + ("lpf", "LPF", 840.0, "M -26 -2 L 14 -2 L 26 10", 20_000.0), + ]; + for (addr, name, x, glyph, stop) in filt { + let p = Place::new(name) + .size(80.0) + .variant("stepped_cream") + .options(CUP) + .legend("") + .bind(rk::bind(&a(addr))) + .touch(TOUCH_MIN); + let k = parts::put(b, root, super::KNOB_ID, &p, x, 128.0); + parts::strip_legend(b, k); + parts::value_at(b, k, 45.0, ink); + parts::format(b, k, ValueFormat::decimals(0).mark(stop, "OFF")); + // The response glyph: a dotted slope into a flat line. + let pts: Vec<(f64, f64)> = glyph + .split(" L ") + .map(|s| { + s.trim_start_matches("M ") + .split(' ') + .map(|n| n.parse::().unwrap_or(0.0)) + .collect::>() + }) + .map(|v| (x + v[0], 70.0 + v[1])) + .collect(); + rk::line(b, root, &pts, 1.2, "#3b3e44"); + } + // Nine sliders, a dotted rule between each at noon. + for (i, (addr, label, _)) in BANDS.iter().enumerate() { + let x = 250.0 + 62.0 * i as f64; + let p = Place { + options: Some(vec!["w=15".into()]), + ..Place::new(*label) + .size(134.0) + .variant("pearl") + .bind(rk::bind(&a(addr))) + .touch(TOUCH_MIN) + }; + let s = parts::put_at(b, root, "lever.eq_slider", &p, x - TOUCH_MIN / 2.0, 59.0); + parts::value_at(b, s, 45.0, ink); + parts::format(b, s, ValueFormat::decimals(1).unit("dB").signed()); + rk::label(b, s, label, (x, 210.0), ink); + if i < 8 { + let dots: Vec> = (0..7) + .map(|j| { + let dx = x + 18.0 + f64::from(j) * 4.4; + vec![(dx, 126.0), (dx + 1.2, 126.0)] + }) + .collect(); + b.add( + root, + parts::stroke_path( + "dots", + (x + 16.0, 124.0, 34.0, 4.0), + &dots, + 1.0, + "#a7aab0", + false, + ), + ); + } + } + // Power: an orange-tipped toggle, and the pilot ring under LPF. + let pw = rk::mini_toggle(b, root, "Power", &a("power"), (89.0, 196.0), 14.0, "chrome"); + rk::label( + b, + pw, + "Power", + (89.0, 167.0), + Ink::new(9.5, "#55585e").weight(500.0).track(0.02), + ); + let pilot = draw::circle( + "pilot", + 836.0, + 196.0, + 7.0, + draw::radial(&[ + (0.0, "#ffffff"), + (0.45, "#ffd0c0"), + (0.7, "#ff5a3a"), + (1.0, "#ff5a3a00"), + ]), + ); + b.add(root, parts::emit(pilot, "#ff6a4a", 0.8, 14.0)); + b.add( + root, + draw::stroked( + draw::circle("pilot ring", 836.0, 196.0, 9.0, draw::solid("#00000000")), + "#ff7a5a", + 2.0, + ), + ); + }); + let mut params = vec![ + rk::hz(&a("hpf"), "High Pass", 20.0, 400.0, 20.0), + rk::hz(&a("lpf"), "Low Pass", 2000.0, 20000.0, 20000.0), + ]; + for (addr, label, _) in BANDS { + params.push(rk::db(&a(addr), &format!("{label} Gain"), -12.0, 12.0, 0.0).featured()); + } + params.push(rk::toggle(&a("power"), "Power", true).featured()); + Face { + file: "10-eq-tabletop-nine-band", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm.rs b/examples/plugin-faces/src/suite_funk_rhythm.rs new file mode 100644 index 00000000..c79e45d8 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm.rs @@ -0,0 +1,39 @@ +//! Suite: funk rhythm — a clean-funk guitar rig as one plugin +//! (`suite-funk-rhythm`), each unit a page in its own form factor +//! (`docs/form-factors.md`): the treadle wah, four standard pre-FX +//! stompboxes, three amp heads, the cab, the desktop graphic EQ and three +//! post-FX pedals (one a big-box delay). +//! +//! The lettering follows the style of the rig it recreates — heavy +//! lowercase names stacked in a pedal's corner, a cartoon maker's mark on +//! the amps — with every name invented. + +mod amps; +mod cab; +mod delay; +mod eq; +pub mod parts; +mod pedals; +mod wah; + +use crate::face::Face; + +/// Every unit of the suite, in signal order. +#[must_use] +pub fn faces() -> Vec { + vec![ + wah::face(), + pedals::envelope(), + pedals::squeeze(), + pedals::glass(), + pedals::haul(), + amps::critic(), + amps::engine(), + amps::desk(), + cab::face(), + eq::face(), + pedals::neon(), + delay::face(), + pedals::rinse(), + ] +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm/amps.rs b/examples/plugin-faces/src/suite_funk_rhythm/amps.rs new file mode 100644 index 00000000..ebd70af4 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm/amps.rs @@ -0,0 +1,1066 @@ +//! The three amp heads (Head form, 1200 wide; the height is the head's +//! own aspect with its handle and feet). Each faceplate strip is the +//! control surface, legends lowercase over the knobs (rendered knobs at the +//! angles the reference photos show), and a cartoon maker's mark — two +//! heavy brush-lettered words with a thick outline — at the right. +//! +//! - `06-critic-head-1200x580`: *The Tone Critic* — pale blue tolex with +//! black moulded corners and a moulded carry handle on chrome posts, a +//! cut blue plate whose bars are stacked zigzags — regular nested +//! chevrons rising and hanging in turn across it — over glowing +//! power valves, a cream strip: volume, bright, bass, middle, treble, +//! master, presence, output, drive, power. +//! - `07-engine-head-1200x600`: *The Clean Engine* — a walnut box, a cream +//! perforated grille, a green strip with white knurled knobs: volume, +//! bright, bass, middle, treble, presence, output, power. +//! - `08-desk-head-1200x596`: *Studio Funk Desk* — a white vented chassis +//! on wooden legs with the valves glowing through the slots, a cream +//! strip with slate pointer knobs: comp (slow/fast), tube sat, high +//! pass, low pass, lows, mids, highs (each with a frequency toggle), +//! output, power. +//! +//! The valves' glow flickers (a motion on their light), the pilot LED +//! follows the power switch. + +use frame::document::proto::NodeId; +use frame::surface::kit::materials::{Dir, Grain, Material, fill}; +use frame::surface::kit::{Place, TOUCH_MIN, draw, motion}; +use frame::surface::proto::ParamInfo; +use frame::surface::widgets::Builder; + +use super::parts::{self, HEAD_W, KnobLook, Type}; +use super::pedals::PLUGIN; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +/// A knob on a strip: address, legend, x. +struct Knob<'a>(&'a str, &'a str, f64); + +/// How a strip's knobs look. +struct Strip<'a> { + look: KnobLook<'a>, + d: f64, + ink: &'a str, + knob_y: f64, + legend_y: f64, +} + +fn strip_knobs(b: &mut Builder<'_>, parent: NodeId, s: &Strip<'_>, ks: &[Knob<'_>]) { + for Knob(addr, legend, x) in ks { + parts::knob3d(b, parent, &s.look, addr, legend, s.d, (*x, s.knob_y), s.ink); + parts::centred( + b, + parent, + legend, + (*x, s.legend_y), + Type::new(12.0, s.ink, 600.0), + ); + } +} + +/// A cream push power button with the pilot LED above it. +fn power( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + (x, y): (f64, f64), + led_y: f64, + d: f64, + led: &str, +) { + let p = Place::new("Power") + .size(d) + .variant("cream") + .bind(ui::bind(addr)) + .options(&["nolamp"]) + .legend("") + .touch(TOUCH_MIN); + let k = frame::surface::kit::place(b, parent, "button.round_cap", &p) + .unwrap_or_else(|e| panic!("{e}")); + ui::strip_legend(b, k); + parts::centre_on(b, k, (x, y)); + parts::status_led(b, parent, addr, 5.0, (x, led_y), led, "dark", None); +} + +/// The maker's mark: two stacked words in a heavy brush face, filled in +/// the plate's colour with a thick dark outline, the model under them. +#[allow(clippy::too_many_arguments)] +fn mark( + b: &mut Builder<'_>, + parent: NodeId, + (cx, cy): (f64, f64), + fill: &str, + outline: &str, + model: &[&str], + model_ink: &str, +) { + let t = Type::new(27.0, fill, 400.0) + .family(draw::family::KNEWAVE) + .upper() + .track(-0.02); + parts::outlined(b, parent, "Funk", (cx, cy - 14.0), t, outline, 2.2); + parts::outlined(b, parent, "Works", (cx, cy + 12.0), t, outline, 2.2); + for (i, l) in model.iter().enumerate() { + parts::centred( + b, + parent, + l, + (cx, cy + 37.0 + 11.0 * i as f64), + Type::new(10.5, model_ink, 700.0), + ); + } +} + +/// Power valves behind a grille: glass envelopes with glowing plates and +/// filaments whose light flickers. +fn valves(b: &mut Builder<'_>, parent: NodeId, xs: &[f64], y: f64, w: f64, h: f64) { + for (i, x) in xs.iter().enumerate() { + let glass = draw::rounded( + draw::rect( + "valve", + x - w / 2.0, + y, + w, + h, + draw::linear( + 90.0, + &[ + (0.0, "#2a2320"), + (0.3, "#4a403a"), + (0.5, "#221c19"), + (1.0, "#15110f"), + ], + ), + ), + w / 2.0, + ); + b.add( + parent, + draw::with_effects(glass, vec![draw::drop_shadow("#00000099", 0.0, 2.0, 6.0)]), + ); + b.add( + parent, + draw::rect( + "plate", + x - w * 0.32, + y + h * 0.25, + w * 0.64, + h * 0.5, + draw::linear( + 90.0, + &[(0.0, "#3b3a38"), (0.5, "#8a8986"), (1.0, "#3b3a38")], + ), + ), + ); + let glow = draw::rect( + "filament", + x - w * 0.18, + y + h * 0.3, + w * 0.36, + h * 0.4, + draw::radial(&[(0.0, "#ffd08a"), (0.5, "#ff8a2a"), (1.0, "#ff6a1000")]), + ); + let glow = b.add( + parent, + draw::emissive(glow, draw::hex("#ff8a2a"), 0.8, w * 0.9), + ); + motion::add(b, glow, motion::flicker(0.12 + 0.03 * i as f64)); + b.add( + parent, + draw::rect( + "glint", + x - w * 0.36, + y + h * 0.08, + w * 0.08, + h * 0.8, + draw::solid("#ffffff24"), + ), + ); + } +} + +/// A moulded carry handle: a flattened arch `thick` px deep from `x0` to +/// `x1`, its feet on `y`, rising `rise`, with grip ribs along its top and +/// a chrome post and screw under each end. +fn moulded_handle( + b: &mut Builder<'_>, + root: NodeId, + (x0, x1): (f64, f64), + y: f64, + rise: f64, + thick: f64, +) { + for x in [x0 + 16.0, x1 - 16.0] { + let post = draw::rounded( + draw::rect( + "handle post", + x - 7.0, + y - 20.0, + 14.0, + 24.0, + draw::linear( + 90.0, + &[ + (0.0, "#6d6f72"), + (0.35, "#f4f5f6"), + (0.6, "#a4a7ab"), + (1.0, "#56585b"), + ], + ), + ), + 3.0, + ); + b.add( + root, + draw::with_effects(post, vec![draw::drop_shadow("#00000080", 1.0, 2.0, 3.0)]), + ); + b.add( + root, + draw::circle( + "post screw", + x, + y - 16.0, + 3.4, + draw::radial_at(0.35, 0.3, 0.8, &[(0.0, "#ffffff"), (1.0, "#6a6c70")]), + ), + ); + } + // The centreline: steep at the ends, flat across the grip. + let n = 48; + let centre: Vec<(f64, f64)> = (0..=n) + .map(|i| { + let u = f64::from(i) / f64::from(n); + let lift = (u * std::f64::consts::PI).sin().powf(0.3); + (x0 + (x1 - x0) * u, y - 18.0 - rise * lift) + }) + .collect(); + let normal = |i: usize| { + let (a, c) = (centre[i.saturating_sub(1)], centre[(i + 1).min(n as usize)]); + let (dx, dy) = (c.0 - a.0, c.1 - a.1); + let l = dx.hypot(dy).max(1e-6); + (-dy / l, dx / l) + }; + // Thicker across the grip than at the ends. + let half = |i: usize| { + thick * 0.5 * (0.8 + 0.2 * (i as f64 / f64::from(n) * std::f64::consts::PI).sin()) + }; + let mut outline: Vec<(f64, f64)> = (0..=n as usize) + .map(|i| { + let (nx, ny) = normal(i); + (centre[i].0 - nx * half(i), centre[i].1 - ny * half(i)) + }) + .collect(); + outline.extend((0..=n as usize).rev().map(|i| { + let (nx, ny) = normal(i); + (centre[i].0 + nx * half(i), centre[i].1 + ny * half(i)) + })); + let (w, h) = (HEAD_W, y + 10.0); + let body = draw::path( + "handle", + 0.0, + 0.0, + w, + h, + draw::polygon(&outline), + draw::linear( + 180.0, + &[(0.0, "#3a3b3f"), (0.45, "#1d1e21"), (1.0, "#0b0b0c")], + ), + ); + b.add( + root, + draw::with_effects(body, vec![draw::drop_shadow("#00000099", 2.0, 4.0, 6.0)]), + ); + // The top edge catches the light; ribs across the grip. + let top: Vec<(f64, f64)> = (4..=n as usize - 4) + .map(|i| { + let (nx, ny) = normal(i); + ( + centre[i].0 + nx * half(i) * 0.7, + centre[i].1 + ny * half(i) * 0.7, + ) + }) + .collect(); + b.add( + root, + ui::strokes("handle light", &[top], 2.0, draw::solid("#ffffff3a")), + ); + let ribs: Vec> = (14..=n as usize - 14) + .step_by(2) + .map(|i| { + let (nx, ny) = normal(i); + let (cx, cy) = centre[i]; + vec![ + (cx + nx * half(i) * 0.15, cy + ny * half(i) * 0.15), + (cx + nx * half(i) * 0.6, cy + ny * half(i) * 0.6), + ] + }) + .collect(); + b.add( + root, + ui::strokes("grip ribs", &ribs, 2.2, draw::solid("#00000070")), + ); +} + +/// The grille's bars: identical zigzags stacked `pitch` apart, so they +/// read as nested chevrons — rising stacks (`^`) at the zigzag's peaks, +/// hanging ones (`v`) midway between — every bar `slope` steep. Peaks at +/// `peak0 + k·period` across a grille `w × h`. +fn zigzag_bars( + (peak0, period): (f64, f64), + slope: f64, + pitch: f64, + w: f64, + h: f64, +) -> Vec> { + let half = period / 2.0; + let drop = slope * half; + // A peak at or left of the edge, then a corner every half period. + let first = peak0 - period * (peak0 / period).ceil(); + let corners: Vec<(f64, f64)> = (0..) + .map(|i| { + ( + first + half * f64::from(i), + if i % 2 == 0 { 0.0 } else { drop }, + ) + }) + .take_while(|(x, _)| *x < w + half) + .collect(); + let (n0, n1) = ( + -(drop / pitch).ceil() as i32 - 1, + (h / pitch).ceil() as i32 + 1, + ); + (n0..=n1) + .map(|k| { + corners + .iter() + .map(|(x, dy)| (*x, f64::from(k) * pitch + dy)) + .collect() + }) + .collect() +} + +fn face(file: &'static str, scene: frame::document::proto::Scene, params: Vec) -> Face { + ui::formatted(Face { + file, + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + }) +} + +// ── The Tone Critic ────────────────────────────────────────────────────── + +pub(super) fn critic() -> Face { + let (w, h) = (HEAD_W, 580.0); + let scene = canvas("The Tone Critic", |b, root| { + parts::page(b, root, w, h); + let (top, bottom) = (64.0, 545.0); + // Feet and handle. + for x in [100.0, w - 170.0] { + let foot = draw::rounded( + draw::rect( + "foot", + x, + bottom - 10.0, + 70.0, + h - bottom + 10.0, + draw::linear( + 90.0, + &[(0.0, "#1a1a1c"), (0.3, "#3a3a3e"), (1.0, "#0d0d0e")], + ), + ), + 8.0, + ); + b.add( + root, + draw::with_effects(foot, vec![draw::drop_shadow("#00000080", 0.0, 3.0, 5.0)]), + ); + } + moulded_handle( + b, + root, + (w / 2.0 - 190.0, w / 2.0 + 190.0), + top + 2.0, + 30.0, + 24.0, + ); + // The tolex box and its corners. + let mut shell = draw::rounded(draw::frame("tolex box", 0.0, top, w, bottom - top), 10.0); + shell.effects = vec![draw::drop_shadow("#00000080", 0.0, 6.0, 14.0)]; + let shell = b.add(root, shell); + fill( + b, + shell, + (0.0, 0.0, w, bottom - top), + 10.0, + &Material::Tolex { + grain: Grain::Levant, + color: "#7896c2".into(), + scale: 1.4, + }, + ); + for n in frame::surface::kit::materials::light::edge( + "tolex edge", + (0.0, 0.0, w, bottom - top), + 10.0, + 6.0, + 0.8, + true, + ) { + b.add(shell, n); + } + for (x, y, fx, fy) in [ + (0.0, top, false, false), + (w - 64.0, top, true, false), + (w - 64.0, bottom - 64.0, true, true), + (0.0, bottom - 64.0, false, true), + ] { + // A moulded corner: an L of black plastic round the box's edge, + // mirrored into each corner. + let m = |px: f64, py: f64| { + ( + if fx { 64.0 - px } else { px }, + if fy { 64.0 - py } else { py }, + ) + }; + let l = draw::polygon(&[ + m(0.0, 0.0), + m(64.0, 0.0), + m(64.0, 16.0), + m(16.0, 16.0), + m(16.0, 64.0), + m(0.0, 64.0), + ]); + let c = draw::path( + "corner", + x, + y, + 64.0, + 64.0, + l, + draw::linear( + 160.0, + &[(0.0, "#4a4a4e"), (0.4, "#1b1b1d"), (1.0, "#060607")], + ), + ); + b.add( + root, + draw::with_effects(c, vec![draw::drop_shadow("#00000080", 0.0, 2.0, 3.0)]), + ); + let (sx, sy) = m(8.0, 8.0); + b.add( + root, + draw::circle( + "corner rivet", + x + sx, + y + sy, + 2.6, + draw::radial_at(0.35, 0.3, 0.8, &[(0.0, "#d9dadc"), (1.0, "#4a4b4e")]), + ), + ); + } + // The grille: a blue cut plate over glowing valves, its bars + // stacked zigzags: nested chevrons rising and hanging in turn (the + // reference, measured). + let (gx, gy, gw, gh) = (30.0, top + 26.0, w - 60.0, 282.0); + let plate = "#315982"; + let mut grille = draw::frame("grille", gx, gy, gw, gh); + grille.fills = vec![draw::solid(plate)]; + let grille = b.add(root, grille); + let (ow, oh) = (gw - 16.0, gh - 24.0); + let mut opening = draw::frame("grille opening", 8.0, 5.0, ow, oh); + opening.fills = vec![draw::linear(180.0, &[(0.0, "#07080a"), (1.0, "#101114")])]; + opening.clips_content = true; + let opening = b.add(grille, opening); + valves( + b, + opening, + &[250.0, 380.0, 530.0, 670.0, 820.0], + 60.0, + 64.0, + 170.0, + ); + // Peaks 456 apart from x = 110; a bar every 47 px down a column, + // 20 px of it bar, 0.95 steep; the first bar's peak 39 px down. + let bars: Vec> = zigzag_bars((110.0, 456.0), 0.95, 47.0, ow, oh) + .into_iter() + .map(|l| l.into_iter().map(|(x, y)| (x, y + 39.0)).collect()) + .collect(); + let off = |dx: f64, dy: f64| { + bars.iter() + .map(|l| l.iter().map(|(x, y)| (x + dx, y + dy)).collect()) + .collect::>>() + }; + let thick = 20.0 / 0.95_f64.hypot(1.0); + b.add( + opening, + ui::strokes("bar shade", &off(2.0, 4.0), thick, draw::solid("#00000099")), + ); + b.add( + opening, + ui::strokes("bars", &bars, thick, draw::solid(plate)), + ); + b.add( + opening, + ui::strokes( + "bar light", + &off(0.0, -thick * 0.5 + 1.0), + 1.6, + draw::solid("#8fb0dc55"), + ), + ); + for n in frame::surface::kit::materials::light::edge( + "grille recess", + (0.0, 0.0, ow, oh), + 0.0, + 4.0, + 1.0, + false, + ) { + b.add(opening, n); + } + for n in frame::surface::kit::materials::light::edge( + "plate edge", + (0.0, 0.0, gw, gh), + 0.0, + 3.0, + 0.7, + true, + ) { + b.add(grille, n); + } + for (sx, sy) in [ + (5.0, 4.0), + (gw - 5.0, 4.0), + (5.0, gh - 12.0), + (gw - 5.0, gh - 12.0), + ] { + parts::screw(b, grille, sx, sy, 2.6, "#2a3440"); + } + // The cream strip. + let (sx, sy, sw, sh) = (30.0, top + 310.0, w - 60.0, 140.0); + let strip = draw::rect( + "faceplate", + sx, + sy, + sw, + sh, + draw::linear(180.0, &[(0.0, "#f1e8d4"), (1.0, "#ddd0b5")]), + ); + let strip = b.add( + root, + draw::with_effects(strip, vec![draw::drop_shadow("#00000066", 0.0, 1.0, 2.0)]), + ); + let ink = "#232120"; + let knob_y = 66.0; + let s = Strip { + look: KnobLook { + cap: "knurled", + opts: &[ + "color=#18181a", + "pointer=#f4f2ee", + "index=dot", + "index_w=0.09", + "index_in=0.66", + "index_out=0.66", + "cap_r=0.92", + "dome=0.1", + "edge=0.12", + "rough=0.28", + "ticks=dots", + ], + }, + d: 44.0, + ink, + knob_y, + legend_y: 20.0, + }; + strip_knobs( + b, + strip, + &s, + &[ + Knob("critic/volume", "volume", 199.0), + Knob("critic/bass", "bass", 338.0), + Knob("critic/middle", "middle", 440.0), + Knob("critic/treble", "treble", 540.0), + Knob("critic/master", "master", 642.0), + Knob("critic/presence", "presence", 742.0), + Knob("critic/output", "output", 842.0), + ], + ); + for (addr, legend, x) in [ + ("critic/bright", "bright", 267.0), + ("critic/drive", "drive", 913.0), + ] { + parts::toggle(b, strip, addr, legend, 17.0, "black", (x, knob_y)); + parts::centred(b, strip, legend, (x, 28.0), Type::new(12.0, ink, 600.0)); + } + power( + b, + strip, + "critic/power", + (968.0, knob_y), + 36.0, + 24.0, + "#8ef0ff", + ); + mark( + b, + strip, + (1054.0, 54.0), + "#f1e8d4", + "#1c1b1b", + &["The Tone", "Critic"], + ink, + ); + parts::cable( + b, + strip, + (89.0, knob_y), + 12.0, + &[(80.0, 110.0), (40.0, 170.0), (-28.0, 190.0)], + ); + }); + // The knobs as the reference photo turns them. + let params = vec![ + parts::dial10("critic/volume", "Volume", 5.0), + parts::dial10("critic/bass", "Bass", 6.0), + parts::dial10("critic/middle", "Middle", 5.0), + parts::dial10("critic/treble", "Treble", 5.0), + parts::dial10("critic/master", "Master", 4.7), + parts::dial10("critic/presence", "Presence", 5.5), + parts::dial10("critic/output", "Output", 5.0), + parts::switch("critic/bright", "Bright", false), + parts::switch("critic/drive", "Drive", false), + parts::switch("critic/power", "Power", true), + ]; + face("06-critic-head-1200x580", scene, params) +} + +// ── The Clean Engine ───────────────────────────────────────────────────── + +pub(super) fn engine() -> Face { + let (w, h) = (HEAD_W, 600.0); + let scene = canvas("The Clean Engine", |b, root| { + parts::page(b, root, w, h); + let bottom = 558.0; + for x in [70.0, w - 150.0] { + let foot = draw::rounded( + draw::rect( + "foot", + x, + bottom - 10.0, + 80.0, + h - bottom + 10.0, + draw::linear( + 90.0, + &[(0.0, "#1c1c1e"), (0.3, "#3c3c40"), (1.0, "#0e0e0f")], + ), + ), + 8.0, + ); + b.add( + root, + draw::with_effects(foot, vec![draw::drop_shadow("#00000080", 0.0, 3.0, 5.0)]), + ); + } + // The walnut box. + let mut shell = draw::rounded(draw::frame("walnut box", 0.0, 0.0, w, bottom), 26.0); + shell.effects = vec![draw::drop_shadow("#00000080", 0.0, 6.0, 14.0)]; + let wood = b.add(root, shell); + fill( + b, + wood, + (0.0, 0.0, w, bottom), + 26.0, + &Material::Wood { + color: "#8f4a28".into(), + dir: Dir::Horizontal, + }, + ); + for n in frame::surface::kit::materials::light::edge( + "box edge", + (0.0, 0.0, w, bottom), + 26.0, + 10.0, + 1.0, + true, + ) { + b.add(wood, n); + } + // The cream perforated grille. + let (gx, gy, gw, gh) = (35.0, 36.0, w - 70.0, 317.0); + let mut grille = draw::frame("grille", gx, gy, gw, gh); + grille.fills = vec![draw::linear(180.0, &[(0.0, "#eee1c3"), (1.0, "#dccba6")])]; + grille.clips_content = true; + let grille = b.add(root, grille); + let mut holes = String::new(); + let (cols, rows) = (40, 10); + for r in 0..rows { + for c in 0..cols { + let (x, y) = (60.0 + f64::from(c) * 25.75, 50.0 + f64::from(r) * 25.0); + holes.push_str(&format!( + "M {:.2} {y:.2} a 4 4 0 1 0 8 0 a 4 4 0 1 0 -8 0 Z ", + x - 4.0 + )); + } + } + b.add( + grille, + draw::with_effects( + draw::path("holes", 0.0, 0.0, gw, gh, holes, draw::solid("#1a1410")), + vec![draw::drop_shadow("#ffffff66", 0.4, 1.0, 0.5)], + ), + ); + for n in frame::surface::kit::materials::light::edge( + "grille recess", + (0.0, 0.0, gw, gh), + 0.0, + 5.0, + 1.0, + false, + ) { + b.add(grille, n); + } + for (sx, sy) in [ + (12.0, 14.0), + (gw - 12.0, 14.0), + (12.0, gh - 14.0), + (gw - 12.0, gh - 14.0), + ] { + parts::screw(b, grille, sx, sy, 7.0, "#2a2a2a"); + } + // The green strip. + let (sx, sy, sw, sh) = (35.0, 357.0, w - 70.0, 165.0); + let strip = draw::rect( + "faceplate", + sx, + sy, + sw, + sh, + draw::linear(180.0, &[(0.0, "#27634a"), (1.0, "#1d4f3b")]), + ); + let strip = b.add(root, strip); + fill( + b, + strip, + (0.0, 0.0, sw, sh), + 0.0, + &Material::Powder { + color: "#235a44".into(), + coarse: false, + }, + ); + let ink = "#f4f1e8"; + let knob_y = 80.0; + let s = Strip { + look: KnobLook { + cap: "white_knurl", + opts: &["ticks=dots"], + }, + d: 54.0, + ink, + knob_y, + legend_y: 28.0, + }; + strip_knobs( + b, + strip, + &s, + &[ + Knob("engine/volume", "volume", 205.0), + Knob("engine/bass", "bass", 357.0), + Knob("engine/middle", "middle", 477.0), + Knob("engine/treble", "treble", 597.0), + Knob("engine/presence", "presence", 717.0), + Knob("engine/output", "output", 838.0), + ], + ); + parts::toggle( + b, + strip, + "engine/bright", + "bright", + 18.0, + "chrome", + (281.0, knob_y), + ); + parts::centred( + b, + strip, + "bright", + (281.0, 40.0), + Type::new(12.0, ink, 600.0), + ); + power( + b, + strip, + "engine/power", + (955.0, knob_y), + 42.0, + 26.0, + "#ff5a3c", + ); + mark( + b, + strip, + (1047.0, 60.0), + "#235a44", + "#f7f3ea", + &["The Clean", "Engine"], + ink, + ); + parts::cable( + b, + strip, + (93.0, knob_y), + 13.0, + &[(88.0, 125.0), (60.0, 190.0), (-30.0, 250.0)], + ); + }); + let params = vec![ + parts::dial10("engine/volume", "Volume", 5.0), + parts::dial10("engine/bass", "Bass", 5.0), + parts::dial10("engine/middle", "Middle", 5.0), + parts::dial10("engine/treble", "Treble", 5.0), + parts::dial10("engine/presence", "Presence", 5.0), + parts::dial10("engine/output", "Output", 5.0), + parts::switch("engine/bright", "Bright", false), + parts::switch("engine/power", "Power", true), + ]; + face("07-engine-head-1200x600", scene, params) +} + +// ── Studio Funk Desk ───────────────────────────────────────────────────── + +pub(super) fn desk() -> Face { + let (w, h) = (HEAD_W, 596.0); + let scene = canvas("Studio Funk Desk", |b, root| { + parts::page(b, root, w, h); + let (top, bottom) = (65.0, 560.0); + // Wooden legs, the chrome handle. + for (x, lean) in [(62.0, -1.0), (w - 118.0, 1.0)] { + let leg = draw::polygon(&[ + (x, bottom - 6.0), + (x + 56.0, bottom - 6.0), + (x + 56.0 + 14.0 * lean, h), + (x + 14.0 * lean, h), + ]); + b.add( + root, + draw::path( + "leg", + 0.0, + 0.0, + w, + h, + leg, + draw::linear( + 90.0, + &[(0.0, "#a86e3c"), (0.5, "#d49a5e"), (1.0, "#9c6534")], + ), + ), + ); + } + let bar = ui::strokes( + "handle", + &[vec![ + (443.0, top + 4.0), + (443.0, 8.0), + (750.0, 8.0), + (750.0, top + 4.0), + ]], + 10.0, + draw::solid("#d4d6d9"), + ); + b.add( + root, + draw::with_effects(bar, vec![draw::drop_shadow("#00000066", 0.0, 2.0, 3.0)]), + ); + // The white chassis. + let mut body = draw::rounded(draw::frame("chassis", 0.0, top, w, bottom - top), 30.0); + body.effects = vec![draw::drop_shadow("#0000008c", 0.0, 6.0, 14.0)]; + let body = b.add(root, body); + fill( + b, + body, + (0.0, 0.0, w, bottom - top), + 30.0, + &Material::Powder { + color: "#f3f3f0".into(), + coarse: false, + }, + ); + for n in frame::surface::kit::materials::light::edge( + "chassis edge", + (0.0, 0.0, w, bottom - top), + 30.0, + 10.0, + 1.0, + true, + ) { + b.add(body, n); + } + // Valves behind the middle vent, then the three vents' slots. + let mut well = draw::frame("valve well", 440.0, 38.0, 322.0, 262.0); + well.fills = vec![draw::solid("#0e0d0c")]; + let well = b.add(body, well); + valves(b, well, &[95.0, 228.0], 40.0, 64.0, 200.0); + for (x0, x1) in [(28.0, 404.0), (440.0, 762.0), (853.0, 1173.0)] { + for r in 0..10 { + let y = 42.0 + f64::from(r) * 25.7; + // Wall between slots over the well, the slot's dark mouth elsewhere. + if x0 > 430.0 && x0 < 450.0 { + continue; + } + let slot = draw::rounded( + draw::rect( + "slot", + x0, + y, + x1 - x0, + 10.0, + draw::linear(180.0, &[(0.0, "#000000"), (1.0, "#26262a")]), + ), + 5.0, + ); + b.add( + body, + draw::with_effects(slot, vec![draw::drop_shadow("#ffffffb0", 0.0, 1.0, 0.0)]), + ); + } + } + for r in 0..11 { + let y = 32.0 + f64::from(r) * 25.7; + b.add( + body, + draw::rect( + "vent bar", + 440.0, + y, + 322.0, + 10.0, + draw::linear(180.0, &[(0.0, "#ffffff"), (1.0, "#e2e2de")]), + ), + ); + } + // The cream strip. + let (sx, sy, sw, sh) = (28.0, 315.0, w - 56.0, 148.0); + let mut strip = draw::rounded(draw::frame("faceplate", sx, sy, sw, sh), 12.0); + strip.fills = vec![draw::linear(180.0, &[(0.0, "#f2ecdd"), (1.0, "#e6dcc6")])]; + let strip = b.add(body, strip); + for n in frame::surface::kit::materials::light::edge( + "strip recess", + (0.0, 0.0, sw, sh), + 12.0, + 5.0, + 1.0, + false, + ) { + b.add(strip, n); + } + let ink = "#262422"; + let knob_y = 78.0; + let s = Strip { + look: KnobLook { + cap: "domed", + opts: &[ + "color=#4f6a74", + "pointer=#f2f0ea", + "index=line", + "cap_r=0.9", + "dome=0.16", + "ribs=24", + "rough=0.35", + "ticks=dots", + ], + }, + d: 42.0, + ink, + knob_y, + legend_y: 33.0, + }; + strip_knobs( + b, + strip, + &s, + &[ + Knob("desk/comp", "comp", 132.0), + Knob("desk/sat", "tube sat", 262.0), + Knob("desk/hpf", "high pass", 357.0), + Knob("desk/lpf", "low pass", 453.0), + Knob("desk/lows", "lows", 551.0), + Knob("desk/mids", "mids", 681.0), + Knob("desk/highs", "highs", 809.0), + Knob("desk/output", "output", 938.0), + ], + ); + for (addr, up, down, x) in [ + ("desk/fast", "slow", "fast", 197.0), + ("desk/low_f", "150 Hz", "225 Hz", 618.0), + ("desk/mid_f", "600 Hz", "900 Hz", 746.0), + ("desk/high_f", "2.5 kHz", "4 kHz", 872.0), + ] { + parts::toggle(b, strip, addr, up, 16.0, "chrome", (x, knob_y)); + parts::centred(b, strip, up, (x, 49.0), Type::new(10.5, ink, 600.0)); + parts::centred(b, strip, down, (x, 108.0), Type::new(10.5, ink, 600.0)); + } + power( + b, + strip, + "desk/power", + (1012.0, knob_y), + 44.0, + 24.0, + "#8ef0ff", + ); + mark( + b, + strip, + (1081.0, 58.0), + "#f2ecdd", + "#2a2826", + &["Studio Funk", "Desk"], + ink, + ); + parts::cable( + b, + strip, + (56.0, knob_y), + 12.0, + &[(50.0, 125.0), (20.0, 200.0), (-40.0, 250.0)], + ); + }); + // Every knob at noon, as the reference sets them. + let params = vec![ + parts::pct("desk/comp", "Comp", 50.0), + parts::pct("desk/sat", "Tube Sat", 50.0), + parts::hz( + "desk/hpf", + "High Pass", + 20.0, + 500.0, + parts::log_at(20.0, 500.0, 0.5), + ), + parts::hz( + "desk/lpf", + "Low Pass", + 2000.0, + 20000.0, + parts::log_at(2000.0, 20000.0, 0.5), + ), + parts::db("desk/lows", "Lows", -12.0, 12.0, 0.0), + parts::db("desk/mids", "Mids", -12.0, 12.0, 0.0), + parts::db("desk/highs", "Highs", -12.0, 12.0, 0.0), + parts::db("desk/output", "Output", -24.0, 12.0, -6.0), + parts::switch("desk/fast", "Comp Speed", false), + parts::switch("desk/low_f", "Lows Frequency", false), + parts::switch("desk/mid_f", "Mids Frequency", false), + parts::switch("desk/high_f", "Highs Frequency", false), + parts::switch("desk/power", "Power", true), + ]; + face("08-desk-head-1200x596", scene, params) +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm/cab.rs b/examples/plugin-faces/src/suite_funk_rhythm/cab.rs new file mode 100644 index 00000000..d0992709 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm/cab.rs @@ -0,0 +1,552 @@ +//! `09-cab-900x900`: the cab page (900 × 900) — a wood-framed 4×12 with +//! four black speakers whose baffle follows the cab choice (white, blue or +//! green: each look a layer shown for its option), the three cabs as glyph +//! buttons under it (a select bank on `cab/model`), and the rig's shared +//! mic controls either side: for each of two mics a dropdown of six mics +//! and an on switch, position, distance, level and pan knobs with their +//! readouts, and a phase button; between them the room send and its level. +//! +//! Each mic also sits on the cab as a handle: drag it across its speaker +//! for position (the dust cap out to the edge) and up for distance. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::materials::{Dir, Material, fill}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{ButtonMode, Control, ControlKind, Range, SlotRole, Unit, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::parts::{self, CAB, Type}; +use super::pedals::PLUGIN; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +pub(crate) const MICS: &[&str] = &[ + "Dynamic 1", + "Dynamic 2", + "Condenser", + "Ribbon 1", + "Ribbon 2", + "Small Condenser", +]; +const CABS: &[&str] = &["White 4x12", "Blue 4x12", "Green 4x12"]; +/// Each cab's baffle. +const BAFFLES: [&str; 3] = ["#e9e9e7", "#a9bcc8", "#a7cbb9"]; +const INK: &str = "#2b2624"; +const DIM: &str = "#7a6e69"; +/// The cab on the page: x, y, side. +const BOX: (f64, f64, f64) = (250.0, 36.0, 400.0); + +/// A maple-framed 4×12 at (`x`, `y`), `s` square, baffle `baffle`. +fn cabinet(b: &mut Builder<'_>, parent: NodeId, (x, y, s): (f64, f64, f64), baffle: &str) { + // Feet. + for (fx, lean) in [(x + s * 0.08, -1.0), (x + s * 0.8, 1.0)] { + let foot = draw::polygon(&[ + (fx, y + s - 4.0), + (fx + s * 0.12, y + s - 4.0), + (fx + s * 0.12 + 5.0 * lean, y + s + 16.0), + (fx + 5.0 * lean, y + s + 16.0), + ]); + b.add( + parent, + draw::path( + "foot", + 0.0, + 0.0, + CAB, + CAB, + foot, + draw::linear( + 90.0, + &[(0.0, "#a8764a"), (0.5, "#d3a171"), (1.0, "#96643c")], + ), + ), + ); + } + let mut frame = draw::rounded(draw::frame("cab", x, y, s, s), 10.0); + frame.effects = vec![draw::drop_shadow("#00000070", 0.0, 8.0, 16.0)]; + let frame = b.add(parent, frame); + fill( + b, + frame, + (0.0, 0.0, s, s), + 10.0, + &Material::Wood { + color: "#c89462".into(), + dir: Dir::Horizontal, + }, + ); + for n in frame::surface::kit::materials::light::edge( + "frame edge", + (0.0, 0.0, s, s), + 10.0, + 6.0, + 1.0, + true, + ) { + b.add(frame, n); + } + let m = s * 0.07; + // The inner lip of the frame, then the baffle. + b.add( + frame, + draw::stroked( + draw::rect( + "lip", + m - 5.0, + m - 5.0, + s - 2.0 * m + 10.0, + s - 2.0 * m + 10.0, + draw::solid("#00000000"), + ), + "#7a4f2c66", + 3.0, + ), + ); + let mut panel = draw::frame("baffle", m, m, s - 2.0 * m, s - 2.0 * m); + panel.fills = vec![draw::radial_at( + 0.45, + 0.4, + 0.8, + &[ + (0.0, &ui::blend(baffle, "#ffffff", 0.25)), + (1.0, &ui::blend(baffle, "#000000", 0.1)), + ], + )]; + let panel = b.add(frame, panel); + for n in frame::surface::kit::materials::light::edge( + "baffle recess", + (0.0, 0.0, s - 2.0 * m, s - 2.0 * m), + 0.0, + 6.0, + 1.0, + false, + ) { + b.add(panel, n); + } + let q = (s - 2.0 * m) / 4.0; + let r = q * 0.9; + for (i, j) in [(1.0, 1.0), (3.0, 1.0), (1.0, 3.0), (3.0, 3.0)] { + let (cx, cy) = (x + m + q * i, y + m + q * j); + // The baffle dishes in round each speaker. + b.add( + parent, + draw::circle( + "dish", + cx + 2.0, + cy + 3.0, + r * 1.12, + draw::radial(&[(0.8, "#00000000"), (0.93, "#00000026"), (1.0, "#00000000")]), + ), + ); + b.add( + parent, + draw::circle( + "dish light", + cx - 2.0, + cy - 3.0, + r * 1.12, + draw::radial(&[(0.85, "#ffffff00"), (0.95, "#ffffff40"), (1.0, "#ffffff00")]), + ), + ); + b.add( + parent, + draw::with_effects( + draw::circle("basket", cx, cy, r, draw::solid("#141415")), + vec![draw::drop_shadow("#00000066", 0.0, 2.0, 5.0)], + ), + ); + let cone = draw::circle( + "cone", + cx, + cy, + r * 0.84, + draw::conic( + 0.0, + &[ + (0.0, "#2c2c2e"), + (0.25, "#141415"), + (0.5, "#303033"), + (0.75, "#121213"), + (1.0, "#2c2c2e"), + ], + ), + ); + b.add(parent, cone); + let rings: Vec> = (1..9) + .map(|k| parts::ring(cx, cy, r * (0.3 + 0.065 * f64::from(k)))) + .collect(); + b.add( + parent, + ui::strokes("ridges", &rings, 1.0, draw::solid("#4a4a4e66")), + ); + b.add( + parent, + draw::circle( + "dust cap", + cx, + cy, + r * 0.22, + draw::radial_at( + 0.38, + 0.32, + 0.8, + &[(0.0, "#9a9a9e"), (0.4, "#303033"), (1.0, "#0a0a0b")], + ), + ), + ); + for a in 0..8 { + let (sx, sy) = draw::polar(cx, cy, r * 0.93, 22.5 + 45.0 * f64::from(a)); + b.add( + parent, + draw::circle("bolt", sx, sy, 2.2, draw::solid("#3a3a3c")), + ); + } + } +} + +/// The centre and radius of the cab's speaker `i` (0 top-left, 1 top-right). +fn speaker(i: usize) -> (f64, f64, f64) { + let (x, y, s) = BOX; + let m = s * 0.07; + let q = (s - 2.0 * m) / 4.0; + ( + x + m + q * if i == 0 { 1.0 } else { 3.0 }, + y + m + q, + q * 0.9, + ) +} + +/// Mic `m` on a handle over speaker `i`. +fn mic(b: &mut Builder<'_>, parent: NodeId, i: usize, m: &str) { + parts::mic_handle( + b, + parent, + speaker(i), + &format!("Mic {}", m.to_uppercase()), + &format!("cab/{m}_pos"), + &format!("cab/{m}_dist"), + ); +} + +/// A select-bank button: a glyph of an amp over its cab, lit when chosen. +fn cab_glyph(b: &mut Builder<'_>, parent: NodeId, i: usize, (x, y): (f64, f64)) { + let (w, h) = (54.0, 50.0); + let root = b.add(parent, draw::frame(&format!("Cab {}", i + 1), x, y, w, h)); + let on = b.add( + root, + draw::rounded( + draw::rect("chosen", 0.0, 0.0, w, h, draw::solid("#fbf7f4")), + 3.0, + ), + ); + let lines = vec![ + parts::round_rect(6.0, 5.0, w - 12.0, 13.0, 1.0), + parts::round_rect(4.0, 22.0, w - 8.0, 24.0, 1.0), + parts::ring(17.0, 34.0, 7.0), + parts::ring(w - 17.0, 34.0, 7.0), + ]; + b.add(root, ui::strokes("glyph", &lines, 2.0, draw::solid(INK))); + let c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: 3, + }, + }) + .slot(SlotRole::On, on.0); + let mut c = c; + c.binding = Some(ui::bind("cab/model")); + b.controls.insert(root, c); +} + +/// A dropdown of `options` on `addr`: a dark pill whose text is the +/// choice, stepped by wheel or arrows. +fn dropdown( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + options: &[&str], +) { + let root = b.add(parent, draw::rounded(draw::frame(name, x, y, w, h), 6.0)); + if let Some(n) = b.scene.node_mut(root) { + n.fills = vec![draw::solid("#141414")]; + } + let value = b.add( + root, + ui::text( + "value", + "", + 14.0, + h / 2.0, + w - 44.0, + 15.0, + "#f4f4f2", + TextAlign::Left, + 500.0, + false, + ), + ); + b.add( + root, + ui::strokes( + "chevron", + &[vec![ + (w - 26.0, h / 2.0 + 3.0), + (w - 20.0, h / 2.0 - 3.0), + (w - 14.0, h / 2.0 + 3.0), + ]], + 1.8, + draw::solid("#f4f4f2"), + ), + ); + let mut c = Control::new(ControlKind::Selector).slot(SlotRole::Value, value.0); + c.binding = Some(ui::bind(addr)); + c.format = Some(ValueFormat::labels(options)); + b.controls.insert(root, c); +} + +/// A latching square toggle with a legend: dark when on. +fn toggle_button( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + legend: &str, + (x, y, w, h): (f64, f64, f64, f64), +) { + let root = b.add(parent, draw::frame(legend, x, y, w, h)); + let off = b.add( + root, + draw::stroked( + draw::rounded( + draw::rect("off", 0.0, 0.0, w, h, draw::solid("#fbf8f6")), + 6.0, + ), + "#b9aeaa", + 1.2, + ), + ); + let on = b.add( + root, + draw::rounded( + draw::rect("on", 0.0, 0.0, w, h, draw::solid("#2b2624")), + 6.0, + ), + ); + let t_off = b.add( + root, + ui::text( + "legend", + legend, + 0.0, + h / 2.0, + w, + 13.0, + INK, + TextAlign::Center, + 600.0, + true, + ), + ); + let t_on = b.add( + root, + ui::text( + "legend", + legend, + 0.0, + h / 2.0, + w, + 13.0, + "#fbf8f6", + TextAlign::Center, + 600.0, + true, + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::On, on.0) + .slot(SlotRole::On, t_on.0) + .slot(SlotRole::Off, t_off.0) + .slot(SlotRole::Off, off.0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(root, c); +} + +/// A flat knob with its value under it and its caption under that. +fn flat(b: &mut Builder<'_>, parent: NodeId, addr: &str, caption: &str, (cx, cy): (f64, f64)) { + let p = Place::new(caption) + .size(46.0) + .variant("light") + .bind(ui::bind(addr)) + .touch(TOUCH_MIN) + .options(&["track=#00000024"]); + let k = ui::put(b, parent, "knob.flat_line", &p, cx, cy); + ui::strip_legend(b, k); + ui::add_value(b, k, 13.0, INK); + // A pan reads C at its centre; a level signed. + if addr.ends_with("pan") { + parts::format(b, k, ValueFormat::decimals(0).mark(0.0, "C")); + } else if addr.ends_with("level") { + parts::format(b, k, ValueFormat::decimals(1).signed()); + } + parts::centred( + b, + parent, + caption, + (cx, cy + 58.0), + Type::new(10.5, DIM, 600.0).upper().track(0.04), + ); +} + +fn mic_panel(b: &mut Builder<'_>, parent: NodeId, m: &str, x0: f64) { + let pre = format!("cab/{m}_"); + let a = |s: &str| format!("{pre}{s}"); + dropdown( + b, + parent, + &a("mic"), + &format!("Mic {}", m.to_uppercase()), + (x0, 560.0, 250.0, 44.0), + MICS, + ); + toggle_button(b, parent, &a("on"), "On", (x0 + 262.0, 560.0, 60.0, 44.0)); + for (i, (id, cap)) in [ + ("pos", "Position"), + ("dist", "Distance"), + ("level", "Mic Level"), + ("pan", "Pan"), + ] + .iter() + .enumerate() + { + flat(b, parent, &a(id), cap, (x0 + 40.0 + 82.0 * i as f64, 676.0)); + } + toggle_button( + b, + parent, + &a("phase"), + "Phase Ø", + (x0 + 100.0, 790.0, 120.0, 44.0), + ); +} + +pub(super) fn face() -> Face { + let scene = canvas("Funk Cab", |b, root| { + parts::page(b, root, CAB, CAB); + let mut bg = draw::frame("room", 0.0, 0.0, CAB, CAB); + bg.fills = vec![draw::linear( + 180.0, + &[ + (0.0, "#f6ddd4"), + (0.56, "#f3d6cc"), + (0.561, "#e8c9bd"), + (1.0, "#f3d9cf"), + ], + )]; + let bg = b.add(root, bg); + // The cab, in the chosen look. + for (i, baffle) in BAFFLES.iter().enumerate() { + let layer = parts::variant_layer(b, bg, "cab/model", i, (0.0, 0.0, CAB, 470.0)); + cabinet(b, layer, BOX, baffle); + } + mic(b, bg, 0, "a"); + mic(b, bg, 1, "b"); + for i in 0..3 { + cab_glyph(b, bg, i, (365.0 + 58.0 * i as f64, 474.0)); + } + parts::centred( + b, + bg, + "MIC A", + (192.0, 540.0), + Type::new(11.0, DIM, 700.0).track(0.08), + ); + parts::centred( + b, + bg, + "MIC B", + (707.0, 540.0), + Type::new(11.0, DIM, 700.0).track(0.08), + ); + mic_panel(b, bg, "a", 30.0); + mic_panel(b, bg, "b", 548.0); + // The room send, between the mics. + b.add( + bg, + draw::rect( + "rule", + 400.0, + 870.0 - 40.0, + 100.0, + 1.0, + draw::solid("#0000001f"), + ), + ); + parts::centred( + b, + bg, + "ROOM SEND", + (450.0, 842.0), + Type::new(10.5, DIM, 700.0).track(0.06), + ); + toggle_button(b, bg, "cab/room_on", "Room", (412.0, 850.0, 76.0, 44.0)); + let p = Place::new("Room level") + .size(40.0) + .variant("light") + .bind(ui::bind("cab/room_level")) + .touch(TOUCH_MIN); + let k = ui::put(b, bg, "knob.flat_line", &p, 450.0, 780.0); + ui::strip_legend(b, k); + ui::add_value(b, k, 12.0, INK); + parts::format(b, k, ValueFormat::decimals(1).signed()); + }); + let mut params = vec![parts::pick("cab/model", "Cab", CABS, 0)]; + for m in ["a", "b"] { + let a = |s: &str| format!("cab/{m}_{s}"); + let up = m.to_uppercase(); + params.extend([ + parts::pick( + &a("mic"), + &format!("Mic {up}"), + MICS, + if m == "a" { 0 } else { 3 }, + ), + parts::switch(&a("on"), &format!("Mic {up} On"), true), + ui::param( + &a("pos"), + &format!("Mic {up} Position"), + Range::linear(0.0, 1.0, Unit::None), + 0.35, + ) + .featured(), + ui::param( + &a("dist"), + &format!("Mic {up} Distance"), + Range::linear(0.0, 1.0, Unit::None), + 0.15, + ) + .featured(), + parts::db(&a("level"), &format!("Mic {up} Level"), -24.0, 12.0, 0.0), + ui::param( + &a("pan"), + &format!("Mic {up} Pan"), + Range::linear(-100.0, 100.0, Unit::Percent), + 0.0, + ) + .featured() + .bipolar(), + parts::switch(&a("phase"), &format!("Mic {up} Phase"), false), + ]); + } + params.push(parts::switch("cab/room_on", "Room Send", true)); + params.push(parts::db("cab/room_level", "Room Level", -24.0, 12.0, -4.0)); + ui::formatted(Face { + file: "09-cab-900x900", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + }) +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm/delay.rs b/examples/plugin-faces/src/suite_funk_rhythm/delay.rs new file mode 100644 index 00000000..0ed75262 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm/delay.rs @@ -0,0 +1,256 @@ +//! `12-repeat-bigbox-404x286`: *Repeat-t-t*, a stereo delay in a rendered +//! big-box enclosure (404 × 286, the BigBox class) — bright green metallic +//! over the copper base, two mini toggles (Sync: free or DAW; Single / +//! Dual), a backlit dot-matrix LCD with the sync mode, the left and right +//! times and the tempo (live), MIX and FEEDBACK up top, the low-cut, TIME +//! L, TIME R and high-cut knobs (the cuts turned out as the reference has +//! them), an infinity sign, and two stomps: ENGAGE and a momentary TAP +//! TEMPO, each with its LED. + +use frame::document::proto::{NodeId, NodeKind, TextAlign}; +use frame::surface::kit::draw; +use frame::surface::proto::{Range, Unit}; +use frame::surface::widgets::Builder; + +use super::parts::{self, BIGBOX_W, PEDAL_H, Type}; +use super::pedals::{self, K}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +const WHITE: &str = "#fbfaf6"; +const DIVS: &[&str] = &[ + "1/32", "1/16T", "1/16", "1/8T", "1/8", "1/8D", "1/4T", "1/4", "1/4D", "1/2", "1/1", +]; + +/// Set every text node under `root` in the display's dot-matrix face. +fn dot_matrix(b: &mut Builder<'_>, root: NodeId, weight: f64) { + let l = draw::Lettering::family(draw::family::DOTO).weight(weight); + for id in b.scene.walk(root) { + if let Some(n) = b.scene.node_mut(id) + && matches!(n.kind, NodeKind::Text(_)) + { + l.apply(n); + } + } +} + +pub(super) fn face() -> Face { + let scene = canvas("Repeat-t-t Delay", |b, root| { + let lid = parts::stompbox_with( + b, + root, + (BIGBOX_W, PEDAL_H), + "#52bf4f", + "powder", + pedals::BASE, + 14.0, + pedals::LID, + ); + let t = Type::new(7.0, WHITE, 800.0); + // The mini toggles and their legends. + for (addr, legend, fx) in [ + ("repeat/sync", "Sync", 0.31), + ("repeat/dual", "Single/Dual", 0.69), + ] { + parts::toggle(b, lid.id, addr, legend, 9.0, "chrome", lid.at(fx, 0.12)); + parts::centred(b, lid.id, legend, lid.at(fx, 0.04), t); + } + // Knobs: the cuts turned out (low cut left, high cut right). + let ks = [ + K("repeat/mix", "Mix", (0.127, 0.25), true), + K("repeat/feedback", "Feedback", (0.873, 0.25), true), + K("repeat/low", "", (0.127, 0.51), false), + K("repeat/time_l", "Time L", (0.373, 0.505), false), + K("repeat/time_r", "Time R", (0.627, 0.505), false), + K("repeat/high", "", (0.873, 0.51), false), + ]; + pedals::knobs(b, &lid, &ks, 40.0); + pedals::filter_marks(b, lid.id, lid.at(0.127, 0.645), lid.at(0.873, 0.645)); + // The LCD: sync mode and voices, then the times and the tempo, in + // a dot-matrix face. + let (x, y) = lid.at(0.27, 0.185); + let (dw, dh) = (lid.w * 0.46, lid.h * 0.155); + let face = ui::display(b, lid.id, "LCD", x, y, dw, dh, "#b8c8c0", "glass"); + let dim = "#a3b0a9"; + let lit = "#e6eee9"; + let top = ui::lit_text( + b, + face, + "FREE / DAW / TAP", + 7.0, + 11.0, + 90.0, + 6.4, + dim, + TextAlign::Left, + 500.0, + ); + let top2 = ui::lit_text( + b, + face, + "SINGLE / DUAL", + dw - 97.0, + 11.0, + 90.0, + 6.4, + dim, + TextAlign::Right, + 500.0, + ); + let l = ui::lit_readout( + b, + face, + "Time L readout", + ui::bind("repeat/time_l"), + 6.0, + 29.0, + 44.0, + 13.0, + lit, + TextAlign::Left, + ); + let tempo = ui::lit_readout( + b, + face, + "Tempo readout", + ui::bind("repeat/bpm"), + 50.0, + 29.0, + dw - 100.0, + 13.0, + lit, + TextAlign::Center, + ); + let r = ui::lit_readout( + b, + face, + "Time R readout", + ui::bind("repeat/time_r"), + dw - 50.0, + 29.0, + 44.0, + 13.0, + lit, + TextAlign::Right, + ); + for n in [top, top2] { + dot_matrix(b, n, 600.0); + } + // The tempo to a tenth, as the unit's display prints it. + if let Some(c) = b.controls.get_mut(&tempo) { + c.format = Some(frame::surface::proto::ValueFormat::decimals(1)); + } + for n in [l, tempo, r] { + dot_matrix(b, n, 700.0); + } + ui::glass(b, face); + // LEDs over the stomps; the tap LED blinks the tempo. + parts::status_led( + b, + lid.id, + "repeat/on", + 5.0, + lid.at(0.127, 0.71), + "#e8f6ff", + "light", + None, + ); + parts::status_led( + b, + lid.id, + "repeat/tap", + 5.0, + lid.at(0.873, 0.71), + "#e8f6ff", + "light", + Some("param(repeat/bpm)/60"), + ); + parts::footswitch( + b, + lid.id, + "repeat/on", + "Engage", + 24.0, + lid.at(0.127, 0.83), + false, + ); + parts::footswitch( + b, + lid.id, + "repeat/tap", + "Tap Tempo", + 24.0, + lid.at(0.873, 0.83), + true, + ); + parts::centred( + b, + lid.id, + "Engage", + lid.at(0.127, 0.95), + Type::new(6.4, WHITE, 800.0), + ); + parts::centred( + b, + lid.id, + "Tap Tempo", + lid.at(0.873, 0.95), + Type::new(6.4, WHITE, 800.0), + ); + // The infinity sign and the name. + let (ix, iy) = lid.at(0.5, 0.8); + let inf: Vec<(f64, f64)> = (0..=64) + .map(|i| { + let a = std::f64::consts::TAU * f64::from(i) / 64.0; + let d = 1.0 + a.sin().powi(2); + (ix + 30.0 * a.cos() / d, iy + 30.0 * a.sin() * a.cos() / d) + }) + .collect(); + parts::art(b, lid.id, &[inf], 3.4, WHITE); + parts::centred( + b, + lid.id, + "Repeat-t-t", + lid.at(0.5, 0.915), + Type::new(12.5, WHITE, 800.0), + ); + }); + let params = vec![ + parts::pct("repeat/mix", "Mix", 50.0), + parts::pct("repeat/feedback", "Feedback", 50.0), + parts::hz( + "repeat/low", + "Low Cut", + 20.0, + 1000.0, + parts::log_at(20.0, 1000.0, 0.2), + ), + parts::hz( + "repeat/high", + "High Cut", + 1000.0, + 20000.0, + parts::log_at(1000.0, 20000.0, 0.8), + ), + parts::pick("repeat/time_l", "Time L", DIVS, 7), + parts::pick("repeat/time_r", "Time R", DIVS, 5), + parts::switch("repeat/sync", "Sync", true), + parts::switch("repeat/dual", "Single/Dual", true), + ui::param( + "repeat/bpm", + "Tempo", + Range::linear(40.0, 240.0, Unit::Bpm), + 120.0, + ) + .featured(), + parts::switch("repeat/on", "Engage", true), + parts::switch("repeat/tap", "Tap Tempo", false), + ]; + ui::formatted(Face { + file: "12-repeat-bigbox-404x286", + plugin: pedals::PLUGIN, + scene, + params, + streams: Vec::new(), + }) +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm/eq.rs b/examples/plugin-faces/src/suite_funk_rhythm/eq.rs new file mode 100644 index 00000000..9aa27956 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm/eq.rs @@ -0,0 +1,319 @@ +//! `10-eq-desktop-1140x282`: the *Nine-Band Desk EQ*, a desktop graphic +//! EQ as wide as a rack face (1140 × 282, the reference's 4:1) in a +//! powder-coated case in three colourways (mauve, slate or blue: +//! `eq/color`, each a layer shown for its option) — big taupe dome knobs +//! for the high-pass and low-pass filters (their settings printed live +//! above; both open, as the reference shows them), nine lit white faders +//! from 65 Hz to 16 kHz with their gain printed above (set in the +//! reference's −12 / 0 / +12 pattern), a cream POWER button and its pilot +//! LED; three swatch keys at the bottom right pick the colourway. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::materials::{Material, fill}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{ButtonMode, Control, ControlKind, Range, SlotRole, Unit, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::parts::{self, KnobLook, RACK_W, Type}; +use super::pedals::PLUGIN; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +const BANDS: &[(&str, &str, f64)] = &[ + ("b65", "65 Hz", 272.0), + ("b125", "125 Hz", 347.0), + ("b250", "250 Hz", 420.0), + ("b500", "500 Hz", 495.0), + ("b1k", "1 kHz", 569.0), + ("b2k", "2 kHz", 642.0), + ("b4k", "4 kHz", 718.0), + ("b8k", "8 kHz", 792.0), + ("b16k", "16 kHz", 865.0), +]; +/// The colourways: name and case paint. +const COLOURS: [(&str, &str); 3] = [ + ("Mauve", "#9c8a8d"), + ("Slate", "#3f4b53"), + ("Blue", "#56799a"), +]; +const INK: &str = "#f4efec"; +/// The filters' ranges (Hz): each is off at its outer stop. +const HPF: (f64, f64) = (20.0, 1000.0); +const LPF: (f64, f64) = (1000.0, 20000.0); + +/// A live value line: `prefix` printed, then the param's value in `format`. +fn value_line( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + prefix: &str, + (cx, cy): (f64, f64), + w: f64, + format: ValueFormat, +) { + let t = Type::new(10.5, INK, 800.0).track(0.06); + let r = if prefix.is_empty() { + ui::lit_readout( + b, + parent, + &format!("{addr} readout"), + ui::bind(addr), + cx - w / 2.0, + cy, + w, + 10.5, + INK, + TextAlign::Center, + ) + } else { + parts::words( + b, + parent, + prefix, + cx - w / 2.0, + cy, + w / 2.0 - 2.0, + TextAlign::Right, + t, + ); + ui::lit_readout( + b, + parent, + &format!("{addr} readout"), + ui::bind(addr), + cx + 2.0, + cy, + w / 2.0, + 10.5, + INK, + TextAlign::Left, + ) + }; + parts::format(b, r, format); +} + +/// The filter's slope drawn over its knob. +fn slope(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64), high: bool) { + let pts = if high { + vec![ + (cx - 9.0, cy + 4.0), + (cx - 4.0, cy - 2.0), + (cx + 9.0, cy - 2.0), + ] + } else { + vec![ + (cx - 9.0, cy - 2.0), + (cx + 4.0, cy - 2.0), + (cx + 9.0, cy + 4.0), + ] + }; + parts::art(b, parent, &[pts], 2.0, INK); +} + +/// The case in one colourway: powder coat, lit edges, a slot rebate round +/// each fader. +fn case(b: &mut Builder<'_>, layer: NodeId, (w, h): (f64, f64), color: &str) { + let mut shell = draw::rounded(draw::frame("case", 0.0, 0.0, w, h), 12.0); + shell.effects = vec![draw::drop_shadow("#00000080", 0.0, 6.0, 12.0)]; + let shell = b.add(layer, shell); + fill( + b, + shell, + (0.0, 0.0, w, h), + 12.0, + &Material::Powder { + color: color.into(), + coarse: false, + }, + ); + for n in frame::surface::kit::materials::light::edge( + "case edge", + (0.0, 0.0, w, h), + 12.0, + 8.0, + 1.0, + true, + ) { + b.add(shell, n); + } + // The case's front lip, darker along the bottom. + b.add( + shell, + draw::rect( + "lip", + 0.0, + h - 14.0, + w, + 14.0, + draw::linear(180.0, &[(0.0, "#00000000"), (1.0, "#00000040")]), + ), + ); +} + +/// The colourway keys: a swatch per colourway centred on (`cx`, `cy`), +/// the chosen one ringed. +fn colour_keys(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64)) { + let n = COLOURS.len(); + for (i, (name, color)) in COLOURS.iter().enumerate() { + let x = cx + 18.0 * (i as f64 - (n as f64 - 1.0) / 2.0); + let key = b.add( + parent, + draw::frame(&format!("Colour {name}"), x - 8.0, cy - 8.0, 16.0, 16.0), + ); + b.add( + key, + draw::stroked( + draw::circle("swatch", 8.0, 8.0, 4.6, draw::solid(color)), + "#00000099", + 0.8, + ), + ); + let ring = b.add( + key, + draw::stroked( + draw::circle("chosen", 8.0, 8.0, 6.6, draw::solid("#00000000")), + INK, + 1.1, + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: n as u32, + }, + }) + .slot(SlotRole::Led, ring.0); + c.binding = Some(ui::bind("eq/color")); + b.controls.insert(key, c); + } +} + +pub(super) fn face() -> Face { + let (w, h) = (RACK_W, 282.0); + let scene = canvas("Nine-Band Desk EQ", |b, root| { + parts::page(b, root, w, h); + for (i, (_, color)) in COLOURS.iter().enumerate() { + let layer = parts::variant_layer(b, root, "eq/color", i, (0.0, 0.0, w, h)); + case(b, layer, (w, h), color); + } + let case = b.add(root, draw::frame("controls", 0.0, 0.0, w, h)); + // Filters: big taupe domes. + let look = KnobLook { + cap: "domed", + opts: &[ + "color=#a8958e", + "pointer=#1a1716", + "index=dot", + "index_w=0.07", + "index_in=0.55", + "index_out=0.55", + "cap_r=0.94", + "dome=0.24", + "rough=0.3", + "ticks=none", + ], + }; + // The filters read OFF at their stops, as the reference prints them. + for (addr, prefix, x, high, stop) in [ + ("eq/hpf", "HPF :", 174.0, true, HPF.0), + ("eq/lpf", "LPF :", 967.0, false, LPF.1), + ] { + value_line( + b, + case, + addr, + prefix, + (x, 36.0), + 150.0, + ValueFormat::default().mark(stop, "OFF"), + ); + slope(b, case, (x, 72.0), high); + parts::knob3d(b, case, &look, addr, prefix, 88.0, (x, 139.0), INK); + } + // Bands. + for (id, hz, x) in BANDS { + let addr = format!("eq/{id}"); + // Gains signed, a flat band a plain 0 (the reference's). + value_line( + b, + case, + &addr, + "", + (*x, 36.0), + 70.0, + ValueFormat::decimals(1).signed().mark(0.0, "0 dB"), + ); + let p = Place::new(*hz) + .size(150.0) + .bind(ui::bind(&addr)) + .touch(TOUCH_MIN); + let f = frame::surface::kit::place(b, case, "lever.lit_fader", &p) + .unwrap_or_else(|e| panic!("{e}")); + parts::centre_on(b, f, (*x, 141.0)); + parts::centred( + b, + case, + hz, + (*x, 251.0), + Type::new(10.5, INK, 800.0).track(0.06), + ); + } + // Power. + let p = Place::new("Power") + .size(30.0) + .variant("cream") + .bind(ui::bind("eq/power")) + .options(&["nolamp"]) + .legend("") + .touch(TOUCH_MIN); + let k = frame::surface::kit::place(b, case, "button.round_cap", &p) + .unwrap_or_else(|e| panic!("{e}")); + ui::strip_legend(b, k); + parts::centre_on(b, k, (174.0, 229.0)); + parts::centred( + b, + case, + "POWER", + (174.0, 259.0), + Type::new(8.5, INK, 800.0).track(0.2), + ); + parts::status_led( + b, + case, + "eq/power", + 7.0, + (964.0, 232.0), + "#eef6ff", + "chrome", + None, + ); + colour_keys(b, case, (1072.0, 256.0)); + }); + let mut params = vec![ + parts::pick("eq/color", "Colour", &COLOURS.map(|c| c.0), 0), + parts::hz("eq/hpf", "HPF", HPF.0, HPF.1, HPF.0), + parts::hz("eq/lpf", "LPF", LPF.0, LPF.1, LPF.1), + parts::switch("eq/power", "Power", true), + ]; + // The reference's bands: −12, 0, +12, repeated. + for (i, (id, hz, _)) in BANDS.iter().enumerate() { + let v = [-12.0, 0.0, 12.0][i % 3]; + params.push( + ui::param( + &format!("eq/{id}"), + hz, + Range::linear(-12.0, 12.0, Unit::Db), + v, + ) + .featured() + .bipolar(), + ); + } + ui::formatted(Face { + file: "10-eq-desktop-1140x282", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + }) +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm/parts.rs b/examples/plugin-faces/src/suite_funk_rhythm/parts.rs new file mode 100644 index 00000000..ae825547 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm/parts.rs @@ -0,0 +1,815 @@ +//! What the amp-suite faces share: sizing the page to the unit's form, +//! rendered pedal enclosures, the one knob helper, placing kit parts by +//! their centre with the face's own legends, the printed lettering (real +//! families and weights), colourway layers, and the catalog entries a unit +//! declares. +//! +//! Used by both artist-style suites (`suite_funk_rhythm`, +//! `suite_boutique_clean`). + +use frame::document::proto::{NodeId, NodeKind, TextAlign, TextCase}; +use frame::surface::kit::{Ink, Place, TOUCH_MIN, draw}; +use frame::surface::proto::{ + Axis, Condition, Control, ControlKind, HandleAxis, ParamInfo, Range, Unit, +}; +use frame::surface::widgets::Builder; + +use crate::reverb::ui; + +// ── form factors (docs/form-factors.md, 60 design px per inch) ──────────── + +/// Every pedal's height (the Standard class's depth). +pub const PEDAL_H: f64 = 286.0; +/// Standard pedal width. +pub const STANDARD_W: f64 = 156.0; +/// Big-box (double) pedal width. +pub const BIGBOX_W: f64 = 404.0; +/// The treadle (wah / expression) class. +pub const TREADLE: (f64, f64) = (241.0, 600.0); +/// An amp head's width; the height follows the faceplate's aspect. +pub const HEAD_W: f64 = 1200.0; +/// The cab page. +pub const CAB: f64 = 900.0; +/// A 19-inch rack face without ears. +pub const RACK_W: f64 = 1140.0; + +// ── placing ────────────────────────────────────────────────────────────── + +/// Size the page's root to the unit. +pub fn page(b: &mut Builder<'_>, root: NodeId, w: f64, h: f64) { + ui::fit_root(b, root, w, h); +} + +/// A rendered pedal's lid: the frame its parts go on, and its size. +#[derive(Clone, Copy)] +pub struct Lid { + pub id: NodeId, + pub w: f64, + pub h: f64, +} + +impl Lid { + /// A point given as fractions of the lid. + #[must_use] + pub fn at(&self, fx: f64, fy: f64) -> (f64, f64) { + (self.w * fx, self.h * fy) + } +} + +/// A rendered stompbox (`panel.pedal_3d`) filling the page (`w × h`): the +/// lid in `finish` and `color`, its front face and base plate in `base`, +/// the jacks standing out of its sides. Returns the lid — the pedal's parts +/// go on it, in its own coordinates. +/// +/// # Panics +/// If the kit's enclosure has no `lid` frame. +pub fn stompbox( + b: &mut Builder<'_>, + root: NodeId, + size: (f64, f64), + color: &str, + finish: &str, + base: &str, + radius: f64, +) -> Lid { + stompbox_with(b, root, size, color, finish, base, radius, &[]) +} + +/// [`stompbox`] with more `panel.pedal_3d` options (`jack_inset=`, `front=`). +/// +/// # Panics +/// If the kit's enclosure has no `lid` frame. +#[allow(clippy::too_many_arguments)] +pub fn stompbox_with( + b: &mut Builder<'_>, + root: NodeId, + (w, h): (f64, f64), + color: &str, + finish: &str, + base: &str, + radius: f64, + extra: &[&str], +) -> Lid { + page(b, root, w, h); + let mut opts = vec![ + format!("w={w}"), + format!("h={h}"), + format!("color={color}"), + format!("finish={finish}"), + format!("base={base}"), + format!("radius={radius}"), + "jacks=side".into(), + ]; + opts.extend(extra.iter().map(ToString::to_string)); + let class = if w > 300.0 { "bigbox" } else { "standard" }; + let p = Place { + options: Some(opts), + ..Place::new("Enclosure").variant(class) + }; + let body = ui::put_at(b, root, "panel.pedal_3d", &p, 0.0, 0.0); + let lid = b + .scene + .node(body) + .and_then(|n| { + n.children + .iter() + .copied() + .find(|c| b.scene.node(*c).is_some_and(|k| k.name == "lid")) + }) + .expect("panel.pedal_3d has a lid"); + let (lw, lh) = b + .scene + .node(lid) + .map_or((w, h), |n| (n.size.width, n.size.height)); + Lid { + id: lid, + w: lw, + h: lh, + } +} + +/// How a rendered knob looks: `knob.render`'s cap variant and options. +#[derive(Clone, Copy)] +pub struct KnobLook<'a> { + pub cap: &'a str, + pub opts: &'a [&'a str], +} + +/// Every knob of both artist suites is placed here — one place to switch +/// them all to another knob renderer: a rendered knob (`knob.render`) of +/// diameter `d` on `addr`, its shaft at (`cx`, `cy`), its hit box tight +/// round the body, its printed ring in `ink`. The face prints the legend. +#[allow(clippy::too_many_arguments)] +pub fn knob( + b: &mut Builder<'_>, + parent: NodeId, + look: &KnobLook<'_>, + addr: &str, + name: &str, + d: f64, + (cx, cy): (f64, f64), + ink: &str, +) -> NodeId { + let mut p = Place::new(name) + .size(d) + .variant(look.cap) + .bind(ui::bind(addr)) + .ink(Ink::Color(ink.into())) + .keep_case() + .touch(TOUCH_MIN); + if !look.opts.is_empty() { + p = p.options(look.opts); + } + let k = ui::put(b, parent, "knob.render", &p, cx, cy); + ui::strip_legend(b, k); + k +} + +/// [`knob`] as a GPU knob (`knob.3d`): `look.cap` names its preset, +/// `look.opts` its options. Where the GPU stage doesn't run it draws as +/// `knob.render` (the preset's fallback). +#[allow(clippy::too_many_arguments)] +pub fn knob3d( + b: &mut Builder<'_>, + parent: NodeId, + look: &KnobLook<'_>, + addr: &str, + name: &str, + d: f64, + (cx, cy): (f64, f64), + ink: &str, +) -> NodeId { + let mut p = Place::new(name) + .size(d) + .variant(look.cap) + .bind(ui::bind(addr)) + .ink(Ink::Color(ink.into())) + .keep_case() + .touch(TOUCH_MIN); + if !look.opts.is_empty() { + p = p.options(look.opts); + } + let k = ui::put(b, parent, "knob.3d", &p, cx, cy); + ui::strip_legend(b, k); + k +} + +/// Give the readout control `id` its own value format. +pub fn format(b: &mut Builder<'_>, id: NodeId, f: frame::surface::proto::ValueFormat) { + if let Some(c) = b.controls.get_mut(&id) { + c.format = Some(f); + } +} + +/// A kit control placed by the centre of its child named one of `anchor` +/// (or its box), legend stripped. +#[allow(clippy::too_many_arguments)] +pub fn part( + b: &mut Builder<'_>, + parent: NodeId, + id: &str, + p: Place, + anchor: &[&str], + (cx, cy): (f64, f64), +) -> NodeId { + let k = ui::put_by(b, parent, id, &p, anchor, cx, cy); + ui::strip_legend(b, k); + k +} + +/// A two-position toggle (`switch.toggle_nut`, chrome or black bat) on +/// `addr`, its nut at (`cx`, `cy`); legends are the face's. +pub fn toggle( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + name: &str, + size: f64, + variant: &str, + at: (f64, f64), +) -> NodeId { + let p = Place::new(name) + .size(size) + .variant(variant) + .bind(ui::bind(addr)) + .legend("") + .options(&["legend_right"]) + .touch(TOUCH_MIN); + part(b, parent, "switch.toggle_nut", p, &["bushing"], at) +} + +/// Move a placed control so its box is centred on (`cx`, `cy`). +pub fn centre_on(b: &mut Builder<'_>, id: NodeId, (cx, cy): (f64, f64)) { + let (w, h) = b + .scene + .node(id) + .map_or((0.0, 0.0), |n| (n.size.width, n.size.height)); + ui::move_to(b, id, cx - w / 2.0, cy - h / 2.0); +} + +/// A stomp footswitch on toggle `addr` (no LED of its own — the pedal's +/// LED is a separate [`status_led`]), its actuator `d` across centred at +/// (`cx`, `cy`). `momentary` for a tap switch. +#[allow(clippy::too_many_arguments)] +pub fn footswitch( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + name: &str, + d: f64, + at: (f64, f64), + momentary: bool, +) -> NodeId { + let mut p = Place::new(name) + .size(d) + .variant("none") + .bind(ui::bind(addr)); + if momentary { + p = p.options(&["momentary"]); + } + let k = ui::put_by(b, parent, "switch.footswitch", &p, &["cap"], at.0, at.1); + ui::strip_legend(b, k); + k +} + +/// An LED showing toggle `addr`, lens `d` across, in `color`, bezel +/// `variant` (`light`, `dark`, `chrome`); `pulse` makes it breathe at a +/// rate (a motion expression in Hz). +#[allow(clippy::too_many_arguments)] +pub fn status_led( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + d: f64, + at: (f64, f64), + color: &str, + variant: &str, + pulse: Option<&str>, +) -> NodeId { + let mut opts = vec![format!("color={color}")]; + if let Some(r) = pulse { + opts.push(format!("pulse={r}")); + } + let p = Place { + options: Some(opts), + ..Place::new("LED") + .size(d) + .variant(variant) + .bind(ui::bind(addr)) + }; + let k = frame::surface::kit::place(b, parent, "meter.status_led", &p) + .unwrap_or_else(|e| panic!("{e}")); + centre_on(b, k, at); + k +} + +// ── colourways ─────────────────────────────────────────────────────────── + +/// A layer of a unit's art shown only while choice `addr` is option +/// `index` (a colourway, a cab's look): a frame `(x, y, w, h)` under +/// `parent` carrying a `Readout` of the choice with a `show_if`, so the +/// runtime hides it for the other options. Paint the variant into it; put +/// the unit's controls outside it. +pub fn variant_layer( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + index: usize, + (x, y, w, h): (f64, f64, f64, f64), +) -> NodeId { + let layer = b.add( + parent, + draw::frame(&format!("{addr} = {index}"), x, y, w, h), + ); + // The readout's value: the option's name, printed clear (nothing on + // the unit shows it; the layer's art is the reading). + let value = b.add( + layer, + draw::text( + "colourway", + "", + 0.0, + 0.0, + 8.0, + 1.0, + "#00000000", + TextAlign::Left, + ), + ); + let mut c = + Control::new(ControlKind::Readout).slot(frame::surface::proto::SlotRole::Value, value.0); + c.binding = Some(ui::bind(addr)); + c.show_if = Some(Condition::any_of(addr, &[index as f64])); + b.controls.insert(layer, c); + layer +} + +// ── cab mics ───────────────────────────────────────────────────────────── + +/// A mic seen from the front, on a `Handle` over the speaker at +/// (`cx`, `cy`), radius `r`: x is its position (`pos`: the dust cap out to +/// the cone's edge), y its distance (`dist`: up and away from the cone). +pub fn mic_handle( + b: &mut Builder<'_>, + parent: NodeId, + (cx, cy, r): (f64, f64, f64), + label: &str, + pos: &str, + dist: &str, +) -> NodeId { + let lane = b.add( + parent, + draw::frame(&format!("{label} lane"), cx, cy - r * 0.9, r, r * 0.9), + ); + let touch = 44.0; + let handle = b.add(lane, draw::frame(label, 0.0, 0.0, touch, touch)); + let c = touch / 2.0; + // A shadow on the cone, the body falling away below the head, the + // head's mesh. + b.add( + handle, + draw::with_effects( + draw::circle( + "mic shadow", + c + 6.0, + c + 9.0, + 13.0, + draw::solid("#00000050"), + ), + vec![draw::drop_shadow("#00000040", 0.0, 0.0, 5.0)], + ), + ); + b.add( + handle, + draw::rounded( + draw::rect( + "mic body", + c - 7.0, + c, + 14.0, + 40.0, + draw::linear( + 90.0, + &[ + (0.0, "#050505"), + (0.45, "#4a4a4e"), + (0.6, "#1a1a1b"), + (1.0, "#050505"), + ], + ), + ), + 5.0, + ), + ); + b.add( + handle, + draw::with_effects( + draw::circle( + "mic head", + c, + c, + 13.0, + draw::radial_at( + 0.38, + 0.32, + 0.8, + &[(0.0, "#8a8a8e"), (0.5, "#3a3a3d"), (1.0, "#0b0b0c")], + ), + ), + vec![draw::drop_shadow("#00000080", 0.0, 2.0, 3.0)], + ), + ); + let mesh: Vec> = (1..4).map(|k| ring(c, c, 3.5 * f64::from(k))).collect(); + b.add( + handle, + ui::strokes("mic mesh", &mesh, 0.7, draw::solid("#b8b8bc80")), + ); + let tag = label + .chars() + .last() + .map(|ch| ch.to_string()) + .unwrap_or_default(); + centred( + b, + handle, + &tag, + (c, c + 27.0), + Type::new(8.0, "#f4f4f2", 800.0), + ); + let kind = ControlKind::Handle { + x: Some(ui::bind(pos)), + y: Some(ui::bind(dist)), + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + }; + b.controls.insert(handle, Control::new(kind)); + handle +} + +// ── lettering ──────────────────────────────────────────────────────────── + +/// How a line of lettering is set: size, colour, weight, tracking, case, +/// and the family (Inter unless set) — set in the font's own weight, never +/// faked. +#[derive(Clone, Copy)] +pub struct Type<'a> { + pub size: f64, + pub color: &'a str, + pub weight: f64, + /// Letter spacing, in ems. + pub track: f64, + pub upper: bool, + pub family: Option<&'a str>, + pub italic: bool, + /// Percent of normal width (families with a width axis). + pub width: Option, +} + +impl<'a> Type<'a> { + #[must_use] + pub const fn new(size: f64, color: &'a str, weight: f64) -> Self { + Self { + size, + color, + weight, + track: 0.0, + upper: false, + family: None, + italic: false, + width: None, + } + } + #[must_use] + pub const fn track(mut self, em: f64) -> Self { + self.track = em; + self + } + #[must_use] + pub const fn upper(mut self) -> Self { + self.upper = true; + self + } + #[must_use] + pub const fn family(mut self, family: &'a str) -> Self { + self.family = Some(family); + self + } + #[must_use] + pub const fn italic(mut self) -> Self { + self.italic = true; + self + } + #[must_use] + pub const fn width(mut self, percent: f64) -> Self { + self.width = Some(percent); + self + } +} + +/// The text node for `s` set as `t`, in a box `w` wide from `x`, centred +/// on `cy`. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn set( + s: &str, + x: f64, + cy: f64, + w: f64, + align: TextAlign, + t: Type<'_>, +) -> frame::document::proto::Node { + let mut n = ui::text( + "print", s, x, cy, w, t.size, t.color, align, t.weight, false, + ); + if let NodeKind::Text(d) = &mut n.kind { + d.letter_spacing = t.size * t.track; + if t.upper { + d.text_case = TextCase::Upper; + } + } + let mut l = t + .family + .map_or_else(draw::Lettering::default, draw::Lettering::family) + .weight(t.weight); + if t.italic { + l = l.italic(); + } + if let Some(wd) = t.width { + l = l.width(wd); + } + draw::lettered(n, &l) +} + +/// A line of lettering in a box `w` wide from `x`, aligned `align`, +/// vertically centred on `cy`. +#[allow(clippy::too_many_arguments)] +pub fn words( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + x: f64, + cy: f64, + w: f64, + align: TextAlign, + t: Type<'_>, +) -> NodeId { + b.add(parent, set(s, x, cy, w, align, t)) +} + +/// A generous box width for `s` set as `t`. +fn box_w(s: &str, t: Type<'_>) -> f64 { + (s.chars().count() as f64 * t.size * (0.72 + t.track) + t.size * 2.0).max(8.0) +} + +/// Lettering centred on (`cx`, `cy`). +pub fn centred( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + t: Type<'_>, +) -> NodeId { + let w = box_w(s, t); + words(b, parent, s, cx - w / 2.0, cy, w, TextAlign::Center, t) +} + +/// Stacked lines set from the left at `x`, the first centred on `cy`, +/// `lead` px apart (a pedal's name in the corner). +pub fn stacked( + b: &mut Builder<'_>, + parent: NodeId, + lines: &[&str], + (x, cy): (f64, f64), + lead: f64, + t: Type<'_>, +) { + for (i, l) in lines.iter().enumerate() { + words( + b, + parent, + l, + x, + cy + lead * i as f64, + box_w(l, t), + TextAlign::Left, + t, + ); + } +} + +/// Lettering with a cartoon outline (a maker's mark): one line of text +/// whose `outline` is a crisp glow `px` wide round the glyphs, dropped a +/// little so the letters sit on a thicker lower edge. +pub fn outlined( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + (cx, cy): (f64, f64), + t: Type<'_>, + outline: &str, + px: f64, +) -> NodeId { + let w = box_w(s, t); + let mut n = set(s, cx - w / 2.0, cy, w, TextAlign::Center, t); + let glow = |dy: f64| frame::document::proto::Effect { + kind: frame::document::proto::EffectKind::DropShadow, + visible: true, + color: draw::hex(outline), + offset_x: 0.0, + offset_y: dy, + radius: px * 0.25, + spread: px, + }; + n.effects = vec![glow(px * 0.7), glow(0.0)]; + b.add(parent, n) +} + +/// A stroked line drawing (icons, printed artwork), points in `parent`'s +/// space, round caps. +pub fn art( + b: &mut Builder<'_>, + parent: NodeId, + lines: &[Vec<(f64, f64)>], + weight: f64, + color: &str, +) -> NodeId { + b.add( + parent, + ui::strokes("art", lines, weight, draw::solid(color)), + ) +} + +/// A circle's outline as a polyline (for [`art`]). +#[must_use] +pub fn ring(cx: f64, cy: f64, r: f64) -> Vec<(f64, f64)> { + ui::ellipse_pts(cx, cy, r, r, 48) +} + +/// A rounded rectangle's outline as a polyline (for [`art`]). +#[must_use] +pub fn round_rect(x: f64, y: f64, w: f64, h: f64, r: f64) -> Vec<(f64, f64)> { + let mut pts = Vec::new(); + let corners = [ + (x + w - r, y + r, -90.0), + (x + w - r, y + h - r, 0.0), + (x + r, y + h - r, 90.0), + (x + r, y + r, 180.0), + ]; + for (cx, cy, a0) in corners { + for i in 0..=6 { + let a = (a0 + 15.0 * f64::from(i)).to_radians(); + pts.push((cx + r * a.cos(), cy + r * a.sin())); + } + } + pts.push(pts[0]); + pts +} + +/// A small screw head. +pub fn screw(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, r: f64, metal: &str) { + let head = draw::circle( + "screw", + cx, + cy, + r, + draw::radial_at(0.35, 0.3, 0.8, &[(0.0, "#f6f5f0"), (1.0, metal)]), + ); + b.add( + parent, + draw::with_effects( + head, + vec![ + draw::inner_shadow("#00000080", 0.0, 0.0, r * 0.35), + draw::drop_shadow("#00000066", 0.0, r * 0.2, r * 0.3), + ], + ), + ); + b.add( + parent, + draw::rotated( + draw::rect( + "slot", + cx - r * 0.62, + cy - r * 0.12, + r * 1.24, + r * 0.24, + draw::solid("#00000099"), + ), + 35.0, + ), + ); +} + +/// An input jack with a cable running out of it and down off the unit. +pub fn cable(b: &mut Builder<'_>, parent: NodeId, (cx, cy): (f64, f64), r: f64, to: &[(f64, f64)]) { + let nut = draw::path( + "jack nut", + cx - r * 1.3, + cy - r * 1.3, + r * 2.6, + r * 2.6, + draw::regular(r * 1.3, r * 1.3, r * 1.3, 6, 0.0), + draw::linear( + 160.0, + &[(0.0, "#f2f2ee"), (0.5, "#8d8e8b"), (1.0, "#d8d8d4")], + ), + ); + b.add( + parent, + draw::with_effects(nut, vec![draw::drop_shadow("#00000080", 0.0, 1.0, 2.0)]), + ); + b.add( + parent, + draw::circle( + "jack", + cx, + cy, + r * 0.8, + draw::radial(&[(0.0, "#1a1a1a"), (1.0, "#444444")]), + ), + ); + let mut pts = vec![(cx, cy)]; + pts.extend_from_slice(to); + b.add( + parent, + draw::with_effects( + ui::strokes("cable", &[pts.clone()], r * 0.9, draw::solid("#1b1b1d")), + vec![draw::drop_shadow("#00000070", 0.0, 3.0, 4.0)], + ), + ); + b.add( + parent, + ui::strokes( + "cable sheen", + &[pts + .iter() + .map(|(x, y)| (x - r * 0.15, y - r * 0.15)) + .collect()], + r * 0.2, + draw::solid("#ffffff22"), + ), + ); + b.add( + parent, + draw::circle( + "plug", + cx, + cy, + r * 0.62, + draw::radial_at(0.4, 0.35, 0.7, &[(0.0, "#e8e8e4"), (1.0, "#6a6a68")]), + ), + ); +} + +// ── catalog ────────────────────────────────────────────────────────────── + +/// A 0–10 amp-style control (printed 0…10), default `v`. +#[must_use] +pub fn dial10(addr: &str, name: &str, v: f64) -> ParamInfo { + ui::param(addr, name, Range::linear(0.0, 10.0, Unit::None), v).featured() +} + +/// A 0–100 % control. +#[must_use] +pub fn pct(addr: &str, name: &str, v: f64) -> ParamInfo { + ui::percent(addr, name, v).featured() +} + +/// A dB control over `lo..hi`. +#[must_use] +pub fn db(addr: &str, name: &str, lo: f64, hi: f64, v: f64) -> ParamInfo { + let p = ui::param(addr, name, Range::linear(lo, hi, Unit::Db), v).featured(); + if lo < 0.0 && hi > 0.0 { p.bipolar() } else { p } +} + +/// A frequency control over `lo..hi` Hz, logarithmic. +#[must_use] +pub fn hz(addr: &str, name: &str, lo: f64, hi: f64, v: f64) -> ParamInfo { + ui::param(addr, name, Range::log(lo, hi, Unit::Hz), v).featured() +} + +/// A time control in ms over `lo..hi`, logarithmic. +#[must_use] +pub fn ms(addr: &str, name: &str, lo: f64, hi: f64, v: f64) -> ParamInfo { + ui::param(addr, name, Range::log(lo, hi, Unit::Milliseconds), v).featured() +} + +/// The value of a log range `lo..hi` at position `t` (0..1): for setting a +/// default to where the reference's knob points. +#[must_use] +pub fn log_at(lo: f64, hi: f64, t: f64) -> f64 { + lo * (hi / lo).powf(t) +} + +/// An on/off. +#[must_use] +pub fn switch(addr: &str, name: &str, on: bool) -> ParamInfo { + ui::toggle(addr, name, on).featured() +} + +/// A stepped choice. +#[must_use] +pub fn pick(addr: &str, name: &str, options: &[&str], default: usize) -> ParamInfo { + ui::choice(addr, name, options, default).featured() +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm/pedals.rs b/examples/plugin-faces/src/suite_funk_rhythm/pedals.rs new file mode 100644 index 00000000..6e6d3c01 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm/pedals.rs @@ -0,0 +1,637 @@ +//! The standard-size stompboxes (156 × 286, the Standard class) as +//! rendered enclosures (`panel.pedal_3d`: the metallic lid, the copper +//! front face and base, the jacks out of the sides): black ridged knobs +//! with white teardrop caps inside rings of white dots, all at noon as the +//! reference shows them, the engage LED between the knobs, a white line +//! icon, arrows for the signal path, the stomp, and the name set lowercase +//! in a heavy sans, stacked in the bottom-left corner. +//! +//! Positions are fractions of the lid, measured off the reference. +//! +//! - `02-envelope-standard-156x286`: *the envelope pusher* — an envelope +//! filter (Range, Sensitivity, Attack, Decay). +//! - `03-squeeze-standard-156x286`: *the fourth slot compressor* — a +//! blend compressor (Blend, Tone, Compression, Volume). +//! - `04-glass-standard-156x286`: *the glass bottle* — a tube drive +//! (Tone, Drive, Level). +//! - `05-haul-standard-156x286`: *the heavy haul overdrive* (Tone, Drive, +//! Level). +//! - `11-neon-standard-156x286`: *The Neon* — a chorus (Mix, Rate, Width; +//! the LED breathes at the rate). +//! - `13-rinse-standard-156x286`: *The Rinse* — a reverb with a shimmer +//! button (Mix, Decay, low cut, high cut). + +use frame::document::proto::NodeId; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::ParamInfo; +use frame::surface::widgets::Builder; + +use super::parts::{self, KnobLook, Lid, PEDAL_H, STANDARD_W, Type}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +pub(super) const PLUGIN: &str = "suite-funk-rhythm"; +const WHITE: &str = "#fbfaf6"; +/// The engage LED's light. +const LED: &str = "#e8f6ff"; +/// The enclosures' front face and base: a rosy copper. +pub(super) const BASE: &str = "#b06f68"; +/// The pedals' knob (`knob.3d`): a black fluted body on a flared skirt, +/// a cream teardrop insert on top for the index. +pub(super) const KNOB: KnobLook<'static> = KnobLook { + cap: "pedal_chicken", + opts: &[ + "insert_color=#f1ede4", + "insert_metal=0", + "insert_rough=0.3", + "insert_r=0.8", + "insert_h=0.14", + "cap_r=0.72", + "fallback_color=#f2efe8", + "material=plastic", + "skirt_color=#18181a", + "ticks=dots", + ], +}; +/// The lid sits in from the sides as far as the reference's jacks stand out. +pub(super) const LID: &[&str] = &["jack_inset=13"]; +/// Knob diameter on a standard lid. +const KNOB_D: f64 = 33.0; + +/// A knob on a pedal: address, legend, centre (lid fractions), legend +/// above (else below). +pub(super) struct K<'a>(pub &'a str, pub &'a str, pub (f64, f64), pub bool); + +/// The shared top half: knobs and their legends. +pub(super) fn knobs(b: &mut Builder<'_>, lid: &Lid, ks: &[K<'_>], d: f64) { + let t = Type::new(7.8, WHITE, 800.0); + for K(addr, legend, (fx, fy), above) in ks { + let (x, y) = lid.at(*fx, *fy); + parts::knob3d(b, lid.id, &KNOB, addr, legend, d, (x, y), WHITE); + let ly = if *above { + y - d * 0.5 - 12.5 + } else { + y + d * 0.5 + 12.5 + }; + if !legend.is_empty() { + parts::centred(b, lid.id, legend, (x, ly), t); + } + } +} + +/// The in/out arrows either side at `y`. +pub(super) fn arrows(b: &mut Builder<'_>, lid: &Lid, y: f64) { + for x in [lid.w * 0.05, lid.w * 0.95 - 4.5] { + b.add( + lid.id, + draw::path( + "arrow", + 0.0, + 0.0, + lid.w, + lid.h, + draw::polygon(&[(x, y - 4.0), (x + 4.5, y), (x, y + 4.0)]), + draw::solid(WHITE), + ), + ); + } +} + +/// The DC jack's printed symbol and legend. +pub(super) fn dc(b: &mut Builder<'_>, lid: NodeId, (cx, cy): (f64, f64)) { + parts::centred(b, lid, "9VDC", (cx, cy - 6.0), Type::new(3.8, WHITE, 800.0)); + parts::art( + b, + lid, + &[ + parts::ring(cx - 5.5, cy, 1.9), + parts::ring(cx, cy, 2.3), + parts::ring(cx + 5.5, cy, 1.9), + vec![(cx - 3.6, cy), (cx - 2.3, cy)], + vec![(cx + 2.3, cy), (cx + 3.6, cy)], + ], + 0.8, + WHITE, + ); +} + +/// A standard pedal: `color` enclosure, knobs, LED, icon, stomp and name. +pub(super) struct Pedal<'a> { + pub title: &'a str, + pub ns: &'a str, + pub color: &'a str, + pub knobs: &'a [K<'a>], + pub name: &'a [&'a str], + pub icon: fn(&mut Builder<'_>, &Lid, (f64, f64)), + pub pulse: Option<&'a str>, + /// Extra controls (a push button): run after the knobs. + pub extra: Option, &Lid)>, +} + +pub(super) fn standard(p: &Pedal<'_>) -> frame::document::proto::Scene { + canvas(p.title, |b, root| { + let lid = parts::stompbox_with( + b, + root, + (STANDARD_W, PEDAL_H), + p.color, + "powder", + BASE, + 9.0, + LID, + ); + knobs(b, &lid, p.knobs, KNOB_D); + if let Some(extra) = p.extra { + extra(b, &lid); + } + parts::status_led( + b, + lid.id, + &format!("{}/on", p.ns), + 5.0, + lid.at(0.5, 0.46), + LED, + "light", + p.pulse, + ); + (p.icon)(b, &lid, lid.at(0.5, 0.615)); + arrows(b, &lid, lid.h * 0.72); + parts::footswitch( + b, + lid.id, + &format!("{}/on", p.ns), + "Bypass", + 24.0, + lid.at(0.5, 0.8), + false, + ); + dc(b, lid.id, lid.at(0.865, 0.862)); + let lines = p.name.len() as f64; + let lead = 9.4; + parts::stacked( + b, + lid.id, + p.name, + (lid.w * 0.06, lid.h * 0.955 - lead * (lines - 1.0)), + lead, + Type::new(10.6, WHITE, 800.0).track(-0.03), + ); + }) +} + +fn four(ns: &'static str, names: [(&'static str, &'static str); 4]) -> Vec> { + // Leaked once per face: the layout table lives as long as the face. + let a = + |i: usize| -> &'static str { Box::leak(format!("{ns}/{}", names[i].0).into_boxed_str()) }; + vec![ + K(a(0), names[0].1, (0.215, 0.16), true), + K(a(1), names[1].1, (0.785, 0.16), true), + K(a(2), names[2].1, (0.215, 0.342), false), + K(a(3), names[3].1, (0.785, 0.342), false), + ] +} + +fn three(ns: &'static str, names: [(&'static str, &'static str); 3]) -> Vec> { + let a = + |i: usize| -> &'static str { Box::leak(format!("{ns}/{}", names[i].0).into_boxed_str()) }; + vec![ + K(a(0), names[0].1, (0.5, 0.16), true), + K(a(1), names[1].1, (0.215, 0.342), false), + K(a(2), names[2].1, (0.785, 0.342), false), + ] +} + +fn face(file: &'static str, scene: frame::document::proto::Scene, params: Vec) -> Face { + ui::formatted(Face { + file, + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + }) +} + +// ── icons (white line art, ~34 px) ─────────────────────────────────────── + +fn envelope_icon(b: &mut Builder<'_>, lid: &Lid, (cx, cy): (f64, f64)) { + let (w, h) = (30.0, 20.0); + let (x, y) = (cx - w / 2.0, cy - h / 2.0); + let body = parts::round_rect(x, y, w, h, 2.0); + let flap = vec![ + (x + 1.0, y + 1.5), + (cx, y + h * 0.58), + (x + w - 1.0, y + 1.5), + ]; + let sweep: Vec<(f64, f64)> = (0..=24) + .map(|i| { + let u = f64::from(i) / 24.0; + ( + x + 3.0 + (w - 6.0) * u, + y + h + 7.0 - 5.0 * (u * std::f64::consts::PI).sin().powf(3.0), + ) + }) + .collect(); + parts::art(b, lid.id, &[body, flap, sweep], 1.8, WHITE); +} + +fn selector_icon(b: &mut Builder<'_>, lid: &Lid, (cx, cy): (f64, f64)) { + let base = cy + 7.0; + let mut lines = vec![vec![(cx - 25.0, base), (cx + 25.0, base)]]; + for i in 0..5 { + let x = cx - 20.0 + 10.0 * f64::from(i); + if i == 3 { + // The blade, leaning into the fourth slot. + lines.push(vec![ + (x - 2.0, base - 1.0), + (x - 5.0, base - 12.0), + (x - 2.0, base - 20.0), + (x + 3.0, base - 17.0), + (x + 2.0, base - 6.0), + (x - 2.0, base - 1.0), + ]); + } else { + lines.push(vec![(x - 2.5, base - 8.0), (x + 2.5, base - 3.0)]); + lines.push(vec![(x + 2.5, base - 8.0), (x - 2.5, base - 3.0)]); + } + // The slot's numeral as a tick below the line. + lines.push(vec![(x, base + 3.0), (x, base + 6.5)]); + } + parts::art(b, lid.id, &lines, 1.5, WHITE); +} + +fn tube_icon(b: &mut Builder<'_>, lid: &Lid, (cx, cy): (f64, f64)) { + let (w, h) = (16.0, 28.0); + let (x, y) = (cx - w / 2.0, cy - h / 2.0 - 2.0); + let mut envelope = vec![(x, y + h), (x, y + 6.0)]; + envelope.extend((0..=12).map(|i| { + let a = std::f64::consts::PI * (1.0 + f64::from(i) / 12.0); + (cx + w / 2.0 * a.cos(), y + 6.0 + 6.0 * a.sin()) + })); + envelope.push((x + w, y + h)); + envelope.push((x, y + h)); + let mut lines = vec![ + envelope, + vec![(cx - 1.5, y - 0.5), (cx - 1.5, y - 3.0)], + vec![(cx + 1.5, y - 0.5), (cx + 1.5, y - 3.0)], + ]; + // Plates and grid. + lines.push(parts::round_rect(x + 3.5, y + 9.0, w - 7.0, 11.0, 1.0)); + for i in 0..4 { + let gx = x + 5.5 + 1.7 * f64::from(i); + lines.push(vec![(gx, y + 10.5), (gx, y + 18.5)]); + } + // The base and its pins. + lines.push(vec![(x - 1.0, y + h + 2.0), (x + w + 1.0, y + h + 2.0)]); + for dx in [-4.5, -1.5, 1.5, 4.5] { + lines.push(vec![(cx + dx, y + h + 2.0), (cx + dx, y + h + 6.0)]); + } + parts::art(b, lid.id, &lines, 1.4, WHITE); +} + +fn truck_icon(b: &mut Builder<'_>, lid: &Lid, (cx, cy): (f64, f64)) { + let (x0, y0) = (cx - 22.0, cy - 13.0); + // The dump body, the cab, the chassis and two big wheels. + let bed = vec![ + (x0 + 14.0, y0 + 2.0), + (x0 + 44.0, y0), + (x0 + 42.0, y0 + 13.0), + (x0 + 16.0, y0 + 14.0), + (x0 + 14.0, y0 + 2.0), + ]; + let cab = vec![ + (x0 + 2.0, y0 + 16.0), + (x0 + 5.0, y0 + 7.0), + (x0 + 14.0, y0 + 7.0), + (x0 + 14.0, y0 + 16.0), + ]; + let chassis = vec![(x0, y0 + 18.0), (x0 + 44.0, y0 + 18.0)]; + let window = vec![ + (x0 + 6.5, y0 + 13.0), + (x0 + 7.5, y0 + 9.5), + (x0 + 12.0, y0 + 9.5), + (x0 + 12.0, y0 + 13.0), + (x0 + 6.5, y0 + 13.0), + ]; + let lines = vec![ + bed, + cab, + chassis, + window, + parts::ring(x0 + 10.0, y0 + 24.0, 5.5), + parts::ring(x0 + 32.0, y0 + 24.0, 5.5), + parts::ring(x0 + 10.0, y0 + 24.0, 2.0), + parts::ring(x0 + 32.0, y0 + 24.0, 2.0), + ]; + parts::art(b, lid.id, &lines, 1.7, WHITE); +} + +fn coil_icon(b: &mut Builder<'_>, lid: &Lid, (cx, cy): (f64, f64)) { + // A spring toy arched over: loops round a semicircle. + let mut lines = Vec::new(); + for i in 0..=26 { + let a = std::f64::consts::PI * (1.0 - f64::from(i) / 26.0); + let (ix, iy) = (cx + 13.0 * a.cos(), cy + 8.0 - 13.0 * a.sin()); + let (ox, oy) = (cx + 25.0 * a.cos(), cy + 8.0 - 25.0 * a.sin()); + lines.push(vec![(ix, iy), (ox, oy)]); + } + parts::art(b, lid.id, &lines, 1.3, WHITE); +} + +fn wave_icon(b: &mut Builder<'_>, lid: &Lid, (cx, cy): (f64, f64)) { + let mut lines = Vec::new(); + for k in 0..4 { + let off = f64::from(k) * 3.2; + lines.push( + (0..=40) + .map(|i| { + let u = f64::from(i) / 40.0; + let x = cx - 16.0 + 32.0 * u; + let env = (u * std::f64::consts::PI).sin(); + ( + x, + cy + off - 4.0 - 12.0 * env * (u * std::f64::consts::TAU * 1.5).cos(), + ) + }) + .collect(), + ); + } + parts::art(b, lid.id, &lines, 1.3, WHITE); +} + +// ── the pedals ─────────────────────────────────────────────────────────── + +pub(super) fn envelope() -> Face { + let ks = four( + "envelope", + [ + ("range", "Range"), + ("sens", "Sensitivity"), + ("attack", "Attack"), + ("decay", "Decay"), + ], + ); + let scene = standard(&Pedal { + title: "Envelope Pusher", + ns: "envelope", + color: "#d46c9b", + knobs: &ks, + name: &["the", "envelope", "pusher"], + icon: envelope_icon, + pulse: None, + extra: None, + }); + // Every knob at noon, as the reference sets them. + let params = vec![ + parts::pct("envelope/range", "Range", 50.0), + parts::pct("envelope/sens", "Sensitivity", 50.0), + parts::ms( + "envelope/attack", + "Attack", + 1.0, + 200.0, + parts::log_at(1.0, 200.0, 0.5), + ), + parts::ms( + "envelope/decay", + "Decay", + 20.0, + 2000.0, + parts::log_at(20.0, 2000.0, 0.5), + ), + parts::switch("envelope/on", "Bypass", true), + ]; + face("02-envelope-standard-156x286", scene, params) +} + +pub(super) fn squeeze() -> Face { + let ks = four( + "squeeze", + [ + ("blend", "Blend"), + ("tone", "Tone"), + ("comp", "Compression"), + ("volume", "Volume"), + ], + ); + let scene = standard(&Pedal { + title: "Fourth Slot Compressor", + ns: "squeeze", + color: "#4b72c2", + knobs: &ks, + name: &["the fourth", "slot", "compressor"], + icon: selector_icon, + pulse: None, + extra: None, + }); + let params = vec![ + parts::pct("squeeze/blend", "Blend", 50.0), + parts::pct("squeeze/tone", "Tone", 50.0), + parts::pct("squeeze/comp", "Compression", 50.0), + parts::db("squeeze/volume", "Volume", -12.0, 12.0, 0.0), + parts::switch("squeeze/on", "Bypass", true), + ]; + face("03-squeeze-standard-156x286", scene, params) +} + +pub(super) fn glass() -> Face { + let ks = three( + "glass", + [("tone", "Tone"), ("drive", "Drive"), ("level", "Level")], + ); + let scene = standard(&Pedal { + title: "Glass Bottle Drive", + ns: "glass", + color: "#5cb86b", + knobs: &ks, + name: &["the", "glass bottle"], + icon: tube_icon, + pulse: None, + extra: None, + }); + let params = vec![ + parts::pct("glass/tone", "Tone", 50.0), + parts::pct("glass/drive", "Drive", 50.0), + parts::db("glass/level", "Level", -12.0, 12.0, 0.0), + parts::switch("glass/on", "Bypass", true), + ]; + face("04-glass-standard-156x286", scene, params) +} + +pub(super) fn haul() -> Face { + let ks = three( + "haul", + [("tone", "Tone"), ("drive", "Drive"), ("level", "Level")], + ); + let scene = standard(&Pedal { + title: "Heavy Haul Overdrive", + ns: "haul", + color: "#c29e3a", + knobs: &ks, + name: &["the heavy haul", "overdrive"], + icon: truck_icon, + pulse: None, + extra: None, + }); + let params = vec![ + parts::pct("haul/tone", "Tone", 50.0), + parts::pct("haul/drive", "Drive", 50.0), + parts::db("haul/level", "Level", -12.0, 12.0, 0.0), + parts::switch("haul/on", "Bypass", true), + ]; + face("05-haul-standard-156x286", scene, params) +} + +pub(super) fn neon() -> Face { + let ks = three( + "neon", + [("mix", "Mix"), ("rate", "Rate"), ("width", "Width")], + ); + let scene = standard(&Pedal { + title: "The Neon Chorus", + ns: "neon", + color: "#a0c0d6", + knobs: &ks, + name: &["The", "Neon"], + icon: coil_icon, + pulse: Some("param(neon/rate)"), + extra: None, + }); + let params = vec![ + parts::pct("neon/mix", "Mix", 50.0), + ui::param( + "neon/rate", + "Rate", + frame::surface::proto::Range::log(0.1, 10.0, frame::surface::proto::Unit::Hz), + 1.0, + ) + .featured(), + parts::pct("neon/width", "Width", 50.0), + parts::switch("neon/on", "Bypass", true), + ]; + face("11-neon-standard-156x286", scene, params) +} + +fn shimmer_button(b: &mut Builder<'_>, lid: &Lid) { + let (cx, cy) = lid.at(0.5, 0.158); + let p = Place::new("Shimmer") + .size(14.0) + .variant("cream") + .bind(ui::bind("rinse/shimmer")) + .options(&["nolamp"]) + .legend("") + .touch(TOUCH_MIN); + let k = frame::surface::kit::place(b, lid.id, "button.round_cap", &p) + .unwrap_or_else(|e| panic!("{e}")); + ui::strip_legend(b, k); + parts::centre_on(b, k, (cx, cy)); + parts::centred( + b, + lid.id, + "Shimmer", + (cx, lid.h * 0.078), + Type::new(7.2, WHITE, 800.0), + ); + for (dx, s) in [(-10.0, "Off"), (10.0, "On")] { + parts::centred( + b, + lid.id, + s, + (cx + dx, cy + 23.0), + Type::new(4.6, WHITE, 800.0), + ); + } + let (y0, x0, x1) = (cy + 18.0, cx - 15.0, cx + 5.0); + parts::art( + b, + lid.id, + &[ + vec![ + (x0, y0), + (x0 + 1.0, y0 - 3.0), + (x0 + 9.0, y0 - 3.0), + (x0 + 10.0, y0), + (x0, y0), + ], + vec![(x1, y0 - 2.0), (x1 + 10.0, y0 - 2.0)], + vec![(x1, y0), (x1 + 10.0, y0)], + ], + 0.9, + WHITE, + ); +} + +pub(super) fn rinse() -> Face { + let ks = vec![ + K("rinse/mix", "Mix", (0.16, 0.15), true), + K("rinse/decay", "Decay", (0.84, 0.15), true), + K("rinse/low", "", (0.2, 0.345), false), + K("rinse/high", "", (0.8, 0.345), false), + ]; + let scene = standard(&Pedal { + title: "The Rinse Reverb", + ns: "rinse", + color: "#3ab4c6", + knobs: &ks, + name: &["The", "Rinse"], + icon: rinse_icon, + pulse: None, + extra: Some(shimmer_button), + }); + // Mix and decay at noon; the low cut turned to the left and the high + // cut to the right, as in the reference. + let params = vec![ + parts::pct("rinse/mix", "Mix", 50.0), + ui::param( + "rinse/decay", + "Decay", + frame::surface::proto::Range::log(0.3, 20.0, frame::surface::proto::Unit::Seconds), + parts::log_at(0.3, 20.0, 0.5), + ) + .featured(), + parts::hz( + "rinse/low", + "Low Cut", + 20.0, + 1000.0, + parts::log_at(20.0, 1000.0, 0.2), + ), + parts::hz( + "rinse/high", + "High Cut", + 1000.0, + 20000.0, + parts::log_at(1000.0, 20000.0, 0.8), + ), + parts::switch("rinse/shimmer", "Shimmer", false), + parts::switch("rinse/on", "Bypass", true), + ]; + face("13-rinse-standard-156x286", scene, params) +} + +/// The rinse's icon, plus the filter curves printed under its tone knobs. +fn rinse_icon(b: &mut Builder<'_>, lid: &Lid, at: (f64, f64)) { + wave_icon(b, lid, at); + filter_marks(b, lid.id, lid.at(0.2, 0.47), lid.at(0.8, 0.47)); +} + +/// The low-cut and high-cut curves printed under a pair of knobs. +pub(super) fn filter_marks(b: &mut Builder<'_>, lid: NodeId, lo: (f64, f64), hi: (f64, f64)) { + parts::art( + b, + lid, + &[ + vec![ + (lo.0 - 8.0, lo.1 + 3.0), + (lo.0 - 4.0, lo.1 - 2.0), + (lo.0 + 8.0, lo.1 - 2.0), + ], + vec![ + (hi.0 - 8.0, hi.1 - 2.0), + (hi.0 + 4.0, hi.1 - 2.0), + (hi.0 + 8.0, hi.1 + 3.0), + ], + ], + 1.4, + WHITE, + ); +} diff --git a/examples/plugin-faces/src/suite_funk_rhythm/wah.rs b/examples/plugin-faces/src/suite_funk_rhythm/wah.rs new file mode 100644 index 00000000..902a8655 --- /dev/null +++ b/examples/plugin-faces/src/suite_funk_rhythm/wah.rs @@ -0,0 +1,232 @@ +//! `01-wah-treadle-241x600`: the *Funk Wah*, seen from above (the Treadle +//! class, 241 × 600) — a royal-blue metallic casting with a raised rim +//! round a black rubber tread moulded in rows of chevrons. +//! +//! The tread is the control: a vertical `Fader` over `wah/position` whose +//! lane is the whole pad — drag toward the toe for treble. Its `Thumb` is +//! the light catching the tilted treadle, a soft band that rides toe-ward +//! with the position. Pressing the toe lip engages it (`wah/on`, a +//! footswitch over the lip), its LED set in the base above. + +use frame::document::proto::{Node, NodeId}; +use frame::surface::kit::draw; +use frame::surface::proto::{ControlKind, Orientation, SlotRole}; +use frame::surface::widgets::Builder; + +use super::parts::{self, TREADLE}; +use super::pedals::PLUGIN; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +const BLUE: &str = "#3f5fb3"; + +fn rounded_rect( + name: &str, + x: f64, + y: f64, + w: f64, + h: f64, + r: f64, + fill: frame::document::proto::Paint, +) -> Node { + draw::rounded(draw::rect(name, x, y, w, h, fill), r) +} + +/// Rows of embossed chevrons across the pad (`x, y, w, h`). +fn chevrons(b: &mut Builder<'_>, parent: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + let peaks = 3.0; + let pitch = w / peaks; + let amp = pitch * 0.55; + let rows = ((h + amp) / 24.0).ceil() as usize; + let mut dark = Vec::new(); + let mut light = Vec::new(); + for r in 0..rows { + let base = y - amp * 0.3 + r as f64 * 24.0; + let line: Vec<(f64, f64)> = (0..=(peaks as usize * 2)) + .map(|i| { + ( + x + pitch / 2.0 * i as f64, + base + if i % 2 == 0 { 0.0 } else { amp }, + ) + }) + .map(|(px, py)| (px, py.clamp(y + 1.0, y + h - 1.0))) + .collect(); + light.push( + line.iter() + .map(|(px, py)| (*px, (py + 1.6).min(y + h - 1.0))) + .collect::>(), + ); + dark.push(line); + } + b.add( + parent, + ui::strokes("ridge light", &light, 1.0, draw::solid("#8d8d8a80")), + ); + b.add( + parent, + ui::strokes("ridges", &dark, 3.2, draw::solid("#1b1b1b")), + ); +} + +pub(super) fn face() -> Face { + let scene = canvas("Funk Wah", |b, root| { + let (w, h) = TREADLE; + parts::page(b, root, w, h); + // The base casting, its top lip showing above the treadle. + let base = rounded_rect( + "base", + 18.0, + 0.0, + w - 36.0, + h - 6.0, + 40.0, + draw::linear(170.0, &[(0.0, "#5a79c9"), (0.5, BLUE), (1.0, "#2c4488")]), + ); + b.add( + root, + draw::with_effects( + base, + vec![ + draw::drop_shadow("#00000080", 0.0, 4.0, 10.0), + draw::inner_shadow("#ffffff40", 0.0, 1.5, 1.0), + ], + ), + ); + // Side hinge studs. + for x in [10.0, w - 14.0] { + b.add( + root, + rounded_rect( + "hinge", + x, + 330.0, + 4.0, + 34.0, + 1.5, + draw::linear( + 90.0, + &[(0.0, "#6d6e70"), (0.5, "#e9e9e6"), (1.0, "#6d6e70")], + ), + ), + ); + parts::screw(b, root, x + 2.0, 470.0, 3.0, "#8a8b88"); + } + // The treadle: an outer rim, a raised inner rim, the tread. + let (tx, ty, tw, th) = (8.0, 44.0, w - 16.0, h - 56.0); + let rim = rounded_rect( + "treadle", + tx, + ty, + tw, + th, + 30.0, + draw::linear( + 165.0, + &[(0.0, "#6b89d6"), (0.4, "#4565b8"), (1.0, "#2a4185")], + ), + ); + b.add( + root, + draw::with_effects( + rim, + vec![ + draw::drop_shadow("#00000099", 0.0, 3.0, 8.0), + draw::inner_shadow("#ffffff4d", 0.0, 1.5, 1.0), + draw::inner_shadow("#00000059", 0.0, -2.0, 4.0), + ], + ), + ); + let inner = rounded_rect( + "rim", + tx + 7.0, + ty + 7.0, + tw - 14.0, + th - 14.0, + 25.0, + draw::linear(170.0, &[(0.0, "#5877c8"), (1.0, "#324d9a")]), + ); + b.add( + root, + draw::with_effects( + inner, + vec![ + draw::drop_shadow("#00000066", 0.0, 1.0, 3.0), + draw::inner_shadow("#ffffff33", 0.0, 1.0, 0.5), + ], + ), + ); + let (px, py, pw, ph) = (tx + 19.0, ty + 19.0, tw - 38.0, th - 38.0); + let mut pad = draw::frame("Tread", px, py, pw, ph); + pad.fills = vec![draw::linear(180.0, &[(0.0, "#4a4a48"), (1.0, "#3a3a38")])]; + pad.corner_radii = frame::document::proto::CornerRadii::uniform(10.0); + pad.clips_content = true; + pad.effects = vec![draw::inner_shadow("#000000b3", 0.0, 2.0, 5.0)]; + let pad = b.add(root, pad); + chevrons(b, pad, (0.0, 0.0, pw, ph)); + // The treadle's position: the light on the tilted tread rides a + // lane the pad's height. + let lane = b.add(pad, draw::frame("lane", 0.0, 0.0, pw, ph)); + let band_h = 120.0; + let band = b.add(lane, draw::frame("tilt", 0.0, 0.0, pw, band_h)); + b.add( + band, + draw::rect( + "sheen", + 0.0, + 0.0, + pw, + band_h, + draw::linear( + 180.0, + &[(0.0, "#ffffff00"), (0.5, "#ffffff1f"), (1.0, "#ffffff00")], + ), + ), + ); + b.add( + band, + draw::rect( + "toe mark", + pw * 0.3, + band_h / 2.0 - 1.0, + pw * 0.4, + 2.0, + draw::solid("#ffffff3d"), + ), + ); + let mut c = frame::surface::proto::Control::new(ControlKind::Fader { + orientation: Orientation::Vertical, + }) + .slot(SlotRole::Thumb, band.0); + c.binding = Some(ui::bind("wah/position")); + b.controls.insert(lane, c); + // The toe lip engages it; its LED sits in the base above. + let lip = b.add( + root, + draw::frame("Toe switch", tx + 50.0, 0.0, tw - 100.0, ty), + ); + let mut fc = frame::surface::proto::Control::new(ControlKind::footswitch()); + fc.binding = Some(ui::bind("wah/on")); + b.controls.insert(lip, fc); + parts::status_led( + b, + root, + "wah/on", + 6.0, + (w / 2.0, 22.0), + "#ff4630", + "dark", + None, + ); + }); + let params = vec![ + parts::pct("wah/position", "Position", 50.0), + parts::switch("wah/on", "Engage", true), + ]; + ui::formatted(Face { + file: "01-wah-treadle-241x600", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + }) +} diff --git a/examples/plugin-faces/src/suite_modern_rhythm.rs b/examples/plugin-faces/src/suite_modern_rhythm.rs new file mode 100644 index 00000000..8c44f6d5 --- /dev/null +++ b/examples/plugin-faces/src/suite_modern_rhythm.rs @@ -0,0 +1,55 @@ +//! Suite: **Modern Rhythm** (`suite-modern-rhythm`) — a modern metal +//! producer's amp-sim suite recreated unit by unit as frame surfaces, drawn +//! after the real plugin's in-window screenshot and its manual's +//! straight-on renders. +//! +//! | file | unit | form | +//! |---|---|---| +//! | `01-pre-standard-compressor` | Compressor: brushed silver, ATTACK toggle | Pedal Standard 156 × 286 | +//! | `02-pre-standard-overdrive-one` | Overdrive 1: brushed teal, clip toggle | Pedal Standard | +//! | `03-pre-standard-delay-one` | Delay 1: brushed purple, LCD time readout | Pedal Standard | +//! | `04-pre-standard-overdrive-two` | Overdrive 2: brushed orange, 4 knobs | Pedal Standard | +//! | `05-amp-head-hex-rhythm` | Hex Rhythm: black tolex, hex lattice over lit valves, cream strip | Head 1200 × 545 | +//! | `06-amp-head-croc-clean` | Croc Clean: grey croc skin, steel strip, cream pointer knobs | Head 1200 × 545 | +//! | `07-amp-head-cube-crunch` | Cube Crunch: cream tolex, cube lattice, slate strip | Head 1200 × 545 | +//! | `08-amp-head-cage-lead` | Cage Lead: perforated black cage over valves | Head 1200 × 545 | +//! | `09-cab-4x12` | 4×12 cabinet behind hex mesh, with the mic strip | Cab 900 × 900 | +//! | `10-eq-tabletop-nine-band` | Nine-band graphic with violet-lit caps | Tabletop 1000 × 254 | +//! | `11-post-bigbox-delay-two` | Delay 2: slate blue, LCD, ENGAGE / TAP TEMPO | Pedal BigBox 404 × 286 | +//! | `12-post-standard-reverb` | Reverb: dusty rose, 4 knobs | Pedal Standard | +//! +//! No real names are printed: units carry generic names in the original's +//! lettering style, and the player's signature on the amps is an invented +//! scribble. + +pub mod amps; +pub mod cab; +pub mod common; +pub mod eq; +pub mod post; +pub mod pre; + +use crate::face::Face; + +/// The suite's plugin id. +pub const PLUGIN: &str = "suite-modern-rhythm"; + +/// Every knob of the suite is placed through this: the component for a +/// knob of kind `id`. The hardware knobs are all `knob.3d` (the GPU knob, +/// `knob.render` without the GPU stage); the cab's flat dials are the +/// plugin's own UI and draw themselves. +#[must_use] +pub const fn knob_id(id: &str) -> &str { + id +} + +/// Every unit of the suite, in signal order. +#[must_use] +pub fn faces() -> Vec { + let mut v = pre::faces(); + v.extend(amps::faces()); + v.extend(cab::faces()); + v.extend(eq::faces()); + v.extend(post::faces()); + v +} diff --git a/examples/plugin-faces/src/suite_modern_rhythm/amps.rs b/examples/plugin-faces/src/suite_modern_rhythm/amps.rs new file mode 100644 index 00000000..b67e3c7a --- /dev/null +++ b/examples/plugin-faces/src/suite_modern_rhythm/amps.rs @@ -0,0 +1,1472 @@ +//! Modern Rhythm amps: four heads, each a page of its own in the Head form +//! at the plugin faceplate's aspect (1200 × 545, 2.2 : 1 — the manual's +//! straight-on renders), the control strip across the bottom fifth. +//! +//! - **Hex Rhythm** (the plugin's own screenshot): black tolex, a lattice of +//! interlocking hexagons over four lit power valves, a cream strip of +//! black pointer knobs with 0–10 round them. +//! - **Croc Clean**: grey crocodile covering the whole front, a scratched +//! steel strip, cream pointer knobs in tick rings, a BRIGHT toggle. +//! - **Cube Crunch**: cream tolex, an ivory lattice of tumbling cubes over +//! the valves, a slate strip, black pointer knobs, BRIGHT. +//! - **Cage Lead**: a perforated black steel cage over glowing valves, +//! spun-cap knobs on a black strip, a wire handle. +//! +//! All four: a red illuminated rocker for power, an input jack with its +//! cable, and a player's scribble (invented) at the right end. + +use frame::document::proto::NodeId; +use frame::surface::kit::materials::{self, Grain, Material, light}; +use frame::surface::kit::{Place, draw, motion}; +use frame::surface::widgets::Builder; + +use crate::delay::parts; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Letter}; + +use super::PLUGIN; +use super::common::legend; + +pub fn faces() -> Vec { + vec![hex_rhythm(), croc_clean(), cube_crunch(), cage_lead()] +} + +pub const W: f64 = 1200.0; +pub const H: f64 = 545.0; + +// ── the cabinet ────────────────────────────────────────────────────────── + +/// A deterministic generator for textures. +struct Rng(u64); +impl Rng { + fn f(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } +} + +fn circle_d(x: f64, y: f64, r: f64, ccw: bool) -> String { + let k = 0.5523 * r; + if ccw { + format!( + "M {:.2} {y:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {y:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {y:.2} Z ", + x + r, + x + r, + y - k, + x + k, + y - r, + y - r, + x - k, + y - r, + x - r, + y - k, + x - r, + x - r, + y + k, + x - k, + y + r, + y + r, + x + k, + y + r, + x + r, + y + k, + x + r + ) + } else { + format!( + "M {:.2} {y:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {y:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {y:.2} Z ", + x + r, + x + r, + y + k, + x + k, + y + r, + y + r, + x - k, + y + r, + x - r, + y + k, + x - r, + x - r, + y - k, + x - k, + y - r, + y - r, + x + k, + y - r, + x + r, + y - k, + x + r + ) + } +} + +/// Leather grain on a covering: fine light and dark flecks. +fn grain( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + density: f64, + (light, dark): (f64, f64), + seed: u64, +) { + let mut r = Rng(seed | 1); + for (c, a) in [("#ffffff", light), ("#000000", dark)] { + let mut d = String::new(); + let n = (w * h * density) as usize; + for _ in 0..n { + let (px, py) = (x + r.f() * w, y + r.f() * h); + let s = 0.6 + r.f() * 1.4; + d.push_str(&format!( + "M {px:.1} {py:.1} l {s:.1} {:.1} l {:.1} {:.1} Z ", + s * 0.4, + -s * 0.7, + s * 0.6 + )); + } + b.add( + parent, + draw::with_opacity(draw::path("grain", 0.0, 0.0, W, H, d, draw::solid(c)), a), + ); + } +} + +/// The feet under the cabinet. +fn feet(b: &mut Builder<'_>, root: NodeId, (x0, x1): (f64, f64), y: f64) { + for x in [x0, x1] { + let foot = draw::rounded( + draw::rect( + "foot", + x - 45.0, + y - 4.0, + 90.0, + H - y + 2.0, + draw::linear(180.0, &[(0.0, "#2c2d30"), (1.0, "#0a0a0b")]), + ), + 6.0, + ); + b.add( + root, + draw::with_effects(foot, vec![draw::drop_shadow("#00000099", 0.0, 3.0, 6.0)]), + ); + } +} + +/// A black moulded carry handle (strap) on top of the cabinet. +fn strap_handle(b: &mut Builder<'_>, root: NodeId, (cx, top, w): (f64, f64, f64), color: &str) { + for x in [cx - w / 2.0, cx + w / 2.0 - 34.0] { + let cap = draw::rounded( + draw::rect( + "handle cap", + x, + top + 12.0, + 34.0, + 22.0, + draw::linear(180.0, &[(0.0, "#9a9b9d"), (1.0, "#3b3c3e")]), + ), + 3.0, + ); + b.add(root, cap); + } + let strap = draw::rounded( + draw::rect( + "handle", + cx - w / 2.0 + 14.0, + top, + w - 28.0, + 16.0, + draw::linear( + 180.0, + &[ + (0.0, &parts::mixed(color, "#ffffff", 0.25)), + (0.5, color), + (1.0, &parts::mixed(color, "#000000", 0.5)), + ], + ), + ), + 8.0, + ); + b.add( + root, + draw::with_effects(strap, vec![draw::drop_shadow("#000000a0", 0.0, 3.0, 5.0)]), + ); +} + +/// Black metal corner protectors at the cabinet's four corners. +fn corners(b: &mut Builder<'_>, root: NodeId, (x, y, w, h): (f64, f64, f64, f64)) { + for (cx, cy, sx, sy) in [ + (x, y, 1.0, 1.0), + (x + w, y, -1.0, 1.0), + (x, y + h, 1.0, -1.0), + (x + w, y + h, -1.0, -1.0), + ] { + let s = 30.0; + let (x0, y0) = ( + if sx > 0.0 { cx - 5.0 } else { cx - s + 5.0 }, + if sy > 0.0 { cy - 5.0 } else { cy - s + 5.0 }, + ); + let n = draw::rounded( + draw::rect( + "corner", + x0, + y0, + s, + s, + draw::linear( + 160.0, + &[(0.0, "#4a4c50"), (0.45, "#1c1d20"), (1.0, "#08080a")], + ), + ), + 9.0, + ); + b.add( + root, + draw::with_effects( + n, + vec![ + draw::drop_shadow("#000000b0", 0.0, 2.0, 3.0), + draw::inner_shadow("#ffffff30", 0.0, 1.0, 1.0), + ], + ), + ); + b.add( + root, + draw::circle( + "rivet", + cx + 7.0 * sx, + cy + 7.0 * sy, + 2.4, + draw::radial(&[(0.0, "#b9babd"), (1.0, "#1b1c1e")]), + ), + ); + } +} + +/// The cabinet box covered in `m` (tolex, croc, powder…) from the kit's +/// materials, lit from the upper left, its edge rolled, standing on the +/// shelf with a cast shadow. +fn covered( + b: &mut Builder<'_>, + root: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + m: &Material, + radius: f64, +) { + let under = draw::rounded( + draw::rect("cabinet", x, y, w, h, draw::solid("#101012")), + radius, + ); + b.add(root, draw::with_effects(under, light::stand(12.0, 1.0))); + materials::fill(b, root, (x, y, w, h), radius, m); + for n in light::edge("cabinet edge", (x, y, w, h), radius, 6.0, 1.0, true) { + b.add(root, n); + } + b.add( + root, + draw::stroked( + draw::rounded( + draw::rect("cabinet outline", x, y, w, h, draw::solid("#00000000")), + radius, + ), + "#000000", + 1.5, + ), + ); +} + +/// Crocodile scales in vector, over a croc-covered box (the material's +/// relief is pixels, which a face written out as markup doesn't carry): +/// a jittered lattice of rounded polygons, each inset from its neighbours +/// by a pale seam, lit on its upper-left edge and shaded on its lower +/// right, the scales running in taller rows as the hide's do. +fn croc_scales( + b: &mut Builder<'_>, + root: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + (px, py): (f64, f64), + seed: u64, +) { + let mut r = Rng(seed | 1); + let cols = (w / px).ceil() as usize + 2; + let rows = (h / py).ceil() as usize + 2; + let mut pts = vec![vec![(0.0, 0.0); cols + 1]; rows + 1]; + for (j, row) in pts.iter_mut().enumerate() { + for (i, p) in row.iter_mut().enumerate() { + let stagger = if j % 2 == 1 { px * 0.5 } else { 0.0 }; + *p = ( + x - px + i as f64 * px + stagger + (r.f() - 0.5) * px * 0.55, + y - py + j as f64 * py + (r.f() - 0.5) * py * 0.45, + ); + } + } + let (mut body, mut lit, mut shade) = (String::new(), String::new(), String::new()); + for j in 0..rows { + for i in 0..cols { + let q = [pts[j][i], pts[j][i + 1], pts[j + 1][i + 1], pts[j + 1][i]]; + let (cx, cy) = ( + q.iter().map(|p| p.0).sum::() / 4.0, + q.iter().map(|p| p.1).sum::() / 4.0, + ); + let inset = |k: f64, dx: f64, dy: f64| -> String { + let v: Vec<(f64, f64)> = q + .iter() + .map(|(qx, qy)| (cx + (qx - cx) * k + dx, cy + (qy - cy) * k + dy)) + .collect(); + // Rounded: corners cut by quadratic curves through the midpoints. + let mid = |a: (f64, f64), b: (f64, f64)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let m0 = mid(v[3], v[0]); + let mut d = format!("M {:.1} {:.1} ", m0.0, m0.1); + for k in 0..4 { + let m = mid(v[k], v[(k + 1) % 4]); + d.push_str(&format!( + "Q {:.1} {:.1} {:.1} {:.1} ", + v[k].0, v[k].1, m.0, m.1 + )); + } + d.push_str("Z "); + d + }; + body.push_str(&inset(0.84, 0.0, 0.0)); + lit.push_str(&inset(0.6, -px * 0.08, -py * 0.08)); + shade.push_str(&inset(0.7, px * 0.07, py * 0.08)); + } + } + let mut f = draw::frame("croc scales", x, y, w, h); + f.clips_content = true; + let f = b.add(root, f); + let at = |d: &str| { + let mut n = draw::path( + "scales", + -x, + -y, + x + w, + y + h, + d.to_string(), + draw::solid("#000000"), + ); + n.fills = Vec::new(); + n + }; + b.add( + f, + draw::rect("seams", 0.0, 0.0, w, h, draw::solid("#6d6962")), + ); + let mut n = at(&body); + n.name = "scales".into(); + n.fills = vec![draw::linear( + 165.0, + &[(0.0, "#58544d"), (0.5, "#403d38"), (1.0, "#2b2926")], + )]; + b.add(f, n); + let mut n = at(&shade); + n.name = "scale shade".into(); + n.fills = vec![draw::solid("#00000040")]; + b.add(f, n); + let mut n = at(&lit); + n.name = "scale light".into(); + n.fills = vec![draw::solid("#ffffff12")]; + b.add(f, n); + b.add( + f, + draw::rect("room light", 0.0, 0.0, w, h, light::sheen(0.12, 0.3)), + ); +} + +// ── valves ─────────────────────────────────────────────────────────────── + +/// A power valve seen through the front: glass, dark plates, the getter's +/// silvering at the top, and the heater's orange glow (lit and flickering +/// a little, as a filament does). +fn valve(b: &mut Builder<'_>, parent: NodeId, (cx, top, w, h): (f64, f64, f64, f64), glow: f64) { + let x = cx - w / 2.0; + b.add( + parent, + draw::rounded( + draw::rect( + "valve base", + x + w * 0.1, + top + h - 18.0, + w * 0.8, + 18.0, + draw::linear(180.0, &[(0.0, "#2b2420"), (1.0, "#0c0a09")]), + ), + 3.0, + ), + ); + let glass = draw::rounded( + draw::rect( + "glass", + x, + top, + w, + h - 14.0, + draw::linear( + 90.0, + &[ + (0.0, "#ffffff10"), + (0.2, "#ffffff2a"), + (0.5, "#ffffff08"), + (0.85, "#ffffff22"), + (1.0, "#ffffff0c"), + ], + ), + ), + w * 0.42, + ); + b.add(parent, draw::stroked(glass, "#ffffff26", 1.0)); + b.add( + parent, + draw::rounded( + draw::rect( + "getter", + x + 3.0, + top + 3.0, + w - 6.0, + h * 0.14, + draw::linear(180.0, &[(0.0, "#c9c3bd55"), (1.0, "#2a262200")]), + ), + w * 0.35, + ), + ); + let (px, pw) = (cx - w * 0.3, w * 0.6); + b.add( + parent, + draw::rect( + "plates", + px, + top + h * 0.26, + pw, + h * 0.5, + draw::linear( + 90.0, + &[(0.0, "#141210"), (0.5, "#3a342e"), (1.0, "#141210")], + ), + ), + ); + for i in 0..3 { + let gx = px + pw * (0.2 + 0.3 * f64::from(i)); + let g = draw::rect( + "heater", + gx - 1.5, + top + h * 0.3, + 3.0, + h * 0.42, + draw::linear( + 180.0, + &[(0.0, "#ffb86a"), (0.5, "#ff7a1a"), (1.0, "#ffb86a")], + ), + ); + let id = b.add( + parent, + draw::emissive(g, draw::hex("#ff7a1a"), 0.9 * glow, 14.0), + ); + motion::add(b, id, motion::flicker(0.08)); + } + let halo = draw::circle( + "glow", + cx, + top + h * 0.5, + w * 0.7, + draw::radial(&[(0.0, "#ff8a3a55"), (1.0, "#ff8a3a00")]), + ); + b.add(parent, draw::with_opacity(halo, glow)); +} + +/// Four big power valves and four small preamp valves across a chassis +/// window at `(x, y, w, h)`. +fn valve_row(b: &mut Builder<'_>, parent: NodeId, (x, y, w, h): (f64, f64, f64, f64), glow: f64) { + b.add( + parent, + draw::rect( + "chassis", + x, + y, + w, + h, + draw::linear( + 180.0, + &[(0.0, "#0a0908"), (0.7, "#171411"), (1.0, "#0c0a09")], + ), + ), + ); + let cx = x + w / 2.0; + let pitch = w * 0.12; + for i in 0..4 { + let vx = cx + (f64::from(i) - 1.5) * pitch; + valve( + b, + parent, + (vx - pitch * 0.45, y + h * 0.44, pitch * 0.34, h * 0.52), + glow * 0.7, + ); + } + for i in 0..4 { + let vx = cx + (f64::from(i) - 1.5) * pitch; + valve( + b, + parent, + (vx + pitch * 0.05, y + h * 0.1, pitch * 0.5, h * 0.86), + glow, + ); + } +} + +// ── grilles ────────────────────────────────────────────────────────────── + +/// A lattice of interlocking hexagons: each cell a hexagon with a smaller +/// one inside, joined by three spokes — cut from steel, the gaps showing +/// what is behind. +fn hex_lattice( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + r: f64, + color: &str, + weight: f64, +) { + let mut lines: Vec> = Vec::new(); + let dx = r * 3f64.sqrt(); + let dy = r * 1.5; + let rows = (h / dy).ceil() as i32 + 1; + let cols = (w / dx).ceil() as i32 + 1; + for j in 0..=rows { + for i in -1..=cols { + let cx = x + f64::from(i) * dx + if j % 2 == 1 { dx / 2.0 } else { 0.0 }; + let cy = y + f64::from(j) * dy; + let hexp = |rr: f64, rot: f64| -> Vec<(f64, f64)> { + (0..=6) + .map(|k| draw::polar(cx, cy, rr, rot + 60.0 * f64::from(k))) + .collect() + }; + lines.push(hexp(r, 0.0)); + lines.push(hexp(r * 0.48, 30.0)); + for k in 0..3 { + let a = 60.0 + 120.0 * f64::from(k); + lines.push(vec![ + draw::polar(cx, cy, r * 0.48 * 0.866, a), + draw::polar(cx, cy, r, a - 30.0), + ]); + } + } + } + let mut frame = draw::frame("lattice", x, y, w, h); + frame.clips_content = true; + let f = b.add(parent, frame); + let shifted: Vec> = lines + .iter() + .map(|l| l.iter().map(|(px, py)| (px - x, py - y)).collect()) + .collect(); + let n = parts::stroke_path("lattice", (0.0, 0.0, w, h), &shifted, weight, color, false); + b.add( + f, + draw::with_effects(n, vec![draw::drop_shadow("#000000c0", 0.0, 2.0, 3.0)]), + ); +} + +/// A lattice of tumbling cubes (isometric): hexagon outlines with three +/// edges in from alternate corners. +fn cube_lattice( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + r: f64, + color: &str, + weight: f64, +) { + let mut lines: Vec> = Vec::new(); + let dx = r * 3f64.sqrt(); + let dy = r * 1.5; + let rows = (h / dy).ceil() as i32 + 1; + let cols = (w / dx).ceil() as i32 + 1; + for j in 0..=rows { + for i in -1..=cols { + let cx = x + f64::from(i) * dx + if j % 2 == 1 { dx / 2.0 } else { 0.0 }; + let cy = y + f64::from(j) * dy; + lines.push( + (0..=6) + .map(|k| draw::polar(cx, cy, r, 60.0 * f64::from(k))) + .collect(), + ); + for k in 0..3 { + let a = 120.0 * f64::from(k) + if (i + j) % 2 == 0 { 0.0 } else { 60.0 }; + lines.push(vec![(cx, cy), draw::polar(cx, cy, r, a)]); + // A second, inset line per face: the lattice's double bars. + lines.push(vec![ + draw::polar(cx, cy, r * 0.5, a + 60.0), + draw::polar(cx, cy, r * 0.5, a + 60.0 + 60.0), + ]); + } + } + } + let mut frame = draw::frame("lattice", x, y, w, h); + frame.clips_content = true; + let f = b.add(parent, frame); + let shifted: Vec> = lines + .iter() + .map(|l| l.iter().map(|(px, py)| (px - x, py - y)).collect()) + .collect(); + let n = parts::stroke_path("lattice", (0.0, 0.0, w, h), &shifted, weight, color, false); + b.add( + f, + draw::with_effects(n, vec![draw::drop_shadow("#00000099", 0.0, 1.5, 2.0)]), + ); +} + +/// A perforated steel sheet: a staggered grid of round holes (cut out, so +/// what is behind shows), with the sheet's own sheen. +fn perforated( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + pitch: f64, + hole: f64, +) { + let mut d = format!( + "M {x:.2} {y:.2} L {:.2} {y:.2} L {:.2} {:.2} L {x:.2} {:.2} Z ", + x + w, + x + w, + y + h, + y + h + ); + let rows = (h / (pitch * 0.866)) as i32; + let cols = (w / pitch) as i32; + for j in 0..rows { + for i in 0..cols { + let cx = + x + pitch * 0.6 + f64::from(i) * pitch + if j % 2 == 1 { pitch / 2.0 } else { 0.0 }; + let cy = y + pitch * 0.55 + f64::from(j) * pitch * 0.866; + if cx + hole > x + w - 2.0 { + continue; + } + d.push_str(&circle_d(cx, cy, hole, true)); + } + } + let n = draw::path( + "cage", + 0.0, + 0.0, + W, + H, + d, + draw::linear( + 170.0, + &[(0.0, "#3a3b3e"), (0.5, "#222326"), (1.0, "#141517")], + ), + ); + b.add( + parent, + draw::with_effects(n, vec![draw::drop_shadow("#000000a0", 0.0, 2.0, 4.0)]), + ); +} + +// ── the strip ──────────────────────────────────────────────────────────── + +/// The control strip plate at `(x, y, w, h)` in `paint`, a bevelled edge. +fn strip( + b: &mut Builder<'_>, + root: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + paint: (&str, &str), + scratches: bool, + seed: u64, +) { + let n = draw::rect( + "strip", + x, + y, + w, + h, + draw::linear(180.0, &[(0.0, paint.0), (1.0, paint.1)]), + ); + b.add( + root, + draw::with_effects( + draw::stroked(n, "#00000080", 1.0), + vec![ + draw::inner_shadow("#ffffff30", 0.0, 1.5, 1.0), + draw::inner_shadow("#00000066", 0.0, -2.0, 3.0), + draw::drop_shadow("#00000080", 0.0, 2.0, 4.0), + ], + ), + ); + if scratches { + let mut r = Rng(seed | 1); + let mut d = String::new(); + for _ in 0..90 { + let (px, py) = (x + r.f() * w, y + 4.0 + r.f() * (h - 8.0)); + let (lx, ly) = (6.0 + r.f() * 28.0, (r.f() - 0.5) * 10.0); + d.push_str(&format!( + "M {px:.1} {py:.1} l {lx:.1} {ly:.1} l 0.5 0.4 l {:.1} {:.1} Z ", + -lx, -ly + )); + } + b.add( + root, + draw::with_opacity( + draw::path("scratches", 0.0, 0.0, W, H, d, draw::solid("#ffffff")), + 0.07, + ), + ); + grain(b, root, (x, y, w, h), 0.02, (0.05, 0.08), seed ^ 0x55); + } +} + +/// What the strip prints round an amp knob. +#[derive(Clone, Copy)] +enum Scale { + /// 0–10 round it. + Numerals, + /// A ring of `n` ticks, every fifth long. + Ticks(usize), +} + +/// The scale printed on the strip round a knob of boss diameter `d` at +/// (`cx`, `cy`) — fixed, in the strip's ink, outside the turning knob. +fn print_scale( + b: &mut Builder<'_>, + p: NodeId, + scale: Scale, + (cx, cy): (f64, f64), + d: f64, + ink: &str, +) { + let k = d / 40.0; + let r_print = d / 2.0 * 1.12 + 4.0 * k; + let sweep = 300.0; + match scale { + Scale::Numerals => { + let size = (6.2 * k).max(6.0); + for i in 0..=10 { + let a = (f64::from(i) / 10.0 - 0.5) * sweep; + b.add( + p, + draw::radial_label( + "numeral", + &i.to_string(), + (cx, cy), + r_print + size * 0.55, + a, + size, + ink, + ), + ); + } + } + Scale::Ticks(n) => { + let lines: Vec> = (0..n) + .map(|i| { + let a = (i as f64 / (n - 1) as f64 - 0.5) * sweep; + let len = if i % 5 == 0 { 5.5 * k } else { 3.2 * k }; + vec![ + draw::polar(cx, cy, r_print, a), + draw::polar(cx, cy, r_print + len, a), + ] + }) + .collect(); + b.add( + p, + parts::stroke_path("scale ticks", (0.0, 0.0, W, H), &lines, 0.8 * k, ink, false), + ); + } + } +} + +/// An amp knob: a `knob.3d` in `preset` + `opts`, its boss `d` across at +/// (`cx`, `cy`), the strip's `scale` round it and its legend cut above. +#[allow(clippy::too_many_arguments)] +fn knob( + b: &mut Builder<'_>, + p: NodeId, + preset: &str, + opts: &[&str], + scale: Scale, + addr: &str, + name: &str, + (cx, cy): (f64, f64), + d: f64, + ink: &str, + legend_y: f64, +) { + print_scale(b, p, scale, (cx, cy), d, ink); + let mut o: Vec = vec!["ticks=none".into()]; + o.extend(opts.iter().map(ToString::to_string)); + let place = Place::new(name) + .size(if preset == "amp_pointer" { + d * KNOB_SCALE + } else { + d * 1.2 + }) + .variant(preset) + .bind(rk::bind(addr)) + .touch(frame::surface::kit::TOUCH_MIN) + .ink(frame::surface::kit::Ink::Color(ink.into())); + let place = Place { + options: Some(o), + ..place + }; + let k = parts::put(b, p, super::knob_id("knob.3d"), &place, cx, cy); + parts::strip_legend(b, k); + legend( + b, + k, + &name.to_uppercase(), + (cx, legend_y), + Letter::new(10.5, 760.0, ink).track(0.06), + ); +} + +/// A pointer knob's footprint over its boss diameter: the fin runs out past +/// the boss to the scale. +const KNOB_SCALE: f64 = 1.4; + +/// The black pointer knob (Hex Rhythm, Cube Crunch): a black dome crossed +/// by a fin. +const BLACK_POINTER: &[&str] = &[ + "color=#141416", + "skirt=none", + "cap_r=0.8", + "height=0.8", + "rough=0.3", + "coat=0.5", + "fallback_color=#1c1c1e", + "pointer=#f2f0ea", +]; +/// The cream pointer knob (Croc Clean). +const CREAM_POINTER: &[&str] = &[ + "color=#eeece6", + "skirt=none", + "cap_r=0.8", + "height=0.8", + "rough=0.42", +]; +/// Cage Lead's black spun cap, a white index. +const BLACK_SPUN: &[&str] = &[ + "color=#34353a", + "pointer=#f2f2f0", + "skirt=none", + "cap_r=0.96", + "index_in=0.45", + "fallback_color=#2c2d31", +]; + +/// The input jack with its cable running off to the left and down. +fn jack(b: &mut Builder<'_>, root: NodeId, (cx, cy): (f64, f64)) { + let cable = [ + (cx, cy), + (cx - 8.0, cy + 30.0), + (cx - 40.0, cy + 80.0), + (cx - 90.0, cy + 110.0), + (cx - 160.0, cy + 122.0), + ]; + rk::curve( + b, + root, + "cable shadow", + (W, H), + &cable + .iter() + .map(|(x, y)| (x + 2.0, y + 5.0)) + .collect::>(), + 12.0, + "#00000080", + ); + rk::curve(b, root, "cable", (W, H), &cable, 10.0, "#161618"); + rk::curve( + b, + root, + "cable sheen", + (W, H), + &cable + .iter() + .map(|(x, y)| (x - 1.5, y - 1.5)) + .collect::>(), + 2.0, + "#ffffff22", + ); + b.add( + root, + draw::circle( + "jack nut", + cx, + cy, + 15.0, + draw::conic( + 20.0, + &[ + (0.0, "#e9e9e7"), + (0.25, "#6f7072"), + (0.5, "#d9d9d7"), + (0.75, "#5a5b5d"), + (1.0, "#e9e9e7"), + ], + ), + ), + ); + b.add( + root, + draw::circle( + "plug", + cx, + cy, + 9.0, + draw::radial(&[(0.0, "#3a3b3e"), (1.0, "#0b0b0c")]), + ), + ); +} + +/// The player's scribble: an invented signature-like flourish. +fn scribble(b: &mut Builder<'_>, root: NodeId, (cx, cy): (f64, f64), color: &str) { + let pts = [ + (cx - 26.0, cy + 12.0), + (cx - 24.0, cy - 14.0), + (cx - 22.0, cy + 8.0), + (cx - 12.0, cy - 20.0), + (cx - 8.0, cy + 10.0), + (cx - 2.0, cy - 2.0), + (cx + 4.0, cy + 8.0), + (cx + 10.0, cy - 8.0), + (cx + 12.0, cy + 9.0), + (cx + 20.0, cy - 6.0), + (cx + 24.0, cy + 14.0), + (cx + 30.0, cy + 2.0), + ]; + rk::curve(b, root, "scribble", (W, H), &pts, 1.6, color); + rk::curve( + b, + root, + "scribble", + (W, H), + &[ + (cx - 30.0, cy + 18.0), + (cx, cy + 20.0), + (cx + 34.0, cy + 15.0), + ], + 1.3, + color, + ); +} + +/// The red power rocker centred on (`cx`, `cy`). +fn power(b: &mut Builder<'_>, root: NodeId, addr: &str, (cx, cy): (f64, f64), h: f64) { + let p = Place::new("Power") + .size(h) + .variant("red") + .bind(rk::bind(addr)) + .touch(frame::surface::kit::TOUCH_MIN); + parts::put(b, root, "switch.lamp_rocker", &p, cx, cy); +} + +/// A thin vertical separator printed on the strip. +fn separator(b: &mut Builder<'_>, root: NodeId, x: f64, (y0, y1): (f64, f64), color: &str) { + parts::vrule(b, root, x, y0, y1, 1.0, color); +} + +fn params( + ns: &str, + knobs: &[(&str, &str, f64)], + toggles: &[(&str, &str, bool)], +) -> Vec { + let mut v: Vec = knobs + .iter() + .map(|(a, n, d)| { + let p = rk::dial10(&format!("{ns}/{a}"), n, *d); + if matches!(*a, "gain" | "master" | "output") { + p.featured() + } else { + p + } + }) + .collect(); + for (a, n, on) in toggles { + v.push(rk::toggle(&format!("{ns}/{a}"), n, *on)); + } + v +} + +// ── 05 · Hex Rhythm ────────────────────────────────────────────────────── + +/// Hex Rhythm — Head, 1200 × 545 (the plugin's faceplate, 2.2 : 1). Black +/// leather-grain tolex, corner protectors, a black steel hex lattice over +/// the lit valves, a cream strip: GAIN · BASS MID TREBLE · RESONANCE +/// PRESENCE MASTER OUTPUT, black pointer knobs with 0–10. +fn hex_rhythm() -> Face { + let ns = "mrhex"; + let knobs: [(&str, &str, f64); 8] = [ + ("gain", "Gain", 6.0), + ("bass", "Bass", 5.0), + ("mid", "Mid", 5.0), + ("treble", "Treble", 5.5), + ("resonance", "Resonance", 5.0), + ("presence", "Presence", 5.0), + ("master", "Master", 5.0), + ("output", "Output", 5.0), + ]; + let scene = canvas("Hex Rhythm", |b, root| { + parts::fit_root(b, root, W, H); + feet(b, root, (210.0, 1010.0), 510.0); + strap_handle(b, root, (W / 2.0 + 10.0, 14.0, 300.0), "#1c1d1f"); + let cab = (84.0, 66.0, 1090.0, 446.0); + covered( + b, + root, + cab, + &Material::Tolex { + grain: Grain::Levant, + color: "#232426".into(), + scale: 1.0, + }, + 10.0, + ); + grain(b, root, cab, 0.06, (0.05, 0.25), 0x4e11); + corners(b, root, cab); + // The grille window: valves behind a hex lattice, a black frame round it. + let g = (118.0, 100.0, 1022.0, 240.0); + b.add( + root, + draw::with_effects( + draw::rect( + "grille frame", + g.0 - 6.0, + g.1 - 6.0, + g.2 + 12.0, + g.3 + 12.0, + draw::solid("#0b0b0c"), + ), + vec![draw::inner_shadow("#000000", 0.0, 2.0, 4.0)], + ), + ); + valve_row(b, root, g, 1.0); + hex_lattice(b, root, g, 38.0, "#2a2b2f", 7.5); + for (x, y) in [ + (g.0 + 3.0, g.1 + 3.0), + (g.0 + g.2 - 3.0, g.1 + 3.0), + (g.0 + 3.0, g.1 + g.3 - 3.0), + (g.0 + g.2 - 3.0, g.1 + g.3 - 3.0), + ] { + b.add( + root, + draw::circle( + "screw", + x, + y, + 3.0, + draw::radial(&[(0.0, "#8a8b8d"), (1.0, "#1a1a1c")]), + ), + ); + } + // The strip. + let s = (118.0, 358.0, 1022.0, 124.0); + strip(b, root, s, ("#ebe6da", "#dcd6c8"), false, 1); + const INK: &str = "#1a1a1a"; + let xs = [245.0, 330.0, 429.0, 527.0, 623.0, 714.0, 809.0, 905.0]; + for ((a, n, _), x) in knobs.iter().zip(xs) { + knob( + b, + root, + "amp_pointer", + BLACK_POINTER, + Scale::Numerals, + &format!("{ns}/{a}"), + n, + (x, 430.0), + 34.0, + INK, + 390.0, + ); + } + separator(b, root, 287.0, (386.0, 470.0), "#1a1a1a90"); + separator(b, root, 576.0, (386.0, 470.0), "#1a1a1a90"); + jack(b, root, (156.0, 422.0)); + power(b, root, &format!("{ns}/power"), (988.0, 424.0), 58.0); + scribble(b, root, (1072.0, 424.0), "#1a1a1a"); + }); + let params = params(ns, &knobs, &[("power", "Power", true)]); + Face { + file: "05-amp-head-hex-rhythm", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 06 · Croc Clean ────────────────────────────────────────────────────── + +/// Croc Clean — Head, 1200 × 545. Grey crocodile over the whole front, a +/// scratched steel strip: BRIGHT, then GAIN BASS MIDDLE TREBLE MASTER +/// OUTPUT on cream pointer knobs inside tick rings. +fn croc_clean() -> Face { + let ns = "mrcroc"; + let knobs: [(&str, &str, f64); 6] = [ + ("gain", "Gain", 4.0), + ("bass", "Bass", 5.0), + ("middle", "Middle", 5.0), + ("treble", "Treble", 6.0), + ("master", "Master", 5.0), + ("output", "Output", 5.0), + ]; + let scene = canvas("Croc Clean", |b, root| { + parts::fit_root(b, root, W, H); + feet(b, root, (240.0, 1010.0), 505.0); + // A straight bar handle with chrome end caps. + let bar = draw::rounded( + draw::rect( + "handle", + 380.0, + 22.0, + 440.0, + 10.0, + draw::linear(180.0, &[(0.0, "#3a3b3e"), (1.0, "#0c0c0d")]), + ), + 5.0, + ); + b.add( + root, + draw::with_effects(bar, vec![draw::drop_shadow("#00000099", 0.0, 3.0, 4.0)]), + ); + for x in [355.0, 815.0] { + b.add( + root, + draw::rounded( + draw::rect( + "handle cap", + x, + 18.0, + 110.0 * 0.3, + 20.0, + draw::linear(180.0, &[(0.0, "#e2e3e5"), (1.0, "#7c7d80")]), + ), + 3.0, + ), + ); + } + let cab = (100.0, 40.0, 1070.0, 470.0); + covered( + b, + root, + cab, + &Material::Croc { + color: "#4a4741".into(), + scale: 1.25, + }, + 10.0, + ); + croc_scales( + b, + root, + (cab.0 + 3.0, cab.1 + 3.0, cab.2 - 6.0, cab.3 - 6.0), + (15.0, 21.0), + 0xc40c, + ); + corners(b, root, cab); + // The front's upper edge: a dark piping line. + b.add( + root, + draw::stroked( + draw::rect( + "front frame", + 140.0, + 76.0, + 990.0, + 290.0, + draw::solid("#00000000"), + ), + "#1a1917", + 3.0, + ), + ); + let s = (135.0, 364.0, 1000.0, 124.0); + strip(b, root, s, ("#5a5c5f", "#3a3c3f"), true, 0x5c7a); + const INK: &str = "#f2f2f0"; + let xs = [357.0, 463.0, 568.0, 673.0, 777.0, 884.0]; + for ((a, n, _), x) in knobs.iter().zip(xs) { + knob( + b, + root, + "amp_pointer", + CREAM_POINTER, + Scale::Ticks(21), + &format!("{ns}/{a}"), + n, + (x, 430.0), + 40.0, + INK, + 384.0, + ); + } + let t = rk::mini_toggle( + b, + root, + "Bright", + &format!("{ns}/bright"), + (279.0, 430.0 - 6.0), + 14.0, + "chrome", + ); + legend( + b, + t, + "BRIGHT", + (279.0, 395.0), + Letter::new(10.0, 760.0, INK).track(0.06), + ); + jack(b, root, (202.0, 426.0)); + power(b, root, &format!("{ns}/power"), (985.0, 424.0), 58.0); + scribble(b, root, (1072.0, 424.0), "#f2f2f0"); + }); + let params = params( + ns, + &knobs, + &[("bright", "Bright", false), ("power", "Power", true)], + ); + Face { + file: "06-amp-head-croc-clean", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 07 · Cube Crunch ───────────────────────────────────────────────────── + +/// Cube Crunch — Head, 1200 × 545. Cream tolex with chrome corners and a +/// brown leather handle, an ivory lattice of tumbling cubes over pale +/// valves, a slate strip: BRIGHT, GAIN · BASS MID TREBLE · PRESENCE DEPTH +/// MASTER OUTPUT on black pointer knobs with 0–10. +fn cube_crunch() -> Face { + let ns = "mrcube"; + let knobs: [(&str, &str, f64); 8] = [ + ("gain", "Gain", 5.0), + ("bass", "Bass", 5.0), + ("mid", "Mid", 5.0), + ("treble", "Treble", 5.0), + ("presence", "Presence", 5.0), + ("depth", "Depth", 5.0), + ("master", "Master", 5.0), + ("output", "Output", 5.0), + ]; + let scene = canvas("Cube Crunch", |b, root| { + parts::fit_root(b, root, W, H); + feet(b, root, (200.0, 1030.0), 508.0); + strap_handle(b, root, (W / 2.0 - 25.0, 10.0, 300.0), "#9c5a3a"); + let cab = (84.0, 62.0, 1090.0, 448.0); + covered( + b, + root, + cab, + &Material::Tolex { + grain: Grain::Bronco, + color: "#e4dece".into(), + scale: 1.0, + }, + 10.0, + ); + grain(b, root, cab, 0.05, (0.2, 0.08), 0xc0be); + // Chrome corners instead of black. + for (cx, cy) in [ + (cab.0 + 6.0, cab.1 + 6.0), + (cab.0 + cab.2 - 6.0, cab.1 + 6.0), + (cab.0 + 6.0, cab.1 + cab.3 - 6.0), + (cab.0 + cab.2 - 6.0, cab.1 + cab.3 - 6.0), + ] { + b.add( + root, + draw::with_effects( + draw::circle( + "corner", + cx, + cy, + 11.0, + draw::radial_at( + 0.4, + 0.35, + 0.8, + &[(0.0, "#ffffff"), (0.5, "#a5a6a8"), (1.0, "#4b4c4e")], + ), + ), + vec![draw::drop_shadow("#00000080", 0.0, 1.0, 2.0)], + ), + ); + } + let g = (120.0, 96.0, 1018.0, 244.0); + b.add( + root, + draw::rect("grille back", g.0, g.1, g.2, g.3, draw::solid("#0b0b0b")), + ); + valve_row(b, root, g, 0.35); + // The lattice itself, ivory, with its own faint frosting over the valves. + cube_lattice(b, root, g, 30.0, "#ece9e1", 5.0); + b.add( + root, + draw::stroked( + draw::rect("grille edge", g.0, g.1, g.2, g.3, draw::solid("#00000000")), + "#c9c4b6", + 3.0, + ), + ); + let s = (120.0, 360.0, 1018.0, 124.0); + strip(b, root, s, ("#44474b", "#2e3134"), true, 0xc7); + const INK: &str = "#f0f0ee"; + let xs = [301.0, 391.0, 476.0, 561.0, 651.0, 741.0, 815.0, 914.0]; + for ((a, n, _), x) in knobs.iter().zip(xs) { + knob( + b, + root, + "amp_pointer", + BLACK_POINTER, + Scale::Numerals, + &format!("{ns}/{a}"), + n, + (x, 428.0), + 30.0, + INK, + 384.0, + ); + } + separator(b, root, 346.0, (380.0, 465.0), "#f0f0ee70"); + separator(b, root, 608.0, (380.0, 465.0), "#f0f0ee70"); + let t = rk::mini_toggle( + b, + root, + "Bright", + &format!("{ns}/bright"), + (227.0, 430.0 - 6.0), + 14.0, + "chrome", + ); + legend( + b, + t, + "BRIGHT", + (227.0, 392.0), + Letter::new(9.5, 760.0, INK).track(0.06), + ); + jack(b, root, (170.0, 422.0)); + power(b, root, &format!("{ns}/power"), (1000.0, 424.0), 56.0); + scribble(b, root, (1080.0, 424.0), "#f0f0ee"); + }); + let params = params( + ns, + &knobs, + &[("bright", "Bright", false), ("power", "Power", true)], + ); + Face { + file: "07-amp-head-cube-crunch", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 08 · Cage Lead ─────────────────────────────────────────────────────── + +/// Cage Lead — Head, 1200 × 545. A black perforated steel cage over four +/// glowing valves, a bent-wire handle, a black scratched strip: GAIN BASS +/// MIDDLE TREBLE MASTER OUTPUT on black spun-cap knobs in tick rings. +fn cage_lead() -> Face { + let ns = "mrcage"; + let knobs: [(&str, &str, f64); 6] = [ + ("gain", "Gain", 7.0), + ("bass", "Bass", 5.0), + ("middle", "Middle", 5.0), + ("treble", "Treble", 5.0), + ("master", "Master", 5.0), + ("output", "Output", 5.0), + ]; + let scene = canvas("Cage Lead", |b, root| { + parts::fit_root(b, root, W, H); + feet(b, root, (270.0, 1000.0), 505.0); + // The bent-wire handle. + let wire = [ + (450.0, 92.0), + (450.0, 30.0), + (455.0, 24.0), + (770.0, 24.0), + (775.0, 30.0), + (775.0, 92.0), + ]; + parts::line( + b, + root, + "handle shadow", + &wire + .iter() + .map(|(x, y)| (x + 2.0, y + 3.0)) + .collect::>(), + 6.0, + "#00000080", + ); + parts::line(b, root, "handle", &wire, 5.0, "#2e2f31"); + parts::line( + b, + root, + "handle sheen", + &wire + .iter() + .map(|(x, y)| (x - 1.0, y - 1.0)) + .collect::>(), + 1.2, + "#8a8b8d", + ); + let cab = (118.0, 90.0, 1005.0, 418.0); + covered( + b, + root, + cab, + &Material::Powder { + color: "#232427".into(), + coarse: true, + }, + 6.0, + ); + grain(b, root, cab, 0.08, (0.06, 0.12), 0xca9e); + let g = (126.0, 98.0, 989.0, 262.0); + valve_row(b, root, g, 1.0); + perforated(b, root, g, 17.0, 5.6); + // A folded lip where the cage meets the strip. + b.add( + root, + draw::rect( + "lip", + 118.0, + 358.0, + 1005.0, + 8.0, + draw::linear(180.0, &[(0.0, "#3b3c3f"), (1.0, "#0e0f10")]), + ), + ); + let s = (118.0, 366.0, 1005.0, 132.0); + strip(b, root, s, ("#2a2b2e", "#17181a"), true, 0xca9e); + const INK: &str = "#eeeeec"; + let xs = [277.0, 395.0, 513.0, 632.0, 752.0, 871.0]; + for ((a, n, _), x) in knobs.iter().zip(xs) { + knob( + b, + root, + "spun_metal", + BLACK_SPUN, + Scale::Ticks(21), + &format!("{ns}/{a}"), + n, + (x, 444.0), + 44.0, + INK, + 400.0, + ); + } + jack(b, root, (178.0, 432.0)); + power(b, root, &format!("{ns}/power"), (962.0, 440.0), 56.0); + scribble(b, root, (1050.0, 440.0), "#eeeeec"); + }); + let params = params(ns, &knobs, &[("power", "Power", true)]); + Face { + file: "08-amp-head-cage-lead", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_modern_rhythm/cab.rs b/examples/plugin-faces/src/suite_modern_rhythm/cab.rs new file mode 100644 index 00000000..21e3a146 --- /dev/null +++ b/examples/plugin-faces/src/suite_modern_rhythm/cab.rs @@ -0,0 +1,916 @@ +//! Modern Rhythm cab: a closed-back 4×12 behind a black honeycomb mesh, on +//! casters — Cab form, 900 × 900 — with the suite's mic strip under it, as +//! the plugin's cab page lays its controls out round the cabinet: the mic +//! (six generic models, and a custom IR slot) with its on switch, POSITION, +//! DISTANCE, MIC LEVEL and PAN on flat dark dials with value arcs, ROOM +//! SEND and its LEVEL, and PHASE. A mic stands at the upper-left cone. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::draw; +use frame::surface::kit::materials::{self, Grain, Material, light}; +use frame::surface::proto::{ + Axis, ButtonMode, Control, ControlKind, HandleAxis, SlotRole, ValueFormat, +}; +use frame::surface::widgets::Builder; + +use crate::delay::parts::{self, Ink}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Letter}; + +use super::PLUGIN; + +const W: f64 = 900.0; +const H: f64 = 900.0; +const INK: &str = "#e4e4e6"; +const DIM: &str = "#9a9ba0"; +const MICS: &[&str] = &[ + "Dynamic A", + "Dynamic A Off-Axis", + "Dynamic B", + "Condenser A", + "Condenser B", + "Ribbon A", + "Ribbon B", + "Custom IR", +]; + +pub fn faces() -> Vec { + vec![four_by_twelve()] +} + +/// The plugin's own flat dial: a dark disc in a track ring, a light arc +/// from the start of travel to the value, a pointer line; the reading +/// printed under it and the name under that. +#[allow(clippy::too_many_arguments)] +pub fn ui_knob( + b: &mut Builder<'_>, + p: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + d: f64, + format: Option, +) -> NodeId { + let w = d.max(44.0) + 30.0; + let h = d + 38.0; + let root = b.add(p, draw::frame(name, cx - w / 2.0, cy - d / 2.0, w, h)); + let dial = b.add(root, draw::frame("dial", (w - d) / 2.0, 0.0, d, d)); + let ring = (d * 0.11).max(3.0); + let mut track = draw::path( + "track", + 0.0, + 0.0, + d, + d, + frame::surface::widgets::sector_path( + d * 0.5, + d * 0.5, + d * 0.5 - ring, + d * 0.5, + -150f64.to_radians(), + 150f64.to_radians(), + ), + draw::solid("#3a3b3f"), + ); + track.name = "track".into(); + b.add(dial, track); + let mut arc = + frame::document::proto::Node::new(frame::document::proto::NodeKind::Ellipse, "value-arc"); + arc.size = frame::document::proto::Size { + width: d, + height: d, + }; + arc.fills = vec![draw::solid("#f2f2f2")]; + let arc = b.add(dial, arc); + let inset = ring + d * 0.06; + let cap = draw::circle( + "cap", + d / 2.0, + d / 2.0, + d / 2.0 - inset, + draw::linear(180.0, &[(0.0, "#3a3b3f"), (1.0, "#232427")]), + ); + b.add( + dial, + draw::with_effects( + draw::stroked(cap, "#48494e", 1.0), + vec![draw::drop_shadow("#00000080", 0.0, 2.0, 4.0)], + ), + ); + let pw = (d * 0.05).max(2.0); + let pointer = b.add( + dial, + draw::rounded( + draw::rect( + "pointer", + d / 2.0 - pw / 2.0, + inset + d * 0.05, + pw, + (d / 2.0 - inset) * 0.8, + draw::solid("#f2f2f2"), + ), + pw / 2.0, + ), + ); + let mut v = parts::text_node( + "", + 0.0, + d + 12.0, + w, + &Ink::new(11.0, INK).weight(600.0).track(0.0).plain(), + TextAlign::Center, + ); + v.name = "value".into(); + let value = b.add(root, v); + let label = rk::letter( + b, + root, + &name.to_uppercase(), + (w / 2.0, d + 30.0), + Letter::new(9.5, 640.0, DIM).track(0.04), + ); + let mut c = Control::new(ControlKind::Knob { + sweep_degrees: 300.0, + }) + .slot(SlotRole::ValueArc, arc.0) + .slot(SlotRole::Needle, pointer.0) + .slot(SlotRole::Value, value.0) + .slot(SlotRole::Label, label.0); + c.binding = Some(rk::bind(addr)); + c.format = format; + b.controls.insert(root, c); + root +} + +/// A pill toggle (the plugin's switch): a track that lights, a knob that +/// slides — `Thumb` travels right when on. +fn pill(b: &mut Builder<'_>, p: NodeId, name: &str, addr: &str, (cx, cy): (f64, f64)) -> NodeId { + let root = b.add(p, draw::frame(name, cx - 22.0, cy - 22.0, 44.0, 44.0)); + b.add( + root, + draw::rounded( + draw::rect("track", 6.0, 14.0, 32.0, 16.0, draw::solid("#e8e8ea")), + 8.0, + ), + ); + let lane = b.add(root, draw::frame("lane", 8.0, 16.0, 28.0, 12.0)); + let thumb = b.add( + lane, + draw::circle("thumb", 6.0, 6.0, 5.5, draw::solid("#141416")), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Thumb, thumb.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// An outlined text button (PHASE Ø), latching. +fn outline_button( + b: &mut Builder<'_>, + p: NodeId, + name: &str, + addr: &str, + (cx, cy, w): (f64, f64, f64), +) -> NodeId { + let root = b.add(p, draw::frame(name, cx - w / 2.0, cy - 22.0, w, 44.0)); + b.add( + root, + draw::stroked( + draw::rounded( + draw::rect("outline", 0.0, 6.0, w, 32.0, draw::solid("#00000000")), + 5.0, + ), + "#8e8f94", + 1.4, + ), + ); + let lit = b.add( + root, + draw::rounded( + draw::rect("latched", 1.0, 7.0, w - 2.0, 30.0, draw::solid("#ffffff22")), + 4.0, + ), + ); + let label = rk::letter( + b, + root, + &format!("{} Ø", name.to_uppercase()), + (w / 2.0, 22.0), + Letter::new(11.0, 650.0, INK).track(0.04), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, lit.0) + .slot(SlotRole::Label, label.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// The mic picker: a black box printing the chosen mic, a chevron. +fn mic_picker(b: &mut Builder<'_>, p: NodeId, addr: &str, (x, cy, w): (f64, f64, f64)) -> NodeId { + let root = b.add( + p, + draw::rounded(draw::frame("Mic", x, cy - 22.0, w, 44.0), 5.0), + ); + if let Some(n) = b.scene.node_mut(root) { + n.fills = vec![draw::solid("#050506")]; + } + let mut t = parts::text_node( + "", + 14.0, + 22.0, + w - 40.0, + &Ink::new(14.0, INK).weight(500.0).track(0.0).plain(), + TextAlign::Left, + ); + t.name = "value".into(); + let value = b.add(root, t); + rk::line( + b, + root, + &[(w - 26.0, 19.0), (w - 20.0, 25.0), (w - 14.0, 19.0)], + 1.8, + INK, + ); + let mut c = Control::new(ControlKind::Selector).slot(SlotRole::Value, value.0); + c.binding = Some(rk::bind(addr)); + c.format = Some(ValueFormat::labels(MICS)); + b.controls.insert(root, c); + root +} + +/// The mic's power: a round outlined button with the power glyph. +fn mic_power(b: &mut Builder<'_>, p: NodeId, addr: &str, (cx, cy): (f64, f64)) -> NodeId { + let root = b.add(p, draw::frame("Mic On", cx - 22.0, cy - 22.0, 44.0, 44.0)); + b.add( + root, + draw::rounded( + draw::rect("bg", 0.0, 0.0, 44.0, 44.0, draw::solid("#2a2b2f")), + 5.0, + ), + ); + let arc = draw::path( + "glyph", + 0.0, + 0.0, + 44.0, + 44.0, + frame::surface::widgets::sector_path( + 22.0, + 23.0, + 8.0, + 9.6, + 35f64.to_radians(), + 325f64.to_radians(), + ), + draw::solid(INK), + ); + let on = b.add(root, draw::emissive(arc, draw::hex(INK), 0.3, 4.0)); + b.add( + root, + draw::rect("stem", 21.2, 12.0, 1.6, 10.0, draw::solid(INK)), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, on.0); + c.binding = Some(rk::bind(addr)); + b.controls.insert(root, c); + root +} + +/// A honeycomb mesh over `(x, y, w, h)`: zigzag rows and the short uprights +/// between them, as one stroked path. +fn honeycomb( + b: &mut Builder<'_>, + p: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + r: f64, + color: &str, + weight: f64, +) { + let dx = r * 3f64.sqrt(); + let mut lines: Vec> = Vec::new(); + let mut row = 0; + let mut yy = 0.0; + while yy < h + r { + let off = if row % 2 == 0 { 0.0 } else { dx / 2.0 }; + let mut zig = Vec::new(); + let mut xx = -dx + off; + while xx < w + dx { + zig.push((xx, yy + r * 0.5)); + zig.push((xx + dx / 2.0, yy)); + xx += dx; + } + lines.push(zig); + let mut xx = -dx + off; + while xx < w + dx { + lines.push(vec![(xx, yy + r * 0.5), (xx, yy + r * 1.5)]); + xx += dx; + } + yy += r * 1.5; + row += 1; + } + let mut f = draw::frame("mesh", x, y, w, h); + f.clips_content = true; + let f = b.add(p, f); + b.add( + f, + parts::stroke_path("honeycomb", (0.0, 0.0, w, h), &lines, weight, color, false), + ); +} + +/// A 12-inch cone seen through the mesh. +fn speaker(b: &mut Builder<'_>, p: NodeId, (cx, cy): (f64, f64), r: f64) { + b.add( + p, + draw::circle( + "basket", + cx, + cy, + r, + draw::radial(&[ + (0.0, "#1b1b1d"), + (0.85, "#101012"), + (0.93, "#2a2a2d"), + (1.0, "#0a0a0b"), + ]), + ), + ); + b.add( + p, + draw::stroked( + draw::circle( + "surround", + cx, + cy, + r * 0.86, + draw::radial_at( + 0.45, + 0.4, + 0.8, + &[(0.0, "#2f3033"), (0.7, "#141416"), (1.0, "#0d0d0e")], + ), + ), + "#3a3b3e", + 1.5, + ), + ); + for k in [0.66, 0.5] { + b.add( + p, + draw::stroked( + draw::circle("ridge", cx, cy, r * k, draw::solid("#00000000")), + "#2a2b2e", + 1.0, + ), + ); + } + b.add( + p, + draw::circle( + "dust cap", + cx, + cy, + r * 0.28, + draw::radial_at( + 0.42, + 0.36, + 0.8, + &[(0.0, "#4a4b4f"), (0.7, "#1e1f22"), (1.0, "#0e0e10")], + ), + ), + ); +} + +/// The dynamic mic on a `Handle` over the upper-left cone: laid in from +/// the left, its grille follows POSITION (from the dust cap, 0, out to the +/// rim, 1) and DISTANCE (0 on the cone's centre line, down and away as it +/// grows). +fn mic(b: &mut Builder<'_>, p: NodeId, ns: &str, (cx, cy): (f64, f64), r: f64) { + let (aw, ah) = (r * 0.85, r * 0.45); + let area = b.add(p, draw::frame("Mic travel", cx - aw, cy - 6.0, aw, ah)); + let t = 48.0; + let h = b.add(area, draw::frame("Mic on cone", 0.0, 0.0, t, t)); + let c = t / 2.0; + let (len, thick) = (150.0, 22.0); + let body = draw::rounded( + draw::rect( + "mic body", + c - len, + c - thick / 2.0, + len - 16.0, + thick, + draw::linear( + 180.0, + &[ + (0.0, "#5d5e62"), + (0.3, "#2a2b2e"), + (0.75, "#0e0e10"), + (1.0, "#232427"), + ], + ), + ), + thick / 2.0, + ); + b.add( + h, + draw::with_effects(body, vec![draw::drop_shadow("#000000b0", 8.0, 14.0, 12.0)]), + ); + b.add( + h, + draw::rect( + "mic ring", + c - 44.0, + c - thick / 2.0 - 0.5, + 5.0, + thick + 1.0, + draw::linear( + 180.0, + &[(0.0, "#e0e1e3"), (0.5, "#707275"), (1.0, "#bcbdc0")], + ), + ), + ); + let grille = draw::rounded( + draw::rect( + "mic grille", + c - 18.0, + c - 12.0, + 26.0, + 24.0, + draw::linear( + 180.0, + &[(0.0, "#c4c5c7"), (0.5, "#6c6d70"), (1.0, "#3a3b3d")], + ), + ), + 9.0, + ); + b.add( + h, + draw::with_effects(grille, vec![draw::drop_shadow("#000000a0", 6.0, 10.0, 8.0)]), + ); + let mesh: Vec> = (0..7) + .map(|k| { + let x = c - 15.0 + 3.3 * f64::from(k); + vec![(x, c - 10.0), (x, c + 10.0)] + }) + .collect(); + b.add( + h, + parts::stroke_path("mic mesh", (0.0, 0.0, t, t), &mesh, 0.8, "#2c2d30", false), + ); + let a = |s: &str| format!("{ns}/{s}"); + let kind = ControlKind::Handle { + x: Some(rk::bind(&a("position"))), + y: Some(rk::bind(&a("distance"))), + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 1.0, max: 0.0 }), + y_map: HandleAxis::new(Axis::Linear { min: 1.0, max: 0.0 }), + }; + b.controls.insert(h, Control::new(kind)); +} + +/// The cabinet's left side, seen as it recedes: a narrowing panel of +/// tolex (lit a little, facing the light), the aluminium trim along the +/// front edge, and a recessed carry handle with its strap. +fn side( + b: &mut Builder<'_>, + p: NodeId, + (x0, x1): (f64, f64), + (top, bottom): (f64, f64), + drop: f64, +) { + let far = |y: f64| top + drop + (y - top) * (bottom - top - 2.0 * drop) / (bottom - top); + let poly = [(x0, far(top)), (x1, top), (x1, bottom), (x0, far(bottom))]; + let d = draw::polygon(&poly); + let n = draw::path( + "side", + 0.0, + 0.0, + W, + H, + d.clone(), + draw::linear( + 90.0, + &[(0.0, "#101012"), (0.6, "#1d1e21"), (1.0, "#26272a")], + ), + ); + b.add( + p, + draw::with_effects(n, vec![draw::drop_shadow("#000000a0", -4.0, 10.0, 16.0)]), + ); + // The side's grain: short horizontal flecks, fewer towards the back. + let mut s: u64 = 0x51de; + let mut rnd = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s >> 11) as f64 / (1u64 << 53) as f64 + }; + let mut g = String::new(); + for _ in 0..900 { + let u = rnd(); + let x = x0 + (x1 - x0) * u; + let (ya, yb) = ( + top + (far(top) - top) * (1.0 - u), + bottom + (far(bottom) - bottom) * (1.0 - u), + ); + let y = ya + (yb - ya) * rnd(); + g.push_str(&format!("M {x:.1} {y:.1} l 1.2 0.2 l -0.3 0.7 Z ")); + } + b.add( + p, + draw::with_opacity( + draw::path("side grain", 0.0, 0.0, W, H, g, draw::solid("#ffffff")), + 0.06, + ), + ); + // The recessed handle: a dish, darker inside, with a black strap. + let at = |u: f64, v: f64| { + let x = x0 + (x1 - x0) * u; + let (ya, yb) = ( + top + (far(top) - top) * (1.0 - u), + bottom + (far(bottom) - bottom) * (1.0 - u), + ); + (x, ya + (yb - ya) * v) + }; + let dish = [at(0.2, 0.33), at(0.82, 0.32), at(0.82, 0.62), at(0.2, 0.6)]; + let n = draw::path( + "handle dish", + 0.0, + 0.0, + W, + H, + draw::polygon(&dish), + draw::linear(90.0, &[(0.0, "#050506"), (1.0, "#18191b")]), + ); + b.add(p, draw::stroked(n, "#3a3b3f", 1.2)); + let strap = [ + at(0.42, 0.35), + at(0.62, 0.345), + at(0.62, 0.6), + at(0.42, 0.59), + ]; + let n = draw::path( + "handle strap", + 0.0, + 0.0, + W, + H, + draw::polygon(&strap), + draw::linear( + 90.0, + &[(0.0, "#2a2b2e"), (0.5, "#48494d"), (1.0, "#1a1b1d")], + ), + ); + b.add( + p, + draw::with_effects(n, vec![draw::drop_shadow("#000000c0", 2.0, 4.0, 4.0)]), + ); + for v in [0.35, 0.6] { + let cap = [ + at(0.36, v - 0.012), + at(0.68, v - 0.018), + at(0.68, v + 0.012), + at(0.36, v + 0.018), + ]; + b.add( + p, + draw::path( + "strap cap", + 0.0, + 0.0, + W, + H, + draw::polygon(&cap), + draw::linear(90.0, &[(0.0, "#8a8b8e"), (1.0, "#d8d9db")]), + ), + ); + } + // The aluminium trim where the side meets the front. + b.add( + p, + draw::rect( + "edge trim", + x1 - 5.0, + top, + 9.0, + bottom - top, + draw::linear( + 90.0, + &[ + (0.0, "#4a4b4e"), + (0.35, "#c9cacc"), + (0.6, "#7a7b7e"), + (1.0, "#2a2b2e"), + ], + ), + ), + ); +} + +fn four_by_twelve() -> Face { + let ns = "mrcab"; + let a = |s: &str| format!("{ns}/{s}"); + let scene = canvas("4x12 Cabinet", |b, root| { + parts::fit_root(b, root, W, H); + // Casters: a swivel plate, the fork, a rubber wheel with its hub. + for x in [150.0, 420.0, 560.0, 810.0] { + b.add( + root, + draw::rect( + "caster plate", + x - 26.0, + 696.0, + 52.0, + 8.0, + draw::linear(180.0, &[(0.0, "#9a9b9e"), (1.0, "#3a3b3e")]), + ), + ); + b.add( + root, + draw::rect( + "caster fork", + x - 14.0, + 702.0, + 28.0, + 26.0, + draw::linear( + 90.0, + &[(0.0, "#5a5b5e"), (0.4, "#c9cacc"), (1.0, "#3a3b3e")], + ), + ), + ); + let wheel = draw::rounded( + draw::rect( + "caster wheel", + x - 11.0, + 712.0, + 22.0, + 42.0, + draw::linear( + 90.0, + &[(0.0, "#050506"), (0.45, "#2e2f32"), (1.0, "#0a0a0b")], + ), + ), + 10.0, + ); + b.add(root, draw::with_effects(wheel, light::stand(3.0, 0.9))); + b.add( + root, + draw::circle( + "hub", + x, + 733.0, + 4.0, + draw::radial(&[(0.0, "#d8d9db"), (1.0, "#4a4b4e")]), + ), + ); + } + // The side, receding to the left. + side(b, root, (24.0, 90.0), (22.0, 700.0), 16.0); + // The front: black tolex, the aluminium frame, corner caps. + let (fx, fy, fw, fh) = (90.0, 20.0, 770.0, 680.0); + let under = draw::rounded( + draw::rect("cabinet", fx, fy, fw, fh, draw::solid("#141416")), + 4.0, + ); + b.add(root, draw::with_effects(under, light::stand(14.0, 1.0))); + materials::fill( + b, + root, + (fx, fy, fw, fh), + 4.0, + &Material::Tolex { + grain: Grain::Levant, + color: "#1e1f21".into(), + scale: 1.0, + }, + ); + for n in light::edge("cabinet edge", (fx, fy, fw, fh), 4.0, 5.0, 1.0, true) { + b.add(root, n); + } + // The aluminium angle round the front's edge. + b.add( + root, + draw::stroked( + draw::rounded( + draw::rect( + "front trim", + fx + 1.5, + fy + 1.5, + fw - 3.0, + fh - 3.0, + draw::solid("#00000000"), + ), + 3.0, + ), + "#7d7f83", + 3.0, + ), + ); + let g = (fx + 26.0, fy + 26.0, fw - 52.0, fh - 52.0); + // The trim round the grille: brushed aluminium angle. + b.add( + root, + draw::stroked( + draw::rect( + "grille trim", + g.0 - 6.0, + g.1 - 6.0, + g.2 + 12.0, + g.3 + 12.0, + draw::solid("#00000000"), + ), + "#46484c", + 2.0, + ), + ); + b.add( + root, + draw::with_effects( + draw::rect("baffle", g.0, g.1, g.2, g.3, draw::solid("#0c0c0d")), + vec![draw::inner_shadow("#000000", 0.0, 3.0, 6.0)], + ), + ); + let (sx0, sx1, sy0, sy1) = ( + g.0 + g.2 * 0.25, + g.0 + g.2 * 0.75, + g.1 + g.3 * 0.25, + g.1 + g.3 * 0.75, + ); + for (sx, sy) in [(sx0, sy0), (sx1, sy0), (sx0, sy1), (sx1, sy1)] { + speaker(b, root, (sx, sy), 142.0); + } + honeycomb(b, root, g, 5.0, "#1c1d20", 1.3); + b.add( + root, + draw::rect( + "mesh sheen", + g.0, + g.1, + g.2, + g.3, + draw::linear( + 160.0, + &[(0.0, "#ffffff14"), (0.5, "#ffffff00"), (1.0, "#00000030")], + ), + ), + ); + for (x, y) in [(fx, fy), (fx + fw, fy), (fx, fy + fh), (fx + fw, fy + fh)] { + let c = draw::rounded( + draw::rect( + "corner", + x - 13.0, + y - 13.0, + 26.0, + 26.0, + draw::linear( + 150.0, + &[(0.0, "#c9cacc"), (0.45, "#6a6b6e"), (1.0, "#1a1b1d")], + ), + ), + 5.0, + ); + b.add(root, draw::with_effects(c, light::stand(2.0, 0.9))); + } + mic(b, root, ns, (sx0, sy0), 142.0); + + // The mic strip. + let strip = draw::rounded( + draw::rect( + "mic strip", + 10.0, + 758.0, + 880.0, + 134.0, + draw::linear(180.0, &[(0.0, "#1e1f22"), (1.0, "#141517")]), + ), + 10.0, + ); + b.add( + root, + draw::with_effects( + draw::stroked(strip, "#2c2d31", 1.0), + vec![draw::drop_shadow("#00000099", 0.0, 3.0, 8.0)], + ), + ); + mic_picker(b, root, &a("mic"), (26.0, 800.0, 196.0)); + mic_power(b, root, &a("mic_on"), (248.0, 800.0)); + rk::letter_aligned( + b, + root, + "MIC", + (28.0, 846.0), + Letter::new(9.5, 640.0, DIM).track(0.04), + TextAlign::Left, + ); + let x0 = 330.0; + let pitch = 88.0; + let fmt3 = Some(ValueFormat { + decimals: Some(3), + unit: Some(String::new()), + ..ValueFormat::default() + }); + ui_knob( + b, + root, + "Position", + &a("position"), + (x0, 800.0), + 42.0, + fmt3.clone(), + ); + ui_knob( + b, + root, + "Distance", + &a("distance"), + (x0 + pitch, 800.0), + 42.0, + fmt3, + ); + ui_knob( + b, + root, + "Mic Level", + &a("level"), + (x0 + 2.0 * pitch, 800.0), + 42.0, + Some(ValueFormat::decimals(1).signed()), + ); + ui_knob( + b, + root, + "Pan", + &a("pan"), + (x0 + 3.0 * pitch, 800.0), + 42.0, + Some(ValueFormat::decimals(0).unit("").mark(0.0, "C")), + ); + parts::vrule(b, root, 700.0, 776.0, 874.0, 1.0, "#3a3b3f"); + rk::letter( + b, + root, + "ROOM SEND", + (755.0, 780.0), + Letter::new(9.5, 700.0, INK).track(0.04), + ); + pill(b, root, "Room Send", &a("room"), (822.0, 780.0)); + ui_knob( + b, + root, + "Room Level", + &a("room_level"), + (760.0, 822.0), + 34.0, + Some(ValueFormat::decimals(1).signed()), + ); + outline_button(b, root, "Phase", &a("phase"), (852.0, 848.0, 64.0)); + }); + let params = vec![ + rk::choice(&a("mic"), "Mic", MICS, 1).featured(), + rk::toggle(&a("mic_on"), "Mic On", true), + crate::delay::parts::param( + &a("position"), + "Position", + frame::surface::proto::Range::linear(0.0, 1.0, frame::surface::proto::Unit::None), + 0.5, + ) + .featured(), + crate::delay::parts::param( + &a("distance"), + "Distance", + frame::surface::proto::Range::linear(0.0, 1.0, frame::surface::proto::Unit::None), + 0.0, + ) + .featured(), + rk::db(&a("level"), "Mic Level", -24.0, 12.0, 0.0), + crate::delay::parts::param( + &a("pan"), + "Pan", + frame::surface::proto::Range::linear( + -100.0, + 100.0, + frame::surface::proto::Unit::Percent, + ), + 0.0, + ) + .bipolar(), + rk::toggle(&a("room"), "Room Send", false), + rk::db(&a("room_level"), "Room Level", -24.0, 12.0, 0.0), + rk::toggle(&a("phase"), "Phase Invert", false), + ]; + Face { + file: "09-cab-4x12", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_modern_rhythm/common.rs b/examples/plugin-faces/src/suite_modern_rhythm/common.rs new file mode 100644 index 00000000..6fe8cc61 --- /dev/null +++ b/examples/plugin-faces/src/suite_modern_rhythm/common.rs @@ -0,0 +1,251 @@ +//! What the Modern Rhythm units share: legends cut as bold lettering and +//! set as a control's `Label`, the pedals' printed arrows and power glyph, +//! the bottom band (the purple pin-point LED over the stomp), and the +//! small LCD windows. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::draw; +use frame::surface::proto::{SlotRole, ValueFormat}; +use frame::surface::widgets::Builder; + +use crate::delay::parts::{self, Screen}; +use crate::rigkit::{self as rk, Letter}; + +/// The pedals' status LED. +pub const LED_PURPLE: &str = "#c69bff"; + +/// A legend cut as lettering, centred on panel (`cx`, `cy`), set as the +/// control's `Label` (so the control is named by the word it prints). +pub fn legend( + b: &mut Builder<'_>, + control: NodeId, + s: &str, + (cx, cy): (f64, f64), + l: Letter<'_>, +) -> NodeId { + let (tx, ty) = b + .scene + .node(control) + .map_or((0.0, 0.0), |n| (n.transform.tx, n.transform.ty)); + let id = rk::letter(b, control, s, (cx - tx, cy - ty), l); + if let Some(c) = b.controls.get_mut(&control) { + c.slots.retain(|s| s.role != SlotRole::Label); + c.slots.push(frame::surface::proto::Slot { + role: SlotRole::Label, + node: id.0, + }); + } + id +} + +/// Printed ▶ arrows at both sides (signal in on the right, out on the left +/// is how the boxes print them: both point right). +pub fn arrows(b: &mut Builder<'_>, p: NodeId, (w, h): (f64, f64), y: f64, x_in: f64, color: &str) { + for x in [x_in, w - x_in] { + let d = draw::polygon(&[(x - 2.6, y - 3.2), (x + 3.0, y), (x - 2.6, y + 3.2)]); + rk::shape(b, p, "arrow", (w, h), d, color); + } +} + +/// The printed power glyph: "9VDC" over a centre-negative jack symbol. +pub fn vdc(b: &mut Builder<'_>, p: NodeId, (w, h): (f64, f64), (cx, cy): (f64, f64), color: &str) { + rk::letter(b, p, "9VDC", (cx, cy - 4.5), Letter::new(3.6, 700.0, color)); + let ring = |x: f64| { + let r = 1.9; + let k = 0.5523 * r; + format!( + "M {:.2} {cy:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} C {:.2} {:.2} {:.2} {:.2} {x:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} Z ", + x + r, + x + r, + cy + k, + x + k, + cy + r, + cy + r, + x - k, + cy + r, + x - r, + cy + k, + x - r, + x - r, + cy - k, + x - k, + cy - r, + cy - r, + x + k, + cy - r, + x + r, + cy - k, + x + r + ) + }; + let mut n = draw::path( + "power glyph", + 0.0, + 0.0, + w, + h, + format!("{}{}{}", ring(cx - 5.2), ring(cx), ring(cx + 5.2)), + draw::solid(color), + ); + n.fills = Vec::new(); + n.stroke = frame::document::proto::Stroke { + paints: vec![draw::solid(color)], + weight: 0.6, + ..frame::document::proto::Stroke::default() + }; + b.add(p, n); + b.add(p, draw::circle("pin", cx, cy, 0.6, draw::solid(color))); + rk::line(b, p, &[(cx - 3.3, cy), (cx - 1.9, cy)], 0.6, color); + rk::line(b, p, &[(cx + 1.9, cy), (cx + 3.3, cy)], 0.6, color); +} + +/// The bottom band: the purple pin-point LED over the stomp switch. +pub fn band(b: &mut Builder<'_>, p: NodeId, w: f64, addr: &str) -> NodeId { + stomp( + b, + p, + "Bypass", + addr, + (w / 2.0, 244.0), + 38.0, + (0.0, -39.0), + false, + ) +} + +/// The flat-capped stomp switch (`switch.stomp_flat`), its nut centred on +/// (`cx`, `cy`), its purple LED (`dx`, `dy`) from it. +#[allow(clippy::too_many_arguments)] +pub fn stomp( + b: &mut Builder<'_>, + p: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + d: f64, + (dx, dy): (f64, f64), + momentary: bool, +) -> NodeId { + let mut opts = vec![ + format!("color={LED_PURPLE}"), + format!("led_dx={dx}"), + format!("led_dy={dy}"), + "led_size=5.5".to_string(), + ]; + if momentary { + opts.push("momentary".into()); + } + let place = frame::surface::kit::Place { + options: Some(opts), + ..frame::surface::kit::Place::new(name) + .size(d) + .bind(rk::bind(addr)) + .touch(frame::surface::kit::TOUCH_MIN) + }; + let root = frame::surface::kit::place(b, p, "switch.stomp_flat", &place) + .unwrap_or_else(|e| panic!("{e}")); + // The nut is centred in the box's switch square: find it by the shadow. + let at = b.scene.node(root).and_then(|n| { + n.children.iter().find_map(|c| { + b.scene.node(*c).filter(|k| k.name == "shadow").map(|k| { + ( + k.transform.tx + k.size.width / 2.0, + k.transform.ty + k.size.height / 2.0, + ) + }) + }) + }); + let (ax, ay) = at.map_or((0.0, 0.0), |(x, y)| { + (x - 0.8 * d / 40.0, y - 6.0 * d / 40.0) + }); + parts::move_to(b, root, cx - ax, cy - ay); + root +} + +/// A fine brushed grain over a lid (`w × h` from its top-left), laid over +/// a satin paint: faint hairlines, not stripes. +pub fn brushing(b: &mut Builder<'_>, p: NodeId, (x, y, w, h): (f64, f64, f64, f64), seed: u64) { + let mut s = seed | 1; + let mut rnd = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s >> 11) as f64 / (1u64 << 53) as f64 + }; + for (c, a) in [("#ffffff", 0.06), ("#000000", 0.07)] { + let mut d = String::new(); + let mut xx = rnd() * 1.5; + while xx < w { + let t = 0.25 + rnd() * 0.3; + let (y0, y1) = (rnd() * h * 0.3, h - rnd() * h * 0.3); + d.push_str(&format!( + "M {:.2} {:.2} h {t:.2} V {:.2} h {:.2} Z ", + x + xx, + y + y0, + y + y1, + -t + )); + xx += 0.9 + rnd() * 1.6; + } + b.add( + p, + draw::with_opacity( + draw::path("brushing", 0.0, 0.0, x + w, y + h, d, draw::solid(c)), + a, + ), + ); + } +} + +/// A small LCD: a dark green-grey window in a black bezel, its reading +/// (the bound param, lit, in fourteen segments) centred. +#[allow(clippy::too_many_arguments)] +pub fn lcd( + b: &mut Builder<'_>, + p: NodeId, + name: &str, + addr: &str, + (x, y, w, h): (f64, f64, f64, f64), + size: f64, + format: Option, +) -> NodeId { + let win = parts::screen( + b, + p, + name, + (x, y, w, h), + Screen { + glass: ("#1c231f", "#27302a"), + ink: "#d8e9dc", + mesh: false, + }, + ); + // Fourteen-segment characters over their unlit ghosts, as the LCDs read. + let seg = rk::lcd::seg14(size * 0.82, "#d8e9dc").track(0.06); + let cells = ((w - 6.0) / rk::lettering::set_width("~", &seg).max(1.0)).floor() as usize; + let r = rk::lcd::readout( + b, + win, + name, + rk::bind(addr), + (2.0, h / 2.0, w - 4.0), + seg, + TextAlign::Center, + Some(("#d8e9dc14", cells)), + format.unwrap_or_default(), + ); + capitals(b, r); + r +} + +/// Print a readout's value in capitals, as the LCDs do ("500 MS"). +pub fn capitals(b: &mut Builder<'_>, readout: NodeId) { + let v = b + .controls + .get(&readout) + .and_then(|c| c.slots_of(SlotRole::Value).next()) + .map(NodeId); + if let Some(v) = v { + rk::case(b, v, true); + } +} diff --git a/examples/plugin-faces/src/suite_modern_rhythm/eq.rs b/examples/plugin-faces/src/suite_modern_rhythm/eq.rs new file mode 100644 index 00000000..97439709 --- /dev/null +++ b/examples/plugin-faces/src/suite_modern_rhythm/eq.rs @@ -0,0 +1,592 @@ +//! Modern Rhythm EQ: a nine-band graphic equaliser as a tabletop unit — +//! Tabletop form at the manual render's aspect (1000 × 254, ≈ 3.9 : 1): a +//! spun HPF knob and the POWER bat over it at the left, nine faders with +//! lit caps between printed tick ladders, each band's gain printed over it +//! (−12.0 dB … +12.0 dB) and its frequency under it, the spun LPF knob and +//! a violet status lamp at the right. +//! +//! The unit comes in the manual's four colourways, a choice param +//! (`mreq/finish`) that repaints it: **Bronze** and **Black** (violet-lit +//! caps, the player's band set 75 Hz … 12 kHz), **Ivory** and **Silver** +//! (white caps, the original set 65 Hz … 16 kHz). Each finish is a layer +//! shown while the param picks it; four swatch keys under the case pick one. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::kit::{Place, draw}; +use frame::surface::proto::{ + ButtonMode, Condition, Control, ControlKind, Orientation, SlotRole, ValueFormat, +}; +use frame::surface::widgets::Builder; + +use crate::delay::parts; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Letter}; + +use super::PLUGIN; +use super::common::legend; + +const W: f64 = 1000.0; +const H: f64 = 254.0; +const INK: &str = "#ececed"; +const VIOLET: &str = "#c38bff"; +const NS: &str = "mreq"; + +/// The colourways: name, plate top / body / bottom, scuff strength, and +/// whether the caps are violet-lit (the player's set) or white (original). +const FINISHES: [(&str, [&str; 3], f64, bool); 4] = [ + ("Bronze", ["#9a8c7f", "#7d7166", "#5f554d"], 0.13, true), + ("Ivory", ["#d8d3c6", "#c3bdae", "#a49e90"], 0.16, false), + ("Black", ["#46474a", "#2a2b2e", "#1b1c1f"], 0.12, true), + ("Silver", ["#a4a6a9", "#86888b", "#6a6c6f"], 0.14, false), +]; +/// The player's band set and the original's. +const BANDS_MOD: [&str; 9] = [ + "75 Hz", "150 Hz", "250 Hz", "400 Hz", "800 Hz", "1.5 kHz", "4.5 kHz", "8 kHz", "12 kHz", +]; +const BANDS_ORIG: [&str; 9] = [ + "65 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz", +]; + +pub fn faces() -> Vec { + vec![nine_band()] +} + +fn finish_addr() -> String { + format!("{NS}/finish") +} + +/// A layer of `parent` (at `(x, y, w, h)`) drawn only while the finish is +/// one of `which`: a readout of the finish param, shown by condition. +fn layer( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + (x, y, w, h): (f64, f64, f64, f64), + which: &[f64], +) -> NodeId { + let id = b.add(parent, draw::frame(name, x, y, w, h)); + // Its value: the finish's name, carried but not printed. + let mut t = draw::text( + "value", + "", + 0.0, + 0.0, + w.min(40.0), + 4.0, + "#00000000", + TextAlign::Left, + ); + t.opacity = 0.0; + let value = b.add(id, t); + let c = Control::new(ControlKind::Readout) + .slot(SlotRole::Value, value.0) + .bound(rk::bind(&finish_addr())) + .shown_if(Condition::any_of(finish_addr(), which)); + b.controls.insert(id, c); + id +} + +/// The finishes whose caps are violet, and whose are white. +fn violet_finishes() -> Vec { + FINISHES + .iter() + .enumerate() + .filter(|(_, f)| f.3) + .map(|(i, _)| i as f64) + .collect() +} +fn white_finishes() -> Vec { + FINISHES + .iter() + .enumerate() + .filter(|(_, f)| !f.3) + .map(|(i, _)| i as f64) + .collect() +} + +/// A graphic-EQ fader: a black slot, a cap that rides it (`Thumb`) — lit +/// violet or white by the finish — the band's gain printed live over it +/// (`Value`), both band sets' frequencies under it. +#[allow(clippy::too_many_arguments)] +fn fader( + b: &mut Builder<'_>, + p: NodeId, + i: usize, + addr: &str, + (cx, y0, len): (f64, f64, f64), + value_y: f64, + freq_y: f64, +) -> NodeId { + let w = 44.0; + let root = b.add( + p, + draw::frame(&format!("Band {}", i + 1), cx - w / 2.0, y0, w, len), + ); + let slot = draw::rounded( + draw::rect( + "slot", + w / 2.0 - 6.0, + 0.0, + 12.0, + len, + draw::linear( + 90.0, + &[(0.0, "#000000"), (0.5, "#151517"), (1.0, "#000000")], + ), + ), + 5.0, + ); + b.add( + root, + draw::with_effects( + draw::stroked(slot, "#3a3b3e", 1.0), + vec![draw::inner_shadow("#000000", 0.0, 2.0, 3.0)], + ), + ); + let cap_h = 26.0; + let lane = b.add(root, draw::frame("lane", 0.0, 0.0, w, len)); + let cap = b.add(lane, draw::frame("cap", w / 2.0 - 8.0, 0.0, 16.0, cap_h)); + let violet = layer( + b, + cap, + "violet cap", + (0.0, 0.0, 16.0, cap_h), + &violet_finishes(), + ); + let body = draw::rounded( + draw::rect( + "cap body", + 0.0, + 0.0, + 16.0, + cap_h, + draw::linear( + 180.0, + &[ + (0.0, "#1a1a1c"), + (0.14, "#3a3a3e"), + (0.2, "#8e6cc0"), + (0.5, "#f1e4ff"), + (0.8, "#8e6cc0"), + (0.86, "#3a3a3e"), + (1.0, "#1a1a1c"), + ], + ), + ), + 2.0, + ); + b.add(violet, draw::emissive(body, draw::hex(VIOLET), 0.8, 18.0)); + let white = layer( + b, + cap, + "white cap", + (0.0, 0.0, 16.0, cap_h), + &white_finishes(), + ); + let body = draw::rounded( + draw::rect( + "cap body", + 0.0, + 0.0, + 16.0, + cap_h, + draw::linear( + 180.0, + &[ + (0.0, "#1a1a1c"), + (0.14, "#5a5a5e"), + (0.22, "#d8d8d6"), + (0.5, "#ffffff"), + (0.78, "#c8c8c6"), + (0.86, "#4a4a4e"), + (1.0, "#1a1a1c"), + ], + ), + ), + 2.0, + ); + b.add( + white, + draw::with_effects(body, vec![draw::drop_shadow("#00000099", 0.0, 2.0, 3.0)]), + ); + // The live gain over the fader. + let size = 9.5; + let mut t = parts::text_node( + "", + -30.0, + value_y - y0, + w + 60.0, + &parts::Ink::new(size, INK).weight(700.0).track(0.0).plain(), + TextAlign::Center, + ); + t.name = "value".into(); + let value = b.add(root, t); + let mut c = Control::new(ControlKind::Fader { + orientation: Orientation::Vertical, + }) + .slot(SlotRole::Thumb, cap.0) + .slot(SlotRole::Value, value.0); + c.binding = Some(rk::bind(addr)); + c.format = Some(ValueFormat::decimals(1).signed()); + b.controls.insert(root, c); + // The frequencies: the player's set on the violet finishes, the + // original's on the white (the one in the violet layer names the band). + let l = Letter::new(9.0, 700.0, INK); + let mod_set = layer( + b, + root, + "player's band", + (0.0, 0.0, w, len), + &violet_finishes(), + ); + let id = rk::letter(b, mod_set, BANDS_MOD[i], (w / 2.0, freq_y - y0), l); + if let Some(c) = b.controls.get_mut(&root) { + c.slots.push(frame::surface::proto::Slot { + role: SlotRole::Label, + node: id.0, + }); + } + let orig = layer( + b, + root, + "original band", + (0.0, 0.0, w, len), + &white_finishes(), + ); + rk::letter(b, orig, BANDS_ORIG[i], (w / 2.0, freq_y - y0), l); + root +} + +/// The printed tick ladder between two faders. +fn ladder(b: &mut Builder<'_>, p: NodeId, cx: f64, (y0, y1): (f64, f64)) { + let n = 13; + let lines: Vec> = (0..n) + .map(|i| { + let y = y0 + (y1 - y0) * f64::from(i) / f64::from(n - 1); + let hw = if i % 6 == 0 { 9.0 } else { 6.0 }; + vec![(cx - hw, y), (cx + hw, y)] + }) + .collect(); + b.add( + p, + draw::with_opacity( + parts::stroke_path("ladder", (0.0, 0.0, W, H), &lines, 1.0, INK, false), + 0.45, + ), + ); +} + +/// A spun filter knob with its readout over it ("HPF : 20 HZ"). +#[allow(clippy::too_many_arguments)] +fn filter( + b: &mut Builder<'_>, + p: NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), + label: &str, + glyph: bool, + stop: f64, +) { + // A dark spun cap in a darker rim, a white line for the index. + let look = [ + "ticks=none", + "color=#4a4b50", + "skirt_color=#16171a", + "pointer=#f4f4f2", + "index_in=0.25", + "index_out=0.92", + "fallback_color=#55565b", + ]; + let place = Place::new(name) + .size(62.0) + .variant("spun_metal") + .bind(rk::bind(addr)) + .touch(frame::surface::kit::TOUCH_MIN) + .options(&look); + let k = parts::put(b, p, super::knob_id("knob.3d"), &place, cx, cy); + parts::strip_legend(b, k); + rk::letter_aligned( + b, + p, + &format!("{label} :"), + (cx - 2.0, 22.0), + Letter::new(9.0, 700.0, INK), + TextAlign::Right, + ); + let mut t = parts::text_node( + "", + cx + 3.0, + 22.0, + 70.0, + &parts::Ink::new(9.0, INK).weight(700.0).track(0.02), + TextAlign::Left, + ); + t.name = "value".into(); + let v = b.add(k, { + let (tx, ty) = b + .scene + .node(k) + .map_or((0.0, 0.0), |n| (n.transform.tx, n.transform.ty)); + let mut t = t; + t.transform.tx -= tx; + t.transform.ty -= ty; + t + }); + if let Some(c) = b.controls.get_mut(&k) { + c.slots.push(frame::surface::proto::Slot { + role: SlotRole::Value, + node: v.0, + }); + // At its stop the filter is out: the readout says so. + c.format = Some(ValueFormat::decimals(0).mark(stop, "OFF")); + } + // The slope glyph under the readout: a low cut rises, a high cut falls. + let y = 50.0; + let pts = if glyph { + vec![(cx - 7.0, y + 5.0), (cx - 2.0, y), (cx + 7.0, y)] + } else { + vec![(cx - 7.0, y), (cx + 2.0, y), (cx + 7.0, y + 5.0)] + }; + rk::line(b, p, &pts, 1.6, INK); +} + +/// One finish's case: the plate in its paint, scratched and scuffed. +fn plate(b: &mut Builder<'_>, p: NodeId, i: usize) { + let (name, [top, body, bottom], scuff, _) = FINISHES[i]; + let l = layer( + b, + p, + &format!("{name} case"), + (0.0, 0.0, W, H - 12.0), + &[i as f64], + ); + let plate = draw::rounded( + draw::rect( + "case", + 0.0, + 0.0, + W, + H - 12.0, + draw::linear( + 180.0, + &[ + (0.0, top), + (0.1, body), + (0.9, bottom), + (1.0, &parts::mixed(bottom, "#000000", 0.4)), + ], + ), + ), + 8.0, + ); + b.add( + l, + draw::with_effects( + draw::stroked(plate, "#000000", 1.5), + vec![draw::inner_shadow("#ffffff30", 0.0, 1.5, 1.0)], + ), + ); + // Scratched metal: pale scratches, scuffs and dark wear. + let mut s: u64 = 0x9e11 + i as u64 * 7; + let mut rnd = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s >> 11) as f64 / (1u64 << 53) as f64 + }; + let mut d = String::new(); + for _ in 0..220 { + let (px, py) = (8.0 + rnd() * (W - 16.0), 8.0 + rnd() * (H - 30.0)); + let (lx, ly) = (5.0 + rnd() * 34.0, (rnd() - 0.5) * 18.0); + d.push_str(&format!( + "M {px:.1} {py:.1} l {lx:.1} {ly:.1} l 0.4 0.5 l {:.1} {:.1} Z ", + -lx, -ly + )); + } + b.add( + l, + draw::with_opacity( + draw::path("scratches", 0.0, 0.0, W, H, d, draw::solid("#ffffff")), + scuff, + ), + ); + for (color, a) in [("#ffffff", scuff * 0.55), ("#000000", scuff * 0.8)] { + let mut d = String::new(); + for _ in 0..40 { + let (px, py) = (20.0 + rnd() * (W - 40.0), 15.0 + rnd() * (H - 45.0)); + let r = 6.0 + rnd() * 22.0; + d.push_str(&format!( + "M {px:.1} {py:.1} q {:.1} {:.1} {:.1} {:.1} q {:.1} {:.1} {:.1} {:.1} Z ", + r, + -r * 0.3, + r * 1.6, + r * 0.2, + -r * 0.6, + r * 0.5, + -r * 1.6, + -r * 0.2 + )); + } + b.add( + l, + draw::with_opacity( + draw::path("scuffs", 0.0, 0.0, W, H, d, draw::solid(color)), + a, + ), + ); + } +} + +/// The four finish keys under the case: swatches, the chosen one ringed. +fn finish_keys(b: &mut Builder<'_>, p: NodeId) { + let n = FINISHES.len(); + for (i, (name, [top, body, _], _, _)) in FINISHES.iter().enumerate() { + let cx = W / 2.0 - 1.5 * 16.0 + 16.0 * i as f64; + let key = b.add( + p, + draw::frame(&format!("Finish {name}"), cx - 7.0, H - 13.0, 14.0, 12.0), + ); + b.add( + key, + draw::stroked( + draw::circle( + "swatch", + 7.0, + 6.0, + 4.0, + draw::linear(180.0, &[(0.0, top), (1.0, body)]), + ), + "#00000099", + 0.8, + ), + ); + let ring = b.add( + key, + draw::stroked( + draw::circle("chosen", 7.0, 6.0, 5.6, draw::solid("#00000000")), + INK, + 1.0, + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: n as u32, + }, + }) + .slot(SlotRole::Led, ring.0); + c.binding = Some(rk::bind(&finish_addr())); + b.controls.insert(key, c); + } +} + +/// The nine-band graphic EQ — Tabletop, 1000 × 254. +fn nine_band() -> Face { + let a = |s: &str| format!("{NS}/{s}"); + let scene = canvas("Nine-Band Graphic", |b, root| { + parts::fit_root(b, root, W, H); + // Rubber feet under the case. + for x in [110.0, 890.0] { + b.add( + root, + draw::rounded( + draw::rect( + "foot", + x - 50.0, + H - 16.0, + 100.0, + 16.0, + draw::linear(180.0, &[(0.0, "#1c1c1e"), (1.0, "#050506")]), + ), + 5.0, + ), + ); + } + let shadow = draw::rounded( + draw::rect("case shadow", 0.0, 0.0, W, H - 12.0, draw::solid("#111214")), + 8.0, + ); + b.add( + root, + draw::with_effects(shadow, vec![draw::drop_shadow("#000000c0", 0.0, 6.0, 14.0)]), + ); + for i in 0..FINISHES.len() { + plate(b, root, i); + } + let xs: Vec = (0..9).map(|i| 237.0 + 65.5 * f64::from(i)).collect(); + for i in 0..8 { + ladder(b, root, (xs[i] + xs[i + 1]) / 2.0, (58.0, 200.0)); + } + for (i, x) in xs.iter().enumerate() { + fader( + b, + root, + i, + &a(&format!("b{}", i + 1)), + (*x, 50.0, 158.0), + 22.0, + 222.0, + ); + } + filter(b, root, "HPF", &a("hpf"), (128.0, 108.0), "HPF", true, 20.0); + filter( + b, + root, + "LPF", + &a("lpf"), + (872.0, 108.0), + "LPF", + false, + 20000.0, + ); + let t = rk::mini_toggle( + b, + root, + "Power", + &a("power"), + (128.0, 182.0 - 6.0), + 12.0, + "chrome", + ); + legend( + b, + t, + "POWER", + (128.0, 216.0), + Letter::new(8.0, 760.0, INK).track(0.04), + ); + rk::led(b, root, (872.0, 182.0), 9.0, VIOLET, true); + finish_keys(b, root); + }); + // The manual's demonstration setting: −12, 0, +12 across the bands. + let demo = [-12.0, 0.0, 12.0]; + let mut params: Vec = (1..=9) + .map(|i| { + rk::db( + &a(&format!("b{i}")), + &format!("Band {i} Gain"), + -12.0, + 12.0, + demo[(i - 1) % 3], + ) + }) + .collect(); + params[0] = params[0].clone().featured(); + params.push(rk::hz(&a("hpf"), "High-Pass", 20.0, 1000.0, 20.0).featured()); + params.push(rk::hz(&a("lpf"), "Low-Pass", 2000.0, 20000.0, 20000.0).featured()); + params.push(rk::toggle(&a("power"), "Power", true).featured()); + params.push(rk::choice( + &a("finish"), + "Finish", + &FINISHES.map(|f| f.0), + 2, + )); + Face { + file: "10-eq-tabletop-nine-band", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_modern_rhythm/post.rs b/examples/plugin-faces/src/suite_modern_rhythm/post.rs new file mode 100644 index 00000000..6133c82b --- /dev/null +++ b/examples/plugin-faces/src/suite_modern_rhythm/post.rs @@ -0,0 +1,256 @@ +//! Modern Rhythm post-FX: a twin-switch delay in the BigBox class +//! (404 × 286 — the render's 1.22 : 1 box pressed to the shared pedal +//! height) and a reverb in the Standard class (156 × 286). +//! +//! - **Delay 2**: slate blue brushed lid; MIX and FEEDBACK over an LCD that +//! reads the mode line ("FREE / DAW / TAP · P-P: OFF / ON") and the time +//! and tempo; SYNC and PING-PONG bat toggles; low cut, TIME, MOD, high cut; +//! ENGAGE and TAP TEMPO stomps with purple pin-point LEDs beside them. +//! - **Reverb**: dusty rose; MIX and DECAY over low and high cut (printed as +//! slope glyphs), the shared title, band and stomp. + +use frame::document::proto::{NodeId, TextAlign}; +use frame::surface::proto::ValueFormat; +use frame::surface::widgets::Builder; + +use crate::delay::parts::{self, Screen}; +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Enclosure, Letter}; + +use super::PLUGIN; +use super::common::{self, legend}; +use super::pre::{self, XL, XR, Y1}; + +pub fn faces() -> Vec { + vec![delay_2(), reverb()] +} + +/// A slope glyph: a low cut rising to a flat top, or a flat top falling. +fn slope(b: &mut Builder<'_>, p: NodeId, (cx, cy): (f64, f64), low: bool, color: &str) { + let pts = if low { + vec![ + (cx - 7.0, cy + 4.0), + (cx - 3.0, cy - 1.0), + (cx + 7.0, cy - 1.0), + ] + } else { + vec![ + (cx - 7.0, cy - 1.0), + (cx + 3.0, cy - 1.0), + (cx + 7.0, cy + 4.0), + ] + }; + rk::line(b, p, &pts, 1.4, color); +} + +// ── 11 · Delay 2 ───────────────────────────────────────────────────────── + +/// Delay 2 — Pedal, BigBox (404 × 286). +fn delay_2() -> Face { + let a = |s: &str| format!("mrdl2/{s}"); + const INK: &str = "#f3f6f7"; + let scene = canvas("Delay 2", |b, root| { + let e = Enclosure::new("bigbox", "#5e7f89", "satin") + .base("#1f2022") + .radius(11.0); + let p = rk::pedal_body(b, root, &e); + let (w, h) = e.size(); + common::brushing(b, p, (8.0, 3.0, w - 16.0, 268.0), 0xde12); + let knob = pre::knob(INK).size(40.0); + let lbl = pre::leg(INK); + // Top row: MIX and FEEDBACK, the toggles, the LCD. + for (addr, name, x) in [("mix", "MIX", 54.0), ("feedback", "FEEDBACK", 350.0)] { + let k = pre::place(b, p, &knob, &a(addr), (x, 64.0)); + legend(b, k, name, (x, 28.0), lbl); + } + for (addr, name, x, dots) in [ + ("sync", "SYNC", 124.0, 3), + ("pingpong", "PING-PONG", 280.0, 2), + ] { + let t = pre::toggle(b, p, name, &a(addr), (x, 34.0)); + legend(b, t, name, (x, 9.0), lbl); + for i in 0..dots { + let dx = (f64::from(i) - f64::from(dots - 1) / 2.0) * 13.0; + b.add( + p, + frame::surface::kit::draw::circle( + "dot", + x + dx, + 51.0, + 1.3, + frame::surface::kit::draw::solid(INK), + ), + ); + } + } + let win = parts::screen( + b, + p, + "Display", + (112.0, 57.0, 180.0, 36.0), + Screen { + glass: ("#34493f", "#40584c"), + ink: "#dcebe1", + mesh: false, + }, + ); + rk::letter_aligned( + b, + win, + "FREE / DAW / TAP", + (6.0, 8.0), + Letter::new(6.2, 640.0, "#dcebe1").track(0.02), + TextAlign::Left, + ); + rk::letter_aligned( + b, + win, + "P-P: OFF / ON", + (174.0, 8.0), + Letter::new(6.2, 640.0, "#dcebe1").track(0.02), + TextAlign::Right, + ); + // Fourteen-segment readings, as the LCD shows them. + let seg = rk::lcd::seg14(10.5, "#dcebe1").track(0.06); + let t = rk::lcd::readout( + b, + win, + "Time readout", + rk::bind(&a("time")), + (4.0, 24.0, 86.0), + seg, + TextAlign::Left, + None, + ValueFormat { + decimals: Some(0), + ..ValueFormat::default() + }, + ); + let bpm = rk::lcd::readout( + b, + win, + "Tempo readout", + rk::bind(&a("bpm")), + (86.0, 24.0, 90.0), + seg, + TextAlign::Right, + None, + ValueFormat { + decimals: Some(1), + ..ValueFormat::default() + }, + ); + common::capitals(b, t); + common::capitals(b, bpm); + // Second row: low cut, TIME, MOD, high cut. + let row2 = [ + ("low_cut", "", 56.0), + ("time", "TIME", 154.0), + ("mod", "MOD", 251.0), + ("high_cut", "", 349.0), + ]; + for (addr, name, x) in row2 { + let k = pre::place(b, p, &knob, &a(addr), (x, 142.0)); + if name.is_empty() { + slope(b, p, (x, 107.0), addr == "low_cut", INK); + } else { + legend(b, k, name, (x, 106.0), lbl); + } + } + common::arrows(b, p, (w, h), 186.0, 20.0, INK); + // The two stomps, their LEDs inboard of them. + let on = common::stomp( + b, + p, + "Engage", + &a("on"), + (54.0, 240.0), + 38.0, + (40.0, 2.0), + false, + ); + legend(b, on, "ENGAGE", (54.0, 206.0), lbl); + let tap = common::stomp( + b, + p, + "Tap Tempo", + &a("tap"), + (350.0, 240.0), + 38.0, + (-40.0, 2.0), + true, + ); + legend(b, tap, "TAP TEMPO", (350.0, 206.0), lbl); + rk::letter_fit( + b, + p, + "DELAY-2", + (w / 2.0, 214.0), + 104.0, + Letter::new(18.0, 380.0, INK), + ); + common::vdc(b, p, (w, h), (w / 2.0, 250.0), INK); + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 30.0).featured(), + rk::percent(&a("feedback"), "Feedback", 40.0).featured(), + rk::toggle(&a("sync"), "Sync", false), + rk::toggle(&a("pingpong"), "Ping-Pong", false), + rk::ms(&a("time"), "Time", 20.0, 2000.0, 1500.0).featured(), + crate::delay::parts::param( + &a("bpm"), + "Tempo", + frame::surface::proto::Range::linear(40.0, 300.0, frame::surface::proto::Unit::Bpm), + 120.0, + ), + rk::percent(&a("mod"), "Modulation", 20.0), + rk::hz(&a("low_cut"), "Low Cut", 20.0, 1000.0, 20.0), + rk::hz(&a("high_cut"), "High Cut", 1000.0, 20000.0, 20000.0), + rk::toggle(&a("on"), "Engage", true).featured(), + rk::toggle(&a("tap"), "Tap Tempo", false), + ]; + Face { + file: "11-post-bigbox-delay-two", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 12 · Reverb ────────────────────────────────────────────────────────── + +/// Reverb — Pedal, Standard (156 × 286). +fn reverb() -> Face { + let a = |s: &str| format!("mrverb/{s}"); + const INK: &str = "#fbf2f3"; + let scene = canvas("Reverb", |b, root| { + let p = pre::lid(b, root, "#b3616f", INK, "REVERB", &a("on")); + pre::dial(b, p, INK, &a("mix"), "MIX", (XL, Y1), true); + pre::dial(b, p, INK, &a("decay"), "DECAY", (XR, Y1), true); + for (addr, x, low) in [("low_cut", XL, true), ("high_cut", XR, false)] { + pre::place(b, p, &pre::knob(INK), &a(addr), (x, 104.0)); + slope(b, p, (x, 134.0), low, INK); + } + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 25.0).featured(), + crate::delay::parts::param( + &a("decay"), + "Decay", + frame::surface::proto::Range::log(0.2, 20.0, frame::surface::proto::Unit::Seconds), + 2.5, + ) + .featured(), + rk::hz(&a("low_cut"), "Low Cut", 20.0, 1000.0, 100.0), + rk::hz(&a("high_cut"), "High Cut", 1000.0, 20000.0, 8000.0), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "12-post-standard-reverb", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_modern_rhythm/pre.rs b/examples/plugin-faces/src/suite_modern_rhythm/pre.rs new file mode 100644 index 00000000..6aac1ae7 --- /dev/null +++ b/examples/plugin-faces/src/suite_modern_rhythm/pre.rs @@ -0,0 +1,314 @@ +//! Modern Rhythm pre-FX: four pedals of the Standard 125B class +//! (156 × 286), all one height, brushed anodised lids over a black base, +//! jacks on the sides. +//! +//! What they share, as the suite draws them: tall knurled knobs with grey +//! satin caps and a white dot, each inside a printed ring of fine dots; bold +//! small legends; a mini bat toggle between the knobs; the model name large +//! in a thin geometric sans ("OVERDRIVE- 1"); printed ▶ arrows at the jacks; +//! a purple pin-point LED over a stomp switch in the bottom band, and the +//! 9VDC glyph beside it. + +use crate::face::{Face, canvas}; +use crate::rigkit::{self as rk, Enclosure, KnobStyle, Letter}; + +use super::PLUGIN; +use super::common::{self, legend}; + +pub fn faces() -> Vec { + vec![compressor(), overdrive_1(), delay_1(), overdrive_2()] +} + +/// Knob centres (the two columns) and the rows, from the reference. +pub(super) const XL: f64 = 33.0; +pub(super) const XR: f64 = 123.0; +pub(super) const Y1: f64 = 47.0; + +/// The pedals' knob: a tall grey cap, knurled round its foot, a domed +/// satin top with a white dot (`knob.3d`'s knurled dome, recoloured). Its +/// ring of 21 dots is printed by [`place`]. +pub(super) const fn knob(ink: &'static str) -> KnobStyle<'static> { + KnobStyle::new(super::knob_id("knob.3d"), "knurled_dome", 36.0, ink).opts(KNOB_LOOK) +} + +const KNOB_LOOK: &[&str] = &[ + "ticks=none", + "color=#4d4f53", + "metal=0.35", + "rough=0.34", + "coat=0.3", + "aniso=0.4", + "ribs=30", + "knurl_band=0.55", + "rib_depth=0.065", + "height=0.72", + "dome=0.07", + "edge=0.1", + "cap_r=0.92", + "index=dot", + "pointer=#f4f4f2", + "index_w=0.07", + "index_in=0.76", + "index_out=0.76", + "fallback=knurled", + "fallback_color=#4a4b4f", +]; + +/// A pedal knob `s` with its shaft at `at`, inside the lid's printed ring of +/// 21 fine dots; no legend (the caller cuts it). +pub(super) fn place( + b: &mut frame::surface::widgets::Builder<'_>, + p: frame::document::proto::NodeId, + s: &KnobStyle<'_>, + addr: &str, + (cx, cy): (f64, f64), +) -> frame::document::proto::NodeId { + let r = s.d / 2.0 * 1.34; + let dot = (s.d * 0.022).max(0.55); + let n = 21; + let d: String = (0..n) + .map(|i| { + let a = (f64::from(i) / f64::from(n - 1) - 0.5) * 300.0; + let (x, y) = frame::surface::kit::draw::polar(cx, cy, r, a); + format!("M {:.3} {y:.3} A {dot:.3} {dot:.3} 0 1 1 {:.3} {y:.3} A {dot:.3} {dot:.3} 0 1 1 {:.3} {y:.3} Z ", x - dot, x + dot, x - dot) + }) + .collect(); + let (w, h) = b + .scene + .node(p) + .map_or((cx * 2.0, cy * 2.0), |n| (n.size.width, n.size.height)); + b.add( + p, + frame::surface::kit::draw::path( + "scale dots", + 0.0, + 0.0, + w, + h, + d, + frame::surface::kit::draw::solid(s.ink), + ), + ); + rk::knob( + b, + p, + s, + addr, + "", + (cx, cy), + 0.0, + crate::delay::parts::Ink::new(6.0, s.ink), + ) +} + +/// A legend's lettering: bold, small, as the lids print it. +pub(super) fn leg(color: &str) -> Letter<'_> { + Letter::new(6.2, 720.0, color).track(0.04) +} + +/// The shared lid and printing; `ink` is the silkscreen colour. +pub(super) fn lid<'a>( + b: &mut frame::surface::widgets::Builder<'_>, + root: frame::document::proto::NodeId, + color: &'a str, + ink: &'a str, + title: &str, + bypass: &str, +) -> frame::document::proto::NodeId { + let e = Enclosure::new("standard", color, "satin") + .base("#1f2022") + .radius(9.0); + let p = rk::pedal_body(b, root, &e); + let (w, h) = e.size(); + common::brushing( + b, + p, + (8.0, 3.0, w - 16.0, 268.0), + color.len() as u64 * 7919 + title.len() as u64, + ); + rk::letter_fit( + b, + p, + title, + (w / 2.0, 176.0), + 128.0f64.min(title.chars().count() as f64 * 13.6), + Letter::new(18.0, 380.0, ink).track(0.0), + ); + common::arrows(b, p, (w, h), 210.0, 13.0, ink); + common::vdc(b, p, (w, h), (w - 21.0, 246.0), ink); + common::band(b, p, w, bypass); + p +} + +/// A knob with its legend above (`above`) or below it. +#[allow(clippy::too_many_arguments)] +pub(super) fn dial( + b: &mut frame::surface::widgets::Builder<'_>, + p: frame::document::proto::NodeId, + ink: &'static str, + addr: &str, + name: &str, + at: (f64, f64), + above: bool, +) { + let k = place(b, p, &knob(ink), addr, at); + let ly = if above { at.1 - 34.5 } else { at.1 + 29.0 }; + legend(b, k, name, (at.0, ly), leg(ink)); +} + +/// A mini bat toggle whose nut sits at (`cx`, `cy`). +pub(super) fn toggle( + b: &mut frame::surface::widgets::Builder<'_>, + p: frame::document::proto::NodeId, + name: &str, + addr: &str, + (cx, cy): (f64, f64), +) -> frame::document::proto::NodeId { + // The kit's toggle keeps a caption strip over it: its nut sits 6 px + // under its box's centre. + rk::mini_toggle(b, p, name, addr, (cx, cy - 6.0), 11.0, "chrome") +} + +// ── 01 · Compressor ────────────────────────────────────────────────────── + +/// Compressor — Pedal, Standard (156 × 286). Brushed silver, dark ink: +/// COMP and LEVEL, the ATTACK toggle (SMOOTH / SNAPPY) between them. +fn compressor() -> Face { + let a = |s: &str| format!("mrcomp/{s}"); + const INK: &str = "#1b1c1e"; + let scene = canvas("Compressor", |b, root| { + let p = lid(b, root, "#c3c5c7", INK, "COMPRESSOR", &a("on")); + dial(b, p, INK, &a("comp"), "COMP", (XL, Y1), true); + dial(b, p, INK, &a("level"), "LEVEL", (XR, Y1), true); + let t = toggle(b, p, "Attack", &a("attack"), (78.0, 94.0)); + legend(b, t, "ATTACK", (78.0, 73.0), leg(INK)); + rk::letter(b, p, "SMOOTH", (46.0, 94.0), leg(INK)); + rk::letter(b, p, "SNAPPY", (110.0, 94.0), leg(INK)); + }); + let params = vec![ + rk::percent(&a("comp"), "Comp", 50.0).featured(), + rk::db(&a("level"), "Level", -12.0, 12.0, 0.0).featured(), + rk::toggle(&a("attack"), "Attack Snappy", false), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "01-pre-standard-compressor", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 02 · Overdrive 1 ───────────────────────────────────────────────────── + +/// Overdrive 1 — Pedal, Standard (156 × 286). Brushed teal, white ink: +/// DRIVE and LEVEL, TONE under them, the clipping toggle between (soft +/// knee over it, hard line under it). +fn overdrive_1() -> Face { + let a = |s: &str| format!("mrod1/{s}"); + const INK: &str = "#f4f6f5"; + let scene = canvas("Overdrive 1", |b, root| { + let p = lid(b, root, "#3e8a79", INK, "OVERDRIVE- 1", &a("on")); + dial(b, p, INK, &a("drive"), "DRIVE", (XL, Y1), true); + dial(b, p, INK, &a("level"), "LEVEL", (XR, Y1), true); + dial(b, p, INK, &a("tone"), "TONE", (78.0, 106.0), false); + toggle(b, p, "Clip", &a("clip"), (78.0, 61.5)); + rk::line( + b, + p, + &[(71.0, 48.0), (75.0, 48.0), (79.5, 43.0), (85.0, 43.0)], + 1.0, + INK, + ); + rk::line(b, p, &[(72.0, 83.0), (84.0, 83.0)], 1.2, INK); + }); + let params = vec![ + rk::percent(&a("drive"), "Drive", 50.0).featured(), + rk::db(&a("level"), "Level", -12.0, 12.0, 0.0).featured(), + rk::percent(&a("tone"), "Tone", 50.0).featured(), + rk::toggle(&a("clip"), "Soft Clip", true), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "02-pre-standard-overdrive-one", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 03 · Delay 1 ───────────────────────────────────────────────────────── + +/// Delay 1 — Pedal, Standard (156 × 286). Brushed purple, white ink: an LCD +/// reading the time ("500 ms"), MIX and FBK, TIME and TONE, the NOTE / MS +/// toggle between them. +fn delay_1() -> Face { + let a = |s: &str| format!("mrdl1/{s}"); + const INK: &str = "#f6f3fa"; + let scene = canvas("Delay 1", |b, root| { + let p = lid(b, root, "#7a58a3", INK, "DELAY- 1", &a("on")); + common::lcd( + b, + p, + "Time readout", + &a("time"), + (53.0, 9.0, 50.0, 18.0), + 9.5, + None, + ); + dial(b, p, INK, &a("mix"), "MIX", (XL, Y1), true); + dial(b, p, INK, &a("feedback"), "FBK", (XR, Y1), true); + dial(b, p, INK, &a("time"), "TIME", (XL, 97.0), false); + dial(b, p, INK, &a("tone"), "TONE", (XR, 97.0), false); + let t = toggle(b, p, "Time mode", &a("sync"), (78.0, 72.0)); + legend(b, t, "NOTE", (78.0, 51.5), leg(INK)); + rk::letter(b, p, "MS", (78.0, 93.5), leg(INK)); + }); + let params = vec![ + rk::percent(&a("mix"), "Mix", 30.0).featured(), + rk::percent(&a("feedback"), "Feedback", 35.0), + rk::ms(&a("time"), "Time", 20.0, 2000.0, 500.0).featured(), + rk::percent(&a("tone"), "Tone", 60.0), + rk::toggle(&a("sync"), "Note Sync", false), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "03-pre-standard-delay-one", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} + +// ── 04 · Overdrive 2 ───────────────────────────────────────────────────── + +/// Overdrive 2 — Pedal, Standard (156 × 286). Brushed orange, white ink: +/// GAIN and LEVEL over BASS and TREBLE. +fn overdrive_2() -> Face { + let a = |s: &str| format!("mrod2/{s}"); + const INK: &str = "#fbf4ef"; + let scene = canvas("Overdrive 2", |b, root| { + let p = lid(b, root, "#cf5f36", INK, "OVERDRIVE- 2", &a("on")); + dial(b, p, INK, &a("gain"), "GAIN", (XL, Y1), true); + dial(b, p, INK, &a("level"), "LEVEL", (XR, Y1), true); + dial(b, p, INK, &a("bass"), "BASS", (XL, 106.0), false); + dial(b, p, INK, &a("treble"), "TREBLE", (XR, 106.0), false); + }); + let params = vec![ + rk::percent(&a("gain"), "Gain", 55.0).featured(), + rk::db(&a("level"), "Level", -12.0, 12.0, 0.0).featured(), + rk::db(&a("bass"), "Bass", -12.0, 12.0, 0.0), + rk::db(&a("treble"), "Treble", -12.0, 12.0, 0.0), + rk::toggle(&a("on"), "Bypass", true).featured(), + ]; + Face { + file: "04-pre-standard-overdrive-two", + plugin: PLUGIN, + scene, + params, + streams: Vec::new(), + } +} diff --git a/examples/plugin-faces/src/suite_poly_clean.rs b/examples/plugin-faces/src/suite_poly_clean.rs new file mode 100644 index 00000000..dbdff3a1 --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean.rs @@ -0,0 +1,44 @@ +//! Suite `suite-poly-clean`: a boutique amp-sim suite for clean, +//! polyphonic playing — three satin pre-amp pedals, three sculpted amp +//! heads (a pink hammered-glass lead, a white relief rhythm, a black floral +//! acoustic), a 2×12 cab, a pink nine-band tabletop EQ, and three post-amp +//! pedals (a chorus, a big-box delay, a reverb) and a four-voice harmonizer. +//! +//! One page per unit (the view system isn't built yet); each page's root +//! is the unit at its form factor, recorded in the root's name and the file +//! name. Params are namespaced per unit. + +mod amps; +mod art; +mod cab; +mod delay; +mod eq; +mod parts; +mod pedals; +mod voices; + +use crate::face::Face; + +/// The plugin id every page of the suite belongs to. +pub const PLUGIN: &str = "suite-poly-clean"; + +#[must_use] +pub fn faces() -> Vec { + [ + pedals::boost(), + pedals::compressor(), + pedals::overdrive(), + amps::flame_lead(), + amps::cherub_rhythm(), + amps::bloom_acoustic(), + cab::face(), + eq::face(), + pedals::chorus(), + delay::face(), + pedals::reverb(), + voices::face(), + ] + .into_iter() + .map(crate::reverb::ui::formatted) + .collect() +} diff --git a/examples/plugin-faces/src/suite_poly_clean/amps.rs b/examples/plugin-faces/src/suite_poly_clean/amps.rs new file mode 100644 index 00000000..4fd1caf4 --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/amps.rs @@ -0,0 +1,669 @@ +//! The three heads, in the Head form at the plugin's aspect: 1200 × 500 +//! design px — the box (1200 × 446, its tolex border included), its handle +//! on top and its feet under it. +//! +//! - `04-amp-flame-lead-head` — pink tolex round violet hammered glass lit +//! by its tubes; a pale marble faceplate with seven pink-capped knobs; a +//! flat strap handle between pink brackets, black rubber feet. +//! - `05-amp-cherub-rhythm-head` — all white: a plaster relief (shield, +//! scrolls, a cherub at each end), a channel lever among six white knobs; +//! a chrome bar handle on posts, white flared feet. +//! - `06-amp-bloom-acoustic-head` — all black: a floral relief (roses, +//! hearts, blossoms, wheat), seven black knurled knobs with a blend +//! control; a black strap handle, black feet. +//! +//! Each faceplate reads left to right: the input jack with its cable, the +//! knobs under tracked capitals and inside printed tick rings, the power +//! button and its white pilot bar, the maker's scribbled mark. + +use frame::document::proto::{CornerRadii, NodeId}; +use frame::surface::kit::draw; +use frame::surface::kit::materials::light; +use frame::surface::kit::materials::texture::Relief; +use frame::surface::proto::{ButtonMode, Control, ControlKind, ParamInfo, SlotRole}; +use frame::surface::widgets::Builder; + +use super::parts::{self, Cap, knob, switch}; +use super::{PLUGIN, art}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +pub const W: f64 = 1200.0; +pub const H: f64 = 500.0; +/// The box: under the handle, over the feet. +const BOX_Y: f64 = 24.0; +const BOX_H: f64 = 446.0; +/// The opening inside the tolex border (box coordinates). +const IX: f64 = 33.0; +const IY: f64 = 34.0; +const IW: f64 = W - 2.0 * IX; +/// The art's height above the faceplate, and the faceplate's. +const ART_H: f64 = 259.0; +const PLATE_H: f64 = 116.0; + +/// What's on top. +enum Handle { + /// A flat strap lying between two brackets in the tolex's colour. + Strap { + bracket: &'static str, + strap: &'static str, + }, + /// A chrome bar on two posts. + Bar, +} + +struct Look { + tolex: &'static str, + plate: [&'static str; 2], + ink: &'static str, + cap: Cap, + knob_d: f64, + cable: &'static str, + handle: Handle, + /// The feet's colour, and whether they flare (the white head's legs). + feet: (&'static str, bool), +} + +enum Art { + Glass, + Relief, + Floral, +} + +/// A faceplate control: a knob, or the channel lever. +enum Ctl { + Knob(&'static str, &'static str, f64), + Channel(&'static str, f64), +} + +fn handle(b: &mut Builder<'_>, root: NodeId, h: &Handle) { + let (x0, x1) = (W * 0.3, W * 0.7); + match h { + Handle::Strap { bracket, strap } => { + // The strap: a flat band sagging a hair between its ends. + let d = format!( + "M {:.1} 16 Q {:.1} 6 {:.1} 16 L {:.1} 24 Q {:.1} 14 {:.1} 24 Z", + x0 + 30.0, + W / 2.0, + x1 - 30.0, + x1 - 30.0, + W / 2.0, + x0 + 30.0 + ); + let band = draw::path( + "handle strap", + 0.0, + 0.0, + W, + H, + d, + draw::linear( + 180.0, + &[ + (0.0, &ui::blend(strap, "#ffffff", 0.25)), + (0.5, strap), + (1.0, &ui::blend(strap, "#000000", 0.5)), + ], + ), + ); + b.add(root, draw::with_effects(band, light::stand(3.0, 0.9))); + for x in [x0, x1 - 64.0] { + let br = draw::rounded( + draw::rect( + "handle bracket", + x, + 12.0, + 64.0, + BOX_Y - 6.0, + draw::linear( + 180.0, + &[ + (0.0, &ui::blend(bracket, "#ffffff", 0.35)), + (0.6, bracket), + (1.0, &ui::blend(bracket, "#000000", 0.3)), + ], + ), + ), + 5.0, + ); + b.add(root, draw::with_effects(br, light::stand(2.5, 0.9))); + b.add( + root, + draw::rect( + "bracket seam", + x + 6.0, + 13.5, + 52.0, + 1.0, + draw::solid("#ffffff55"), + ), + ); + } + } + Handle::Bar => { + let chrome = draw::linear( + 180.0, + &[ + (0.0, "#ffffff"), + (0.35, "#c9ccd0"), + (0.55, "#7d8187"), + (1.0, "#e4e6e9"), + ], + ); + for x in [x0 + 20.0, x1 - 20.0] { + let post = draw::rounded( + draw::rect( + "handle post", + x - 7.0, + 2.0, + 14.0, + BOX_Y + 2.0, + draw::linear( + 90.0, + &[(0.0, "#8c9096"), (0.4, "#ffffff"), (1.0, "#6c7076")], + ), + ), + 3.0, + ); + b.add(root, draw::with_effects(post, light::stand(2.0, 0.8))); + b.add( + root, + draw::rounded( + draw::rect( + "post base", + x - 12.0, + BOX_Y - 4.0, + 24.0, + 6.0, + draw::solid("#b8bbc0"), + ), + 2.0, + ), + ); + } + let d = format!( + "M {:.1} 6 Q {:.1} -4 {:.1} 6 L {:.1} 12 Q {:.1} 2 {:.1} 12 Z", + x0 + 16.0, + W / 2.0, + x1 - 16.0, + x1 - 16.0, + W / 2.0, + x0 + 16.0 + ); + b.add( + root, + draw::with_effects( + draw::path("handle bar", 0.0, 0.0, W, H, d, chrome), + light::stand(2.5, 0.8), + ), + ); + } + } +} + +fn feet(b: &mut Builder<'_>, root: NodeId, (color, flare): (&str, bool)) { + let top = BOX_Y + BOX_H - 14.0; + for cx in [W * 0.115, W * 0.885] { + let (wt, wb) = if flare { (104.0, 66.0) } else { (92.0, 80.0) }; + let pts = [ + (cx - wt / 2.0, top), + (cx + wt / 2.0, top), + (cx + wb / 2.0, H - 2.0), + (cx - wb / 2.0, H - 2.0), + ]; + let paint = draw::linear( + 90.0, + &[ + (0.0, &ui::blend(color, "#000000", 0.45)), + (0.35, &ui::blend(color, "#ffffff", 0.12)), + (0.7, color), + (1.0, &ui::blend(color, "#000000", 0.55)), + ], + ); + let foot = draw::path("foot", 0.0, 0.0, W, H, draw::polygon(&pts), paint); + b.add( + root, + draw::with_effects(foot, vec![draw::drop_shadow("#00000080", 0.0, 3.0, 4.0)]), + ); + } +} + +fn head(b: &mut Builder<'_>, root: NodeId, ns: &str, look: &Look, art_kind: &Art, ctls: &[Ctl]) { + ui::fit_root(b, root, W, H); + // Feet and handle behind; then the box. + feet(b, root, look.feet); + handle(b, root, &look.handle); + let r = 26.0; + let shadow = draw::rounded( + draw::rect("box shadow", 0.0, BOX_Y, W, BOX_H, draw::solid(look.tolex)), + r, + ); + b.add(root, draw::with_effects(shadow, light::stand(14.0, 1.0))); + let mut bx = draw::frame("Head", 0.0, BOX_Y, W, BOX_H); + bx.corner_radii = CornerRadii::uniform(r); + let tolex = b.add(root, bx); + parts::covering( + b, + tolex, + (0.0, 0.0, W, BOX_H), + r, + look.tolex, + Relief::Leather, + 0.8, + 1.0, + ); + // The covering rolling round the box's edges: lit top-left, shaded bottom-right. + for n in light::edge("box edge", (0.0, 0.0, W, BOX_H), r, 16.0, 1.1, true) { + b.add(tolex, n); + } + // The opening's lip: the tolex folding in round the art and faceplate. + for n in light::edge( + "opening lip", + (IX - 4.0, IY - 4.0, IW + 8.0, ART_H + PLATE_H + 8.0), + 8.0, + 5.0, + 1.2, + false, + ) { + b.add(tolex, n); + } + + let mut art_frame = draw::frame("Front art", IX, IY, IW, ART_H); + art_frame.clips_content = true; + art_frame.corner_radii = CornerRadii { + top_left: 5.0, + top_right: 5.0, + bottom_left: 0.0, + bottom_right: 0.0, + }; + let art_id = b.add(tolex, art_frame); + match art_kind { + Art::Glass => art::hammered_glass(b, art_id, IW, ART_H), + Art::Relief => art::relief_white(b, art_id, IW, ART_H), + Art::Floral => art::floral_black(b, art_id, IW, ART_H), + } + + let mut plate = draw::frame("Faceplate", IX, IY + ART_H, IW, PLATE_H); + plate.fills = vec![ + draw::linear(180.0, &[(0.0, look.plate[0]), (1.0, look.plate[1])]), + draw::linear( + 100.0, + &[ + (0.0, "#ffffff00"), + (0.35, "#ffffff14"), + (0.5, "#ffffff00"), + (0.8, "#0000000a"), + (1.0, "#ffffff00"), + ], + ), + ]; + plate.corner_radii = CornerRadii { + top_left: 0.0, + top_right: 0.0, + bottom_left: 5.0, + bottom_right: 5.0, + }; + let plate = b.add(tolex, plate); + // The plate's top edge catching the light, its lower edge in shadow. + b.add( + plate, + draw::rect( + "plate lit edge", + 0.0, + 0.0, + IW, + 1.5, + draw::solid("#ffffff66"), + ), + ); + b.add( + plate, + draw::rect( + "plate shade", + 0.0, + 1.5, + IW, + 5.0, + draw::linear(180.0, &[(0.0, "#00000030"), (1.0, "#00000000")]), + ), + ); + b.add( + plate, + draw::rect( + "plate foot", + 0.0, + PLATE_H - 3.0, + IW, + 3.0, + draw::linear(180.0, &[(0.0, "#00000000"), (1.0, "#00000030")]), + ), + ); + + // The input jack and its cable, curling out under the head. + let (jx, jy) = (152.0, 71.0); + let cable = [ + (jx, jy), + (jx - 4.0, jy + 30.0), + (jx - 22.0, PLATE_H + 40.0), + (jx - 70.0, PLATE_H + 64.0), + (jx - 150.0, PLATE_H + 76.0), + ]; + b.add( + plate, + draw::with_effects( + ui::line_node("cable", &cable, 8.0, look.cable), + vec![draw::drop_shadow("#00000080", 1.0, 3.0, 3.0)], + ), + ); + b.add( + plate, + ui::line_node( + "cable sheen", + &cable + .iter() + .map(|(x, y)| (x - 1.5, y - 1.0)) + .collect::>(), + 1.6, + &ui::blend(look.cable, "#ffffff", 0.35), + ), + ); + b.add( + plate, + draw::path( + "jack nut", + 0.0, + 0.0, + IW, + PLATE_H, + draw::regular(jx, jy, 15.0, 6, 0.0), + draw::linear( + 150.0, + &[(0.0, "#f4f4f2"), (0.5, "#9a9b99"), (1.0, "#e0e0de")], + ), + ), + ); + b.add( + plate, + draw::circle( + "plug", + jx, + jy, + 9.5, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#ffffff"), (1.0, "#6e6f71")]), + ), + ); + b.add( + plate, + draw::circle("plug tip", jx, jy, 4.0, draw::solid(look.cable)), + ); + + for c in ctls { + match c { + Ctl::Knob(id, legend, x) => { + parts::print(b, plate, legend, *x, 25.0, 7.5, look.ink, 700.0, 0.3); + knob( + b, + plate, + &look.cap, + &format!("{ns}/{id}"), + legend, + look.knob_d, + *x, + 71.0, + look.ink, + ); + } + Ctl::Channel(id, x) => { + parts::print(b, plate, "CH1", *x, 50.0, 6.0, look.ink, 700.0, 0.12); + switch( + b, + plate, + "switch.mini_lever", + "chrome", + &format!("{ns}/{id}"), + "Channel", + 14.0, + *x, + 71.0, + &["vertical"], + &["bushing"], + ); + parts::print(b, plate, "CH2", *x, 93.0, 6.0, look.ink, 700.0, 0.12); + } + } + } + power(b, plate, &format!("{ns}/power"), 887.0, 71.0, look.cable); + art::flourish(b, plate, 995.0, 71.0, 100.0, look.ink); +} + +/// The power button and its pilot: a small dark push-button, and a white +/// bar that lights while the amp is on. +fn power(b: &mut Builder<'_>, parent: NodeId, addr: &str, cx: f64, cy: f64, dark: &str) { + let root = b.add( + parent, + draw::frame("Power", cx - 22.0, cy - 22.0, 100.0, 44.0), + ); + b.add( + root, + draw::with_effects( + draw::circle("button ring", 22.0, 22.0, 9.5, draw::solid("#00000055")), + vec![draw::drop_shadow("#ffffff30", 0.0, 1.0, 0.5)], + ), + ); + let btn = draw::circle( + "button", + 22.0, + 22.0, + 8.0, + draw::radial_at( + 0.38, + 0.3, + 0.9, + &[(0.0, "#6a6a6e"), (0.6, dark), (1.0, "#000000")], + ), + ); + b.add( + root, + draw::with_effects(btn, vec![draw::drop_shadow("#00000099", 0.0, 1.5, 2.0)]), + ); + b.add( + root, + draw::rounded( + draw::rect( + "pilot slot", + 70.0, + 19.0, + 24.0, + 6.0, + draw::solid("#00000040"), + ), + 3.0, + ), + ); + let bar = draw::rounded( + draw::rect( + "pilot", + 70.0, + 19.5, + 24.0, + 5.0, + draw::linear(180.0, &[(0.0, "#ffffff"), (1.0, "#e4e8ff")]), + ), + 2.5, + ); + let bar = draw::with_effects(bar, vec![draw::drop_shadow("#ffffffcc", 0.0, 0.0, 5.0)]); + let bar = b.add(root, draw::emissive(bar, draw::hex("#f4f6ff"), 0.5, 6.0)); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Led, bar.0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(root, c); +} + +fn dials(ns: &str, ids: &[(&str, &str, f64)]) -> Vec { + let mut v: Vec = ids + .iter() + .map(|(id, name, d)| parts::dial10(ns, id, name, *d)) + .collect(); + if let Some(first) = v.first_mut() { + *first = first.clone().featured(); + } + v +} + +const XS: [f64; 7] = [266.0, 354.0, 442.0, 530.0, 617.0, 706.0, 793.0]; + +pub fn flame_lead() -> Face { + const NS: &str = "lead"; + let look = Look { + tolex: "#e98aa6", + plate: ["#ebe7e9", "#d9d3d6"], + ink: "#3a2a31", + cap: parts::LEAD, + knob_d: 40.0, + cable: "#1e1e20", + handle: Handle::Strap { + bracket: "#e27f9d", + strap: "#2a2426", + }, + feet: ("#38383b", false), + }; + let scene = canvas("Flame Lead (Head, 1200×500)", |b, root| { + let names = [ + ("gain", "GAIN"), + ("bass", "BASS"), + ("middle", "MIDDLE"), + ("treble", "TREBLE"), + ("presence", "PRESENCE"), + ("master", "MASTER"), + ("output", "OUTPUT"), + ]; + let ctls: Vec = names + .iter() + .zip(XS) + .map(|((id, l), x)| Ctl::Knob(id, l, x)) + .collect(); + head(b, root, NS, &look, &Art::Glass, &ctls); + }); + let mut params = dials( + NS, + &[ + ("gain", "Gain", 4.3), + ("bass", "Bass", 5.0), + ("middle", "Middle", 5.0), + ("treble", "Treble", 5.0), + ("presence", "Presence", 5.0), + ("master", "Master", 5.0), + ("output", "Output", 5.0), + ], + ); + params.push(parts::on(NS, "power", "Power", true).featured()); + Face { + file: "04-amp-flame-lead-head", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} + +pub fn cherub_rhythm() -> Face { + const NS: &str = "rhythm"; + let look = Look { + tolex: "#ececea", + plate: ["#eeeeec", "#e0e0de"], + ink: "#454545", + cap: parts::RHYTHM, + knob_d: 42.0, + cable: "#e8e8e6", + handle: Handle::Bar, + feet: ("#e6e6e4", true), + }; + let scene = canvas("Cherub Rhythm (Head, 1200×500)", |b, root| { + let ctls = [ + Ctl::Knob("gain", "GAIN", 267.0), + Ctl::Channel("channel", 338.0), + Ctl::Knob("bass", "BASS", 411.0), + Ctl::Knob("middle", "MIDDLE", 507.0), + Ctl::Knob("treble", "TREBLE", 602.0), + Ctl::Knob("presence", "PRESENCE", 699.0), + Ctl::Knob("output", "OUTPUT", 794.0), + ]; + head(b, root, NS, &look, &Art::Relief, &ctls); + }); + let mut params = dials( + NS, + &[ + ("gain", "Gain", 5.0), + ("bass", "Bass", 5.0), + ("middle", "Middle", 5.0), + ("treble", "Treble", 5.0), + ("presence", "Presence", 5.0), + ("output", "Output", 5.0), + ], + ); + params.push(parts::on(NS, "channel", "Channel 1", true)); + params.push(parts::on(NS, "power", "Power", true).featured()); + Face { + file: "05-amp-cherub-rhythm-head", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} + +pub fn bloom_acoustic() -> Face { + const NS: &str = "acoustic"; + let look = Look { + tolex: "#1f1f21", + plate: ["#232325", "#19191a"], + ink: "#e9e9e9", + cap: parts::ACOUSTIC, + knob_d: 40.0, + cable: "#0c0c0d", + handle: Handle::Strap { + bracket: "#2a2a2c", + strap: "#111112", + }, + feet: ("#303033", false), + }; + let scene = canvas("Bloom Acoustic (Head, 1200×500)", |b, root| { + let names = [ + ("gain", "GAIN"), + ("bass", "BASS"), + ("middle", "MIDDLE"), + ("treble", "TREBLE"), + ("presence", "PRESENCE"), + ("blend", "BLEND"), + ("output", "OUTPUT"), + ]; + let ctls: Vec = names + .iter() + .zip(XS) + .map(|((id, l), x)| Ctl::Knob(id, l, x)) + .collect(); + head(b, root, NS, &look, &Art::Floral, &ctls); + }); + let mut params = dials( + NS, + &[ + ("gain", "Gain", 5.0), + ("bass", "Bass", 5.0), + ("middle", "Middle", 5.0), + ("treble", "Treble", 5.0), + ("presence", "Presence", 5.0), + ("blend", "Blend", 5.0), + ("output", "Output", 5.0), + ], + ); + params.push(parts::on(NS, "power", "Power", true).featured()); + Face { + file: "06-amp-bloom-acoustic-head", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} diff --git a/examples/plugin-faces/src/suite_poly_clean/art.rs b/examples/plugin-faces/src/suite_poly_clean/art.rs new file mode 100644 index 00000000..923cbb19 --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/art.rs @@ -0,0 +1,1131 @@ +//! The heads' sculpted fronts, drawn procedurally (a seeded generator, so +//! every build draws the same art): +//! +//! - [`hammered_glass`] — violet hammered glass lit from below by the +//! tubes (the lead head); +//! - [`relief_white`] — a white plaster relief: moulded frame, a shield +//! with a bead strand, C-scrolls and acanthus sprays, a haloed cherub at +//! one end and a horned, bat-winged one at the other (the rhythm head); +//! - [`floral_black`] — a black floral relief: roses, five-petal flowers, +//! leaves and wheat (the acoustic head); +//! - [`flourish`] — the maker's scribbled mark on a faceplate. + +use std::f64::consts::{PI, TAU}; + +use frame::document::proto::{Node, NodeId}; +use frame::surface::kit::{draw, motion}; +use frame::surface::widgets::Builder; + +use crate::reverb::ui; + +/// A small deterministic generator (an LCG), so the art is the same on +/// every build. +pub struct Rng(u64); + +impl Rng { + #[must_use] + pub const fn new(seed: u64) -> Self { + Self(seed) + } + /// 0..1. + pub fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 11) as f64) / ((1_u64 << 53) as f64) + } + /// `lo..hi`. + pub fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (hi - lo) * self.next() + } +} + +/// A closed shape through `pts` (parent coordinates) as a vector whose box +/// is the points' extent — so its gradients light this shape alone. +fn shape(name: &str, pts: &[(f64, f64)], fill: frame::document::proto::Paint) -> Node { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + let local: Vec<(f64, f64)> = pts.iter().map(|(x, y)| (x - x0, y - y0)).collect(); + draw::path( + name, + x0, + y0, + (x1 - x0).max(1.0), + (y1 - y0).max(1.0), + draw::polygon(&local), + fill, + ) +} + +/// An ellipse's outline, turned `rot` degrees about its centre. +fn oval(cx: f64, cy: f64, rx: f64, ry: f64, rot: f64) -> Vec<(f64, f64)> { + let (s, c) = rot.to_radians().sin_cos(); + (0..28) + .map(|i| { + let a = TAU * f64::from(i) / 28.0; + let (x, y) = (rx * a.cos(), ry * a.sin()); + (cx + x * c - y * s, cy + x * s + y * c) + }) + .collect() +} + +/// A pointed leaf from `(x, y)` along `deg` (0 = up), `len` long, `wid` wide. +fn leaf(x: f64, y: f64, len: f64, wid: f64, deg: f64) -> Vec<(f64, f64)> { + let a = deg.to_radians(); + let (ux, uy) = (a.sin(), -a.cos()); + let (nx, ny) = (-uy, ux); + let mut pts = Vec::new(); + for i in 0..=12 { + let t = f64::from(i) / 12.0; + let half = wid / 2.0 * (PI * t).sin().powf(0.8); + pts.push((x + ux * len * t + nx * half, y + uy * len * t + ny * half)); + } + for i in (0..=12).rev() { + let t = f64::from(i) / 12.0; + let half = wid / 2.0 * (PI * t).sin().powf(0.8); + pts.push((x + ux * len * t - nx * half, y + uy * len * t - ny * half)); + } + pts +} + +/// An acanthus leaf: [`leaf`] with a scalloped, lobed edge. +fn acanthus(x: f64, y: f64, len: f64, wid: f64, deg: f64) -> Vec<(f64, f64)> { + let a = deg.to_radians(); + let (ux, uy) = (a.sin(), -a.cos()); + let (nx, ny) = (-uy, ux); + let half = + |t: f64| wid / 2.0 * (PI * t).sin().powf(0.7) * (0.72 + 0.28 * (t * 7.0 * PI).cos().abs()); + let mut pts = Vec::new(); + for i in 0..=24 { + let t = f64::from(i) / 24.0; + pts.push(( + x + ux * len * t + nx * half(t), + y + uy * len * t + ny * half(t), + )); + } + for i in (0..=24).rev() { + let t = f64::from(i) / 24.0; + pts.push(( + x + ux * len * t - nx * half(t), + y + uy * len * t - ny * half(t), + )); + } + pts +} + +// ── the lead head's hammered glass ─────────────────────────────────────── + +/// Hammered violet glass filling `w × h` of `parent`, lit from behind by +/// the tubes: magenta along the top, deep violet through the middle, four +/// tall pale flames of valve light rising from a lavender glow along the +/// bottom — and over it all the glass itself, a packed field of shallow +/// hammer dimples (each a soft cup: its upper rim in shadow, its lower +/// lip catching the light behind) under a frosted grain. +pub fn hammered_glass(b: &mut Builder<'_>, parent: NodeId, w: f64, h: f64) { + b.add( + parent, + draw::rect( + "glass", + 0.0, + 0.0, + w, + h, + draw::linear( + 180.0, + &[ + (0.0, "#c43ccc"), + (0.12, "#8d2fb0"), + (0.38, "#58288e"), + (0.66, "#4a3597"), + (0.88, "#6f55cc"), + (1.0, "#b89cff"), + ], + ), + ), + ); + // Deeper at the ends, as the light falls off away from the valves. + b.add( + parent, + draw::rect( + "falloff", + 0.0, + 0.0, + w, + h, + draw::linear( + 90.0, + &[ + (0.0, "#1a0a3a66"), + (0.25, "#1a0a3a00"), + (0.75, "#1a0a3a00"), + (1.0, "#1a0a3a66"), + ], + ), + ), + ); + // The valves' light: a flame per valve, rising from the bottom. + let mut rng = Rng::new(7); + for i in 0..4 { + let cx = w * (0.355 + 0.1 * f64::from(i)) + rng.range(-8.0, 8.0); + let fw = rng.range(130.0, 160.0); + let top = h * rng.range(0.3, 0.4); + let col = draw::rect( + "valve light", + cx - fw / 2.0, + top, + fw, + (h - top) * 1.3, + draw::radial_at( + 0.5, + 0.62, + 0.5, + &[ + (0.0, "#f8f2ffee"), + (0.3, "#dcc8ffa0"), + (0.65, "#a78bff30"), + (1.0, "#9d7cff00"), + ], + ), + ); + let col = b.add(parent, col); + // The flame's hot core, higher and narrower. + b.add( + parent, + draw::rect( + "valve core", + cx - fw * 0.28, + top + (h - top) * 0.12, + fw * 0.56, + (h - top) * 0.9, + draw::radial_at( + 0.5, + 0.55, + 0.5, + &[(0.0, "#fbf8ffb0"), (0.5, "#e6d8ff40"), (1.0, "#e6d8ff00")], + ), + ), + ); + motion::add(b, col, motion::flicker(0.05)); + } + b.add( + parent, + draw::rect( + "valve glow", + 0.0, + h * 0.6, + w, + h * 0.4, + draw::linear( + 180.0, + &[(0.0, "#c2a6ff00"), (0.75, "#c9b0ff55"), (1.0, "#e6d9ffc0")], + ), + ), + ); + + // The dimples: overlapping hammer cups on a jittered grid, each shaded + // as a hollow lit from behind — its upper rim in shadow, its lower lip + // bright — so the glass reads as beaten, not tiled. + let (cw, ch) = (44.0, 34.0); + let mut rng = Rng::new(42); + let rows = (h / ch).ceil() as i32 + 1; + let cols = (w / cw).ceil() as i32 + 1; + for j in 0..rows { + for i in 0..cols { + let off = if j % 2 == 0 { 0.0 } else { cw / 2.0 }; + let (cx, cy) = ( + f64::from(i) * cw + off + rng.range(-cw * 0.3, cw * 0.3), + f64::from(j) * ch + rng.range(-ch * 0.3, ch * 0.3), + ); + let (rx, ry) = (cw * rng.range(0.55, 0.8), ch * rng.range(0.55, 0.8)); + let k = rng.range(0.6, 1.0); + let cup = draw::linear( + 180.0 + rng.range(-20.0, 20.0), + &[ + (0.0, &format!("#12053a{:02x}", (90.0 * k) as u8)), + (0.45, "#12053a10"), + (0.75, "#f4e8ff08"), + (1.0, &format!("#f4e8ff{:02x}", (70.0 * k) as u8)), + ], + ); + b.add( + parent, + shape("dimple", &oval(cx, cy, rx, ry, rng.range(-25.0, 25.0)), cup), + ); + } + } + // Frost: a fine speckle of lit and dark grains over the glass. + let (mut lit, mut dark) = (String::new(), String::new()); + for _ in 0..2600 { + let (x, y) = (rng.range(0.0, w), rng.range(0.0, h)); + let r = rng.range(0.5, 1.3); + let d = format!("M {:.1} {:.1} h {r:.1} v {r:.1} h -{r:.1} Z ", x, y); + if rng.next() < 0.5 { + lit.push_str(&d) + } else { + dark.push_str(&d) + } + } + b.add( + parent, + draw::path("frost lit", 0.0, 0.0, w, h, lit, draw::solid("#f6ecff26")), + ); + b.add( + parent, + draw::path("frost dark", 0.0, 0.0, w, h, dark, draw::solid("#12053a2e")), + ); + // The hammered surface's own ripple and the frosting on the glass. + let full = (0.0, 0.0, w, h); + frame::surface::kit::materials::texture::relief( + b, + parent, + full, + 0.0, + frame::surface::kit::materials::texture::Relief::Hammered { cells: 10 }, + 0.35, + 1.2, + ); + frame::surface::kit::materials::texture::relief( + b, + parent, + full, + 0.0, + frame::surface::kit::materials::texture::Relief::Powder { coarse: true }, + 0.4, + 0.8, + ); + // Glass sheen and the shadow the tolex casts into the opening. + b.add( + parent, + draw::rect( + "sheen", + 0.0, + 0.0, + w, + h, + draw::linear( + 110.0, + &[(0.0, "#ffffff00"), (0.4, "#ffffff0c"), (0.6, "#ffffff00")], + ), + ), + ); + b.add( + parent, + draw::rect( + "top shade", + 0.0, + 0.0, + w, + 14.0, + draw::linear(180.0, &[(0.0, "#2a0a3a99"), (1.0, "#2a0a3a00")]), + ), + ); + b.add( + parent, + draw::rect( + "left shade", + 0.0, + 0.0, + 10.0, + h, + draw::linear(90.0, &[(0.0, "#2a0a3a80"), (1.0, "#2a0a3a00")]), + ), + ); +} + +// ── the rhythm head's white relief ─────────────────────────────────────── + +fn plaster(n: Node, depth: f64) -> Node { + draw::with_effects( + n, + vec![ + draw::drop_shadow("#0000007a", depth * 0.6, depth * 1.2, depth * 1.5), + draw::inner_shadow("#00000045", -depth * 0.35, -depth * 0.6, depth * 0.7), + draw::inner_shadow("#ffffffe6", depth * 0.25, depth * 0.35, depth * 0.3), + ], + ) +} + +const WHITE: &str = "#eeeeec"; + +fn white_fill() -> frame::document::proto::Paint { + draw::radial_at( + 0.35, + 0.3, + 0.9, + &[(0.0, "#fbfbfa"), (0.6, "#ececea"), (1.0, "#d2d2d0")], + ) +} + +/// A spiral scroll about (`cx`, `cy`): `turns` turns from `r0` out to `r1`, +/// `dir` ±1 for its hand. +fn scroll(cx: f64, cy: f64, r0: f64, r1: f64, turns: f64, start: f64, dir: f64) -> Vec<(f64, f64)> { + (0..=90) + .map(|i| { + let t = f64::from(i) / 90.0; + let a = start + dir * t * turns * TAU; + let r = r0 + (r1 - r0) * t; + (cx + r * a.cos(), cy + r * a.sin()) + }) + .collect() +} + +/// A spray of leaflets along a quadratic curve `p0 → p2` bowed through `p1`. +#[allow(clippy::too_many_arguments)] +fn spray( + b: &mut Builder<'_>, + parent: NodeId, + (p0, p1, p2): ((f64, f64), (f64, f64), (f64, f64)), + n: usize, + size: f64, + rng: &mut Rng, + depth: f64, + name: &str, +) { + let q = |t: f64| { + let u = 1.0 - t; + ( + u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, + u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1, + ) + }; + let stem: Vec<(f64, f64)> = (0..=24).map(|i| q(f64::from(i) / 24.0)).collect(); + b.add( + parent, + draw::with_effects( + ui::line_node("stem", &stem, size * 0.18, "#dcdcda"), + vec![draw::drop_shadow( + "#00000040", + depth * 0.4, + depth * 0.8, + depth, + )], + ), + ); + let mut d = String::new(); + for k in 0..n { + let t = (k as f64 + 0.5) / n as f64; + let (x, y) = q(t); + let (x2, y2) = q((t + 0.02).min(1.0)); + let along = (x2 - x).atan2(-(y2 - y)).to_degrees(); + let side = if k % 2 == 0 { 1.0 } else { -1.0 }; + let s = size * (1.0 - 0.45 * t) * rng.range(0.8, 1.15); + d.push_str(&draw::polygon(&acanthus( + x, + y, + s, + s * 0.55, + along + side * rng.range(35.0, 60.0), + ))); + d.push(' '); + } + let (w, h) = b + .scene + .node(parent) + .map_or((0.0, 0.0), |p| (p.size.width, p.size.height)); + b.add( + parent, + plaster( + draw::path(name, 0.0, 0.0, w, h, d, draw::solid(WHITE)), + depth, + ), + ); +} + +/// A small five-petal rosette. +fn rosette(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, r: f64, dark: bool) { + for i in 0..5 { + let a = f64::from(i) * 72.0; + let (px, py) = draw::polar(cx, cy, r * 0.55, a); + let n = shape( + "petal", + &oval(px, py, r * 0.5, r * 0.36, a - 90.0), + if dark { black_fill() } else { white_fill() }, + ); + b.add( + parent, + if dark { + lacquer(n, r * 0.15) + } else { + plaster(n, r * 0.15) + }, + ); + } + let n = shape( + "heart", + &oval(cx, cy, r * 0.3, r * 0.3, 0.0), + if dark { black_fill() } else { white_fill() }, + ); + b.add( + parent, + if dark { + lacquer(n, r * 0.1) + } else { + plaster(n, r * 0.1) + }, + ); +} + +/// A cherub: head, curls, a raised arm, a drape, and (`devil`) horns and a +/// bat wing — or a halo and a feathered wing. `dir` ±1 faces it. +fn cherub(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, s: f64, dir: f64, devil: bool) { + // The wing, behind. + if devil { + // A bat wing: a smooth leading edge from the shoulder out to the + // tip, a scalloped trailing edge between its fingers. + let (sx, sy) = (cx - dir * s * 0.12, cy - s * 0.12); + let tips: Vec<(f64, f64)> = (0..5) + .map(|i| { + let a = (-60.0 + 34.0 * f64::from(i)).to_radians(); + let r = s * (1.15 - 0.1 * f64::from(i)); + (sx - dir * r * a.cos(), sy + r * a.sin()) + }) + .collect(); + let mut pts = vec![(sx, sy)]; + // Leading edge: bowed up. + for k in 1..=8 { + let t = f64::from(k) / 8.0; + let (x, y) = ( + sx + (tips[0].0 - sx) * t, + sy + (tips[0].1 - sy) * t - s * 0.18 * (PI * t).sin(), + ); + pts.push((x, y)); + } + // Trailing edge: a scallop between each pair of finger tips. + for w2 in tips.windows(2) { + let (a, c) = (w2[0], w2[1]); + for k in 1..=6 { + let t = f64::from(k) / 6.0; + let (x, y) = (a.0 + (c.0 - a.0) * t, a.1 + (c.1 - a.1) * t); + let pull = s * 0.14 * (PI * t).sin(); + pts.push((x + (sx - x) * pull / s, y + (sy - y) * pull / s)); + } + } + pts.push((cx, cy + s * 0.3)); + b.add(parent, plaster(shape("bat wing", &pts, white_fill()), 3.0)); + let ribs: Vec> = tips.iter().map(|t| vec![(sx, sy), *t]).collect(); + b.add( + parent, + plaster( + ui::strokes("wing bones", &ribs, s * 0.035, draw::solid(WHITE)), + 1.5, + ), + ); + } else { + for i in 0..5 { + let a = -dir * (100.0 + 14.0 * f64::from(i)); + let len = s * (1.0 - 0.1 * f64::from(i)); + b.add( + parent, + plaster( + shape( + "feather", + &leaf(cx - dir * s * 0.15, cy - s * 0.05, len, s * 0.3, a), + white_fill(), + ), + 2.0, + ), + ); + } + } + // Body and drape. + b.add( + parent, + plaster( + shape( + "body", + &oval(cx, cy + s * 0.35, s * 0.36, s * 0.5, dir * 15.0), + white_fill(), + ), + 3.0, + ), + ); + b.add( + parent, + plaster( + shape( + "drape", + &leaf( + cx - dir * s * 0.1, + cy + s * 0.9, + s * 1.1, + s * 0.5, + -dir * 20.0, + ), + white_fill(), + ), + 2.5, + ), + ); + b.add( + parent, + plaster( + shape( + "leg", + &oval( + cx + dir * s * 0.15, + cy + s * 0.85, + s * 0.14, + s * 0.3, + -dir * 25.0, + ), + white_fill(), + ), + 2.5, + ), + ); + // The raised arm, reaching toward the centre. + b.add( + parent, + plaster( + shape( + "arm", + &leaf( + cx + dir * s * 0.2, + cy + s * 0.05, + s * 0.8, + s * 0.2, + dir * 55.0, + ), + white_fill(), + ), + 2.5, + ), + ); + // Head with curls. + let (hx, hy) = (cx + dir * s * 0.05, cy - s * 0.32); + b.add( + parent, + plaster( + shape("head", &oval(hx, hy, s * 0.3, s * 0.3, 0.0), white_fill()), + 3.0, + ), + ); + for i in 0..6 { + let (x, y) = draw::polar(hx, hy, s * 0.26, -80.0 + 32.0 * f64::from(i)); + b.add( + parent, + plaster( + shape("curl", &oval(x, y, s * 0.08, s * 0.08, 0.0), white_fill()), + 1.2, + ), + ); + } + if devil { + for sx in [-1.0, 1.0] { + b.add( + parent, + plaster( + shape( + "horn", + &leaf( + hx + sx * s * 0.14, + hy - s * 0.2, + s * 0.26, + s * 0.1, + sx * 25.0, + ), + white_fill(), + ), + 1.5, + ), + ); + } + } else { + let mut pts = oval( + hx - dir * s * 0.05, + hy - s * 0.4, + s * 0.3, + s * 0.08, + -dir * 10.0, + ); + pts.push(pts[0]); + let ring = ui::strokes("halo", &[pts], s * 0.05, draw::solid(WHITE)); + b.add(parent, plaster(ring, 1.5)); + } +} + +/// A white plaster relief filling `w × h` of `parent`. +pub fn relief_white(b: &mut Builder<'_>, parent: NodeId, w: f64, h: f64) { + b.add( + parent, + draw::rect( + "ground", + 0.0, + 0.0, + w, + h, + draw::linear(180.0, &[(0.0, "#d9d9d7"), (1.0, "#e6e6e4")]), + ), + ); + // The moulded frame: a stepped bevel, then the recessed field. + let m = 14.0; + let outer = draw::rect( + "moulding", + m * 0.4, + m * 0.4, + w - m * 0.8, + h - m * 0.8, + draw::solid("#e9e9e7"), + ); + b.add( + parent, + draw::with_effects( + outer, + vec![ + draw::inner_shadow("#ffffff", 1.5, 1.5, 1.0), + draw::inner_shadow("#0000004d", -1.5, -1.5, 2.0), + ], + ), + ); + let mut field = draw::frame("field", m, m, w - 2.0 * m, h - 2.0 * m); + field.fills = vec![draw::linear(180.0, &[(0.0, "#dededc"), (1.0, "#e8e8e6")])]; + field.effects = vec![draw::inner_shadow("#00000059", 2.0, 4.0, 6.0)]; + field.clips_content = true; + let field = b.add(parent, field); + let (fw, fh) = (w - 2.0 * m, h - 2.0 * m); + let (cx, cy) = (fw / 2.0, fh * 0.52); + let mut rng = Rng::new(11); + + // Sprays of acanthus sweeping out from the shield, mirrored. + for dir in [-1.0, 1.0] { + for (k, (dx0, dy0, dx1, dy1, dx2, dy2)) in [ + (40.0, -30.0, 150.0, -110.0, 290.0, -55.0), + (40.0, 30.0, 160.0, 110.0, 300.0, 45.0), + (60.0, -5.0, 170.0, -40.0, 250.0, 5.0), + (290.0, -55.0, 370.0, -100.0, 460.0, -25.0), + (300.0, 45.0, 390.0, 95.0, 480.0, 20.0), + (240.0, 0.0, 330.0, -10.0, 420.0, 15.0), + ] + .iter() + .enumerate() + { + spray( + b, + field, + ( + (cx + dir * dx0, cy + dy0), + (cx + dir * dx1, cy + dy1), + (cx + dir * dx2, cy + dy2), + ), + 16 + 2 * k, + 30.0, + &mut rng, + 2.4, + "acanthus", + ); + } + // The C-scrolls either side of the shield. + for (x, y, r, turns, start) in [ + (150.0, 30.0, 40.0, 1.4, 0.0), + (290.0, -30.0, 28.0, 1.2, PI), + (95.0, -50.0, 20.0, 1.1, PI / 2.0), + (400.0, 10.0, 24.0, 1.3, 0.0), + ] { + let pts = scroll(cx + dir * x, cy + y, 3.0, r, turns, start, dir); + b.add( + field, + plaster(ui::line_node("scroll", &pts, 10.0, WHITE), 3.0), + ); + } + for (x, y, r) in [ + (220.0, -60.0, 11.0), + (360.0, 55.0, 10.0), + (120.0, 70.0, 9.0), + (250.0, 62.0, 9.5), + (420.0, -55.0, 9.0), + (200.0, 20.0, 8.0), + (330.0, -75.0, 8.0), + (440.0, 70.0, 8.0), + ] { + rosette(b, field, cx + dir * x, cy + y, r, false); + } + } + // The shield, its bead strand and the rosette crowning it. + let (top, bot) = (cy - 72.0, cy + 82.0); + let mut shield = vec![(cx, top + 10.0)]; + for i in 0..=30 { + let u = f64::from(i) / 30.0; + let half = 46.0 * (1.0 - u.powf(2.5)).powf(0.6) + 4.0; + shield.push((cx - half, top + (bot - top) * u)); + } + for i in (0..=30).rev() { + let u = f64::from(i) / 30.0; + let half = 46.0 * (1.0 - u.powf(2.5)).powf(0.6) + 4.0; + shield.push((cx + half, top + (bot - top) * u)); + } + b.add(field, plaster(shape("shield", &shield, white_fill()), 4.0)); + b.add( + field, + plaster( + shape( + "shield boss", + &oval(cx, cy + 5.0, 26.0, 40.0, 0.0), + white_fill(), + ), + 2.0, + ), + ); + for i in 0..13 { + let t = f64::from(i) / 12.0; + let a = PI * (0.1 + 0.8 * t); + let (x, y) = (cx - 34.0 * a.cos(), cy - 30.0 + 38.0 * a.sin()); + b.add( + field, + plaster(shape("bead", &oval(x, y, 3.2, 3.2, 0.0), white_fill()), 1.0), + ); + } + rosette(b, field, cx, cy - 78.0, 12.0, false); + // The cherubs at either end, reaching in. + cherub(b, field, fw * 0.075, fh * 0.46, 84.0, 1.0, false); + cherub(b, field, fw * 0.925, fh * 0.46, 84.0, -1.0, true); +} + +// ── the acoustic head's black floral relief ────────────────────────────── + +fn black_fill() -> frame::document::proto::Paint { + draw::radial_at( + 0.32, + 0.25, + 0.95, + &[(0.0, "#56565a"), (0.5, "#2a2a2c"), (1.0, "#101011")], + ) +} + +fn lacquer(n: Node, depth: f64) -> Node { + draw::with_effects( + n, + vec![ + draw::drop_shadow("#000000e0", depth * 0.5, depth, depth * 1.6), + draw::inner_shadow("#ffffff40", depth * 0.25, depth * 0.35, depth * 0.3), + draw::inner_shadow("#00000080", -depth * 0.3, -depth * 0.5, depth * 0.8), + ], + ) +} + +/// A crescent petal round (`cx`, `cy`): an arc band from `a0` through +/// `span` degrees at radius `r`, `t` thick in its middle, tapering to +/// points at its ends and cupped (its inner edge pulled in). +fn crescent(cx: f64, cy: f64, r: f64, t: f64, a0: f64, span: f64) -> Vec<(f64, f64)> { + let n = 18; + let mut pts = Vec::new(); + for i in 0..=n { + let u = f64::from(i) / f64::from(n); + pts.push(draw::polar( + cx, + cy, + r + t * 0.15 * (PI * u).sin(), + a0 + span * u, + )); + } + for i in (0..=n).rev() { + let u = f64::from(i) / f64::from(n); + pts.push(draw::polar(cx, cy, r - t * (PI * u).sin(), a0 + span * u)); + } + pts +} + +/// A rose: a tight spiral bud wrapped in crescent petals, each ring wider +/// and more open, the outer petals rolled back at their lips. +fn rose(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, r: f64, rng: &mut Rng) { + // The outer, open petals: broad cups behind everything. + let off = rng.range(0.0, 72.0); + for i in 0..5 { + let a = off + 72.0 * f64::from(i); + let (px, py) = draw::polar(cx, cy, r * 0.55, a); + b.add( + parent, + lacquer( + shape( + "outer petal", + &oval(px, py, r * 0.5, r * 0.42, a + 90.0), + black_fill(), + ), + (r * 0.07).max(2.0), + ), + ); + b.add( + parent, + lacquer( + shape( + "petal lip", + &crescent(px, py, r * 0.42, r * 0.1, a - 70.0, 140.0), + black_fill(), + ), + (r * 0.03).max(1.0), + ), + ); + } + // The wrapped petals: crescents spiralling inward. + for ring in 0..5 { + let k = f64::from(ring); + let rr = r * (0.62 - 0.11 * k); + let n = 3; + let off = rng.range(0.0, 120.0) + k * 47.0; + for i in 0..n { + let a = off + 360.0 * f64::from(i) / f64::from(n); + let c = crescent( + cx + rng.range(-1.0, 1.0), + cy + rng.range(-1.0, 1.0), + rr, + rr * 0.34, + a, + 170.0 + k * 10.0, + ); + b.add( + parent, + lacquer(shape("petal", &c, black_fill()), (rr * 0.08).max(1.2)), + ); + } + } + let bud = scroll(cx, cy, 1.0, r * 0.14, 1.4, rng.range(0.0, TAU), 1.0); + b.add( + parent, + lacquer( + ui::line_node("bud", &bud, (r * 0.04).max(1.2), "#3a3a3d"), + 1.2, + ), + ); +} + +/// A heart, as the body has it: a lopsided muscle, its vessels rising from +/// the top, a vein running down its face. +fn heart(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, s: f64, rot: f64) { + for (dx, dy, w, l, a) in [ + (-0.12, -0.55, 0.13, 0.42, -10.0), + (0.08, -0.6, 0.12, 0.5, 8.0), + (0.28, -0.45, 0.1, 0.36, 30.0), + ] { + let (x, y) = (cx + dx * s, cy + dy * s); + b.add( + parent, + lacquer( + shape( + "vessel", + &oval(x, y, w * s, l * s * 0.5, rot + a), + black_fill(), + ), + s * 0.04, + ), + ); + } + b.add( + parent, + lacquer( + shape( + "atrium", + &oval(cx - s * 0.22, cy - s * 0.18, s * 0.32, s * 0.28, rot - 20.0), + black_fill(), + ), + s * 0.05, + ), + ); + b.add( + parent, + lacquer( + shape( + "ventricle", + &leaf(cx + s * 0.05, cy - s * 0.3, s * 1.05, s * 0.85, rot + 170.0), + black_fill(), + ), + s * 0.07, + ), + ); + let vein = [ + (cx + s * 0.02, cy - s * 0.2), + (cx + s * 0.1, cy + s * 0.1), + (cx + s * 0.05, cy + s * 0.4), + (cx + s * 0.14, cy + s * 0.55), + ]; + b.add(parent, ui::line_node("vein", &vein, s * 0.03, "#3c3c40")); +} + +/// A five-petal flower. +fn blossom(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, r: f64, rot: f64) { + for i in 0..5 { + let a = rot + f64::from(i) * 72.0; + b.add( + parent, + lacquer( + shape("petal", &leaf(cx, cy, r, r * 0.7, a), black_fill()), + r * 0.06, + ), + ); + // The petal's crease. + let (ex, ey) = draw::polar(cx, cy, r * 0.7, a); + b.add( + parent, + ui::line_node("crease", &[(cx, cy), (ex, ey)], 0.9, "#0b0b0c"), + ); + } + rosette(b, parent, cx, cy, r * 0.22, true); +} + +/// A stalk of wheat from (`x`, `y`) along `deg`. +fn wheat(b: &mut Builder<'_>, parent: NodeId, x: f64, y: f64, len: f64, deg: f64) { + let a = deg.to_radians(); + let (ux, uy) = (a.sin(), -a.cos()); + b.add( + parent, + lacquer( + ui::line_node( + "straw", + &[(x, y), (x + ux * len, y + uy * len)], + 3.0, + "#2a2a2c", + ), + 1.5, + ), + ); + for k in 0..14 { + let t = 0.3 + 0.7 * f64::from(k) / 14.0; + let (gx, gy) = (x + ux * len * t, y + uy * len * t); + for side in [-1.0, 1.0] { + b.add( + parent, + lacquer( + shape( + "grain", + &oval( + gx + side * ux.abs() * 0.0, + gy, + len * 0.06, + len * 0.028, + deg + 90.0 + side * 35.0, + ), + black_fill(), + ), + 1.2, + ), + ); + } + } +} + +/// A black floral relief filling `w × h` of `parent`: packed so no ground +/// shows — leaves behind, then sweeping wheat, blossoms, hearts, roses. +pub fn floral_black(b: &mut Builder<'_>, parent: NodeId, w: f64, h: f64) { + b.add( + parent, + draw::rect( + "ground", + 0.0, + 0.0, + w, + h, + draw::linear(180.0, &[(0.0, "#1b1b1c"), (1.0, "#121213")]), + ), + ); + let mut rng = Rng::new(23); + // Leaves everywhere behind, in two layers. + for layer in 0..2 { + for _ in 0..170 { + let (x, y) = (rng.range(-30.0, w + 30.0), rng.range(-20.0, h + 20.0)); + let len = rng.range(40.0, 84.0) * if layer == 0 { 1.2 } else { 1.0 }; + let deg = rng.range(0.0, 360.0); + let pts = acanthus(x, y, len, len * 0.5, deg); + b.add( + parent, + lacquer(shape("leaf", &pts, black_fill()), 2.0 + f64::from(layer)), + ); + let a = deg.to_radians(); + let rib = [(x, y), (x + a.sin() * len * 0.85, y - a.cos() * len * 0.85)]; + b.add(parent, ui::line_node("rib", &rib, 0.9, "#0a0a0a")); + } + } + for (x, y, len, deg) in [ + (0.12, 0.62, 180.0, 62.0), + (0.36, 0.98, 210.0, 68.0), + (0.72, 0.28, 170.0, -66.0), + (0.6, 1.05, 190.0, 55.0), + (0.0, 0.95, 150.0, 42.0), + (0.9, 0.95, 160.0, -60.0), + (0.2, 0.05, 150.0, 115.0), + ] { + wheat(b, parent, w * x, h * y, len, deg); + } + for (x, y, r, rot) in [ + (0.14, 0.74, 54.0, 10.0), + (0.47, 0.5, 50.0, 30.0), + (0.63, 0.2, 48.0, -10.0), + (0.955, 0.5, 42.0, 20.0), + (0.3, 0.14, 28.0, 5.0), + (0.87, 0.9, 30.0, 40.0), + (0.73, 0.88, 24.0, 12.0), + (0.53, 0.06, 26.0, 50.0), + (0.25, 0.95, 28.0, 8.0), + (0.42, 0.18, 22.0, 20.0), + ] { + blossom(b, parent, w * x, h * y, r, rot); + } + heart(b, parent, w * 0.245, h * 0.62, 58.0, -12.0); + heart(b, parent, w * 0.585, h * 0.64, 70.0, 10.0); + heart(b, parent, w * 0.735, h * 0.22, 56.0, 18.0); + rose(b, parent, w * 0.065, h * 0.22, 58.0, &mut rng); + rose(b, parent, w * 0.97, h * 0.1, 44.0, &mut rng); + rose(b, parent, w * 0.33, h * 0.42, 86.0, &mut rng); + rose(b, parent, w * 0.84, h * 0.6, 90.0, &mut rng); + rose(b, parent, w * 0.5, h * 0.9, 34.0, &mut rng); + // The shadow the tolex casts into the opening. + b.add( + parent, + draw::rect( + "top shade", + 0.0, + 0.0, + w, + 16.0, + draw::linear(180.0, &[(0.0, "#000000c0"), (1.0, "#00000000")]), + ), + ); + b.add( + parent, + draw::rect( + "left shade", + 0.0, + 0.0, + 12.0, + h, + draw::linear(90.0, &[(0.0, "#000000a0"), (1.0, "#00000000")]), + ), + ); +} + +/// The maker's mark: a quick looped scribble `w` wide from (`x`, `cy`). +pub fn flourish(b: &mut Builder<'_>, parent: NodeId, x: f64, cy: f64, w: f64, ink: &str) { + let mut pts = Vec::new(); + for i in 0..=60 { + let t = f64::from(i) / 60.0; + let a = t * TAU * 1.5; + pts.push(( + x + w * 0.05 + w * 0.3 * t + w * 0.06 * a.cos(), + cy - w * 0.12 * a.sin() + w * 0.05 * t, + )); + } + for i in 0..=30 { + let t = f64::from(i) / 30.0; + pts.push(( + x + w * 0.35 + w * 0.2 * t, + cy + w * 0.03 - w * 0.25 * (PI * t).sin(), + )); + } + for i in 0..=20 { + let t = f64::from(i) / 20.0; + pts.push((x + w * 0.55 + w * 0.45 * t, cy - w * 0.02 * t)); + } + b.add(parent, ui::line_node("mark", &pts, 1.1, ink)); + b.add( + parent, + ui::line_node( + "mark stroke", + &[(x + w * 0.25, cy + w * 0.12), (x + w * 0.55, cy - w * 0.22)], + 1.0, + ink, + ), + ); +} diff --git a/examples/plugin-faces/src/suite_poly_clean/cab.rs b/examples/plugin-faces/src/suite_poly_clean/cab.rs new file mode 100644 index 00000000..8939e556 --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/cab.rs @@ -0,0 +1,995 @@ +//! `07-cab-two-twelve` — the cab page as the plugin draws it (1500 × 860): +//! the 2×12 in the middle, a mic on each speaker, and a column of the +//! plugin's own flat controls either side — each mic's model dropdown and +//! on switch, its position, distance, level and pan on flat arc knobs, its +//! phase button, and the room send under the right-hand mic. +//! +//! The cab comes in the three finishes the heads wear (a `cab/finish` +//! choice repaints it): white, pink and clear acrylic. Drag a mic across +//! its speaker (position: from the dust cap out to the edge) and down away +//! from it (distance). + +use std::fmt::Write as _; + +use frame::document::proto::{CornerRadii, NodeId, TextAlign}; +use frame::surface::kit::draw; +use frame::surface::kit::materials::texture::Relief; +use frame::surface::proto::{ + Axis, Binding, ButtonMode, Condition, Control, ControlKind, HandleAxis, Range, SlotRole, Unit, + ValueFormat, +}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::parts::{self, Hand, knob}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +const W: f64 = 1500.0; +const H: f64 = 860.0; +/// The cab's box. +const CX: f64 = 290.0; +const CY: f64 = 30.0; +const CW: f64 = 920.0; +const CH: f64 = 700.0; +const MICS: [&str; 6] = [ + "Dynamic A", + "Dynamic B", + "Condenser A", + "Condenser B", + "Ribbon A", + "Ribbon B", +]; +const FINISH: &str = "cab/finish"; +const TEXT: &str = "#ececee"; +const DIM: &str = "#8c8c92"; + +/// A finish: the covering, the cloth and its weave, the speakers' ink. +struct Finish { + name: &'static str, + tolex: &'static str, + cloth: [&'static str; 2], + thread: &'static str, + ink: &'static str, +} + +const FINISHES: [Finish; 3] = [ + Finish { + name: "white", + tolex: "#ebebe9", + cloth: ["#d9d9d7", "#c6c6c4"], + thread: "#ffffff1c", + ink: "#0000001f", + }, + Finish { + name: "pink", + tolex: "#e98aa6", + cloth: ["#8e7c91", "#76647a"], + thread: "#ffffff12", + ink: "#00000024", + }, + Finish { + name: "clear", + tolex: "#3a4046", + cloth: ["#2a2d31", "#1d1f22"], + thread: "#ffffff0c", + ink: "#ffffff14", + }, +]; + +#[allow(clippy::too_many_arguments)] +fn text( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + cx: f64, + cy: f64, + size: f64, + color: &str, + weight: f64, +) -> NodeId { + parts::print(b, parent, s, cx, cy, size, color, weight, 0.02) +} + +/// A speaker behind the cloth: the basket ring, the cone's rolls, the cap. +fn speaker(b: &mut Builder<'_>, parent: NodeId, cx: f64, cy: f64, r: f64, ink: &str) { + b.add( + parent, + draw::circle( + "cone", + cx, + cy, + r, + draw::radial_at( + 0.5, + 0.5, + 0.5, + &[ + (0.0, "#00000008"), + (0.7, "#0000000f"), + (0.92, "#0000001c"), + (1.0, "#ffffff0d"), + ], + ), + ), + ); + for f in [1.0, 0.95, 0.72, 0.52, 0.34] { + b.add( + parent, + draw::stroked( + draw::circle("roll", cx, cy, r * f, draw::solid("#00000000")), + ink, + 1.6, + ), + ); + b.add( + parent, + draw::stroked( + draw::circle( + "roll light", + cx + 1.0, + cy + 1.5, + r * f, + draw::solid("#00000000"), + ), + "#ffffff14", + 1.0, + ), + ); + } + b.add( + parent, + draw::circle( + "dust cap", + cx, + cy, + r * 0.2, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#ffffff22"), (1.0, "#0000001c")]), + ), + ); +} + +/// One finish of the cab, filling `parent` (cab coordinates): shown while +/// `cab/finish` is `i` — a readout of the finish itself, its value slot +/// left hidden. +fn cab_body( + b: &mut Builder<'_>, + parent: NodeId, + i: usize, + f: &Finish, + speakers: &[(f64, f64)], + r: f64, +) { + let root = b.add( + parent, + draw::frame(&format!("Cab {}", f.name), 0.0, 0.0, CW, CH), + ); + let mut hidden = ui::text( + "finish value", + "", + 0.0, + 6.0, + 1.0, + 1.0, + TEXT, + TextAlign::Left, + 400.0, + false, + ); + hidden.opacity = 0.0; + let hv = b.add(root, hidden); + let mut c = Control::new(ControlKind::Readout).slot(SlotRole::Value, hv.0); + c.binding = Some(ui::bind(FINISH)); + c.show_if = Some(Condition::any_of(FINISH, &[i as f64])); + b.controls.insert(root, c); + + let rr = 22.0; + let shadow = draw::rounded( + draw::rect("cab shadow", 0.0, 0.0, CW, CH, draw::solid(f.tolex)), + rr, + ); + b.add( + root, + draw::with_effects( + shadow, + vec![draw::drop_shadow("#000000b0", 0.0, 14.0, 26.0)], + ), + ); + parts::covering( + b, + root, + (0.0, 0.0, CW, CH), + rr, + f.tolex, + Relief::Leather, + 0.8, + 1.0, + ); + if f.name == "clear" { + // Acrylic: a glassy sheen and bright cut edges. + b.add( + root, + draw::rounded( + draw::rect( + "acrylic sheen", + 0.0, + 0.0, + CW, + CH, + draw::linear( + 120.0, + &[ + (0.0, "#ffffff30"), + (0.35, "#ffffff08"), + (0.5, "#ffffff00"), + (0.8, "#ffffff10"), + (1.0, "#ffffff00"), + ], + ), + ), + rr, + ), + ); + b.add( + root, + draw::stroked( + draw::rounded( + draw::rect( + "acrylic edge", + 1.0, + 1.0, + CW - 2.0, + CH - 2.0, + draw::solid("#00000000"), + ), + rr, + ), + "#dfe8f0aa", + 2.0, + ), + ); + } + // The box's rolled edge: lit along the top, shaded along the bottom. + b.add( + root, + draw::rounded( + draw::rect( + "edge light", + 0.0, + 0.0, + CW, + 22.0, + draw::linear(180.0, &[(0.0, "#ffffff40"), (1.0, "#ffffff00")]), + ), + rr, + ), + ); + b.add( + root, + draw::rect( + "edge shade", + 0.0, + CH - 26.0, + CW, + 26.0, + draw::linear(180.0, &[(0.0, "#00000000"), (1.0, "#00000038")]), + ), + ); + // The baffle: the cloth, framed by the covering folding in. + let m = 38.0; + let (gw, gh) = (CW - 2.0 * m, CH - 2.0 * m); + let mut grille = draw::frame("Grille", m, m, gw, gh); + grille.fills = vec![draw::linear(180.0, &[(0.0, f.cloth[0]), (1.0, f.cloth[1])])]; + grille.clips_content = true; + grille.corner_radii = CornerRadii::uniform(6.0); + let grille = b.add(root, grille); + let mut weave = String::new(); + let mut x = 0.0; + while x < gw { + let _ = write!(weave, "M {x:.1} 0 L {x:.1} {gh:.1} "); + x += 3.0; + } + let mut y = 0.0; + while y < gh { + let _ = write!(weave, "M 0 {y:.1} L {gw:.1} {y:.1} "); + y += 3.0; + } + b.add( + grille, + draw::stroked( + draw::path("weave", 0.0, 0.0, gw, gh, weave, draw::solid("#00000000")), + f.thread, + 0.8, + ), + ); + for (sx, sy) in speakers { + speaker(b, grille, sx - m, sy - m, r, f.ink); + } + b.add( + grille, + draw::rect( + "cloth sheen", + 0.0, + 0.0, + gw, + gh, + draw::linear( + 120.0, + &[(0.0, "#ffffff14"), (0.5, "#ffffff00"), (1.0, "#0000001a")], + ), + ), + ); + b.add( + grille, + draw::rect( + "recess top", + 0.0, + 0.0, + gw, + 12.0, + draw::linear(180.0, &[(0.0, "#00000050"), (1.0, "#00000000")]), + ), + ); + b.add( + grille, + draw::rect( + "recess left", + 0.0, + 0.0, + 10.0, + gh, + draw::linear(90.0, &[(0.0, "#00000040"), (1.0, "#00000000")]), + ), + ); + // Corner screws on the baffle's frame. + for (sx, sy) in [ + (m * 0.5, m * 0.5), + (CW - m * 0.5, m * 0.5), + (m * 0.5, CH - m * 0.5), + (CW - m * 0.5, CH - m * 0.5), + ] { + b.add( + root, + draw::circle( + "screw", + sx, + sy, + 3.2, + draw::radial_at( + 0.35, + 0.3, + 0.9, + &[(0.0, "#fafafa"), (0.6, "#a4a4a8"), (1.0, "#56565a")], + ), + ), + ); + } +} + +/// A dynamic mic seen from the front at an angle, its grille head at the +/// handle's centre and its body running back along `lean` degrees (0 = up). +/// A `Handle`: across is its position on the cone, down its distance. +fn mic(b: &mut Builder<'_>, lane: NodeId, ns: &str, lean: f64) { + let touch = 56.0; + let handle = b.add( + lane, + draw::frame(&format!("{ns} placement"), 0.0, 0.0, touch, touch), + ); + let (cx, cy) = (touch / 2.0, touch / 2.0); + let a = lean.to_radians(); + let (ux, uy) = (a.sin(), -a.cos()); + let (nx, ny) = (-uy, ux); + let body = |from: f64, to: f64, w0: f64, w1: f64| { + let (p0, p1) = ( + (cx + ux * from, cy + uy * from), + (cx + ux * to, cy + uy * to), + ); + [ + (p0.0 + nx * w0 / 2.0, p0.1 + ny * w0 / 2.0), + (p1.0 + nx * w1 / 2.0, p1.1 + ny * w1 / 2.0), + (p1.0 - nx * w1 / 2.0, p1.1 - ny * w1 / 2.0), + (p0.0 - nx * w0 / 2.0, p0.1 - ny * w0 / 2.0), + ] + }; + let shadow: Vec<(f64, f64)> = body(0.0, 84.0, 28.0, 20.0) + .iter() + .map(|(x, y)| (x + 10.0, y + 16.0)) + .collect(); + b.add( + handle, + draw::with_effects( + draw::path( + "mic shadow", + 0.0, + 0.0, + touch, + touch, + draw::polygon(&shadow), + draw::solid("#00000040"), + ), + vec![draw::drop_shadow("#00000030", 0.0, 0.0, 8.0)], + ), + ); + let side = |dark: &str, lit: &str| { + draw::linear( + lean + 90.0, + &[(0.0, dark), (0.35, lit), (0.55, dark), (1.0, "#050506")], + ) + }; + // The shaft, the neck ring, then the grille head. + b.add( + handle, + draw::path( + "mic body", + 0.0, + 0.0, + touch, + touch, + draw::polygon(&body(14.0, 84.0, 22.0, 18.0)), + side("#0d0d0e", "#4a4a4e"), + ), + ); + b.add( + handle, + draw::path( + "mic neck", + 0.0, + 0.0, + touch, + touch, + draw::polygon(&body(12.0, 20.0, 26.0, 23.0)), + side("#1a1a1c", "#6a6a6e"), + ), + ); + b.add( + handle, + draw::path( + "mic head", + 0.0, + 0.0, + touch, + touch, + draw::polygon(&body(-7.0, 13.0, 22.0, 28.0)), + side("#141416", "#58585c"), + ), + ); + let mut mesh = String::new(); + for i in 0..6 { + let t = -2.0 + f64::from(i) * 2.6; + let half = 11.0 + t * 0.25; + let (x, y) = (cx + ux * t, cy + uy * t); + let _ = write!( + mesh, + "M {:.2} {:.2} L {:.2} {:.2} ", + x + nx * half, + y + ny * half, + x - nx * half, + y - ny * half + ); + } + b.add( + handle, + draw::stroked( + draw::path( + "mesh", + 0.0, + 0.0, + touch, + touch, + mesh, + draw::solid("#00000000"), + ), + "#7a7a7e", + 0.7, + ), + ); + b.add( + handle, + draw::circle( + "capsule", + cx - ux * 5.0, + cy - uy * 5.0, + 10.5, + draw::radial_at(0.4, 0.35, 0.8, &[(0.0, "#58585c"), (1.0, "#0a0a0b")]), + ), + ); + let kind = ControlKind::Handle { + x: Some(Binding::param(format!("{ns}/position"))), + y: Some(Binding::param(format!("{ns}/distance"))), + wheel: None, + x_map: HandleAxis::new(Axis::Linear { min: 0.0, max: 1.0 }), + y_map: HandleAxis::new(Axis::Linear { min: 1.0, max: 0.0 }), + }; + b.controls.insert(handle, Control::new(kind)); +} + +/// A flat arc knob with its value over its caption. +#[allow(clippy::too_many_arguments)] +fn arc_knob( + b: &mut Builder<'_>, + parent: NodeId, + addr: &str, + caption: &str, + cx: f64, + cy: f64, + format: Option, +) { + knob(b, parent, &parts::SCREEN, addr, caption, 64.0, cx, cy, TEXT); + let r = b.add( + parent, + draw::frame( + &format!("{caption} value"), + cx - 50.0, + cy + 40.0, + 100.0, + 18.0, + ), + ); + let t = b.add( + r, + draw::lettered( + ui::text( + "value", + "", + 0.0, + 9.0, + 100.0, + 15.0, + TEXT, + TextAlign::Center, + 500.0, + false, + ), + &Hand::Ui.lettering(500.0), + ), + ); + let mut c = Control::new(ControlKind::Readout).slot(SlotRole::Value, t.0); + c.binding = Some(ui::bind(addr)); + c.format = format; + b.controls.insert(r, c); + text(b, parent, caption, cx, cy + 70.0, 13.0, TEXT, 600.0); +} + +/// A mic's model dropdown and its on switch. +fn mic_select(b: &mut Builder<'_>, parent: NodeId, ns: &str, x: f64, y: f64) { + let (w, h) = (176.0, 44.0); + let root = b.add(parent, draw::frame(&format!("{ns} model"), x, y, w, h)); + b.add( + root, + draw::rounded( + draw::rect("box", 0.0, 0.0, w, h, draw::solid("#2b2b2e")), + 5.0, + ), + ); + let t = b.add( + root, + draw::lettered( + ui::text( + "value", + "", + 16.0, + h / 2.0, + w - 40.0, + 16.0, + TEXT, + TextAlign::Left, + 400.0, + false, + ), + &Hand::Ui.lettering(400.0), + ), + ); + ui::line( + b, + root, + "chevron", + &[ + (w - 26.0, h / 2.0 - 3.0), + (w - 20.0, h / 2.0 + 3.0), + (w - 14.0, h / 2.0 - 3.0), + ], + 1.4, + TEXT, + ); + let mut c = Control::new(ControlKind::Selector).slot(SlotRole::Value, t.0); + c.binding = Some(ui::bind(&format!("{ns}/model"))); + b.controls.insert(root, c); + // The on switch: a power glyph, lit while the mic is on. + let p = b.add( + parent, + draw::frame(&format!("{ns} on"), x + w + 2.0, y, 62.0, h), + ); + b.add( + p, + draw::rounded( + draw::rect("box", 0.0, 0.0, 62.0, h, draw::solid("#2b2b2e")), + 5.0, + ), + ); + let glyph = |b: &mut Builder<'_>, node: NodeId, color: &str| { + let d = "M 24.5 15 A 10 10 0 1 0 37.5 15"; + b.add( + node, + draw::stroked( + draw::path( + "power ring", + 0.0, + 0.0, + 62.0, + h, + d.into(), + draw::solid("#00000000"), + ), + color, + 2.0, + ), + ); + ui::line( + b, + node, + "power bar", + &[(31.0, 9.0), (31.0, 21.0)], + 2.0, + color, + ); + }; + let off = b.add(p, draw::frame("off", 0.0, 0.0, 62.0, h)); + glyph(b, off, DIM); + let on = b.add(p, draw::frame("on", 0.0, 0.0, 62.0, h)); + glyph(b, on, TEXT); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Off, off.0) + .slot(SlotRole::On, on.0); + c.binding = Some(ui::bind(&format!("{ns}/on"))); + b.controls.insert(p, c); +} + +/// An outlined pill toggle (PHASE Ø). +fn phase(b: &mut Builder<'_>, parent: NodeId, ns: &str, cx: f64, cy: f64) { + let (w, h) = (150.0, 46.0); + let root = b.add( + parent, + draw::frame(&format!("{ns} phase"), cx - w / 2.0, cy - h / 2.0, w, h), + ); + let off = b.add(root, draw::frame("off", 0.0, 0.0, w, h)); + b.add( + off, + draw::stroked( + draw::rounded( + draw::rect("pill", 1.0, 1.0, w - 2.0, h - 2.0, draw::solid("#00000000")), + 4.0, + ), + "#7a7a80", + 1.4, + ), + ); + let on = b.add(root, draw::frame("on", 0.0, 0.0, w, h)); + b.add( + on, + draw::rounded( + draw::rect("pill", 0.0, 0.0, w, h, draw::solid("#e8e8ea")), + 4.0, + ), + ); + b.add( + root, + draw::lettered( + ui::text( + "label", + "PHASE Ø", + 0.0, + h / 2.0, + w, + 17.0, + "#b8b8bd", + TextAlign::Center, + 500.0, + false, + ), + &Hand::Ui.lettering(500.0), + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Off, off.0) + .slot(SlotRole::On, on.0); + c.binding = Some(ui::bind(&format!("{ns}/phase"))); + b.controls.insert(root, c); +} + +/// A slide toggle (the room send's switch). +fn slide_toggle(b: &mut Builder<'_>, parent: NodeId, addr: &str, name: &str, cx: f64, cy: f64) { + let (w, h) = (44.0, 44.0); + let root = b.add(parent, draw::frame(name, cx - w / 2.0, cy - h / 2.0, w, h)); + let (px, py, pw, ph) = (6.0, h / 2.0 - 9.0, 32.0, 18.0); + let off = b.add(root, draw::frame("off", 0.0, 0.0, w, h)); + b.add( + off, + draw::stroked( + draw::rounded( + draw::rect("pill", px, py, pw, ph, draw::solid("#00000000")), + 9.0, + ), + TEXT, + 1.6, + ), + ); + b.add( + off, + draw::circle("dot", px + 9.0, h / 2.0, 5.0, draw::solid(TEXT)), + ); + let on = b.add(root, draw::frame("on", 0.0, 0.0, w, h)); + b.add( + on, + draw::rounded(draw::rect("pill", px, py, pw, ph, draw::solid(TEXT)), 9.0), + ); + b.add( + on, + draw::circle("dot", px + pw - 9.0, h / 2.0, 5.5, draw::solid("#1b1b1d")), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Off, off.0) + .slot(SlotRole::On, on.0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(root, c); +} + +/// One mic's column of controls, `x0` its left edge. +fn mic_column(b: &mut Builder<'_>, root: NodeId, ns: &str, x0: f64) { + mic_select(b, root, ns, x0, 60.0); + arc_knob( + b, + root, + &format!("{ns}/position"), + "POSITION", + x0 + 60.0, + 190.0, + Some(ValueFormat::decimals(3)), + ); + arc_knob( + b, + root, + &format!("{ns}/distance"), + "DISTANCE", + x0 + 180.0, + 190.0, + Some(ValueFormat::decimals(3)), + ); + arc_knob( + b, + root, + &format!("{ns}/level"), + "MIC LEVEL", + x0 + 60.0, + 360.0, + Some(ValueFormat::default().signed()), + ); + arc_knob( + b, + root, + &format!("{ns}/pan"), + "PAN", + x0 + 180.0, + 360.0, + Some(ValueFormat::decimals(0).mark(0.0, "C")), + ); + ui::rule(b, root, x0, 482.0, 240.0, 1.0, "#ffffff1a"); + phase(b, root, ns, x0 + 120.0, 790.0); +} + +/// The finish picker: three lettered chips under the cab. +fn finish_picker(b: &mut Builder<'_>, root: NodeId) { + let (w, h) = (96.0, 44.0); + for (i, f) in FINISHES.iter().enumerate() { + let cx = W / 2.0 + (i as f64 - 1.0) * (w + 16.0); + let chip = b.add( + root, + draw::frame( + &format!("{} finish", f.name), + cx - w / 2.0, + H - h - 36.0, + w, + h, + ), + ); + b.add( + chip, + draw::rounded( + draw::rect("chip", 0.0, 0.0, w, h, draw::solid("#161618cc")), + h / 2.0, + ), + ); + b.add( + chip, + draw::circle("swatch", 20.0, h / 2.0, 8.0, draw::solid(f.tolex)), + ); + b.add( + chip, + draw::lettered( + ui::text( + "name", + f.name, + 32.0, + h / 2.0, + w - 36.0, + 14.0, + DIM, + TextAlign::Left, + 500.0, + false, + ), + &Hand::Geo.lettering(500.0), + ), + ); + let lit = b.add(chip, draw::frame("lit", 0.0, 0.0, w, h)); + b.add( + lit, + draw::stroked( + draw::rounded( + draw::rect("ring", 1.0, 1.0, w - 2.0, h - 2.0, draw::solid("#00000000")), + h / 2.0, + ), + TEXT, + 1.5, + ), + ); + b.add( + lit, + draw::lettered( + ui::text( + "name", + f.name, + 32.0, + h / 2.0, + w - 36.0, + 14.0, + TEXT, + TextAlign::Left, + 500.0, + false, + ), + &Hand::Geo.lettering(500.0), + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: FINISHES.len() as u32, + }, + }) + .slot(SlotRole::Led, lit.0); + c.binding = Some(ui::bind(FINISH)); + b.controls.insert(chip, c); + } +} + +pub fn face() -> Face { + let scene = canvas("Two Twelve Cab (cab page, 1500×860)", |b, root| { + ui::fit_root(b, root, W, H); + let mut ground = draw::rect("page", 0.0, 0.0, W, H, draw::solid("#1b1b1d")); + ground.corner_radii = CornerRadii::uniform(10.0); + b.add(root, ground); + // Feet under the cab, then the cab in its finishes. + for fx in [0.1, 0.9] { + b.add( + root, + draw::rounded( + draw::rect( + "foot", + CX + CW * fx - 40.0, + CY + CH - 12.0, + 80.0, + 30.0, + draw::linear( + 90.0, + &[(0.0, "#0a0a0b"), (0.4, "#39393c"), (1.0, "#0a0a0b")], + ), + ), + 5.0, + ), + ); + } + let cab = b.add(root, draw::frame("Cab", CX, CY, CW, CH)); + let r = 190.0; + let speakers = [(CW * 0.27, CH * 0.56), (CW * 0.73, CH * 0.56)]; + for (i, f) in FINISHES.iter().enumerate() { + cab_body(b, cab, i, f, &speakers, r); + } + // The mics ride over every finish: a lane across each speaker (the + // dust cap at its left end for mic 1, its right end for mic 2). + for (k, (sx, sy)) in speakers.iter().enumerate() { + let (lx, lean) = if k == 0 { + (*sx - 20.0, -50.0) + } else { + (*sx - r + 20.0, 50.0) + }; + let lane = b.add( + cab, + draw::frame(&format!("Mic {} lane", k + 1), lx, *sy - 30.0, r, 150.0), + ); + mic(b, lane, &format!("mic{}", k + 1), lean); + } + + mic_column(b, root, "mic1", 24.0); + mic_column(b, root, "mic2", W - 24.0 - 240.0); + // The room send, under the right-hand mic. + let rx = W - 24.0 - 120.0; + text(b, root, "ROOM SEND", rx - 18.0, 520.0, 15.0, TEXT, 600.0); + slide_toggle(b, root, "room/on", "Room send on", rx + 70.0, 520.0); + arc_knob( + b, + root, + "room/send", + "LEVEL", + rx, + 600.0, + Some(ValueFormat::default().signed()), + ); + finish_picker(b, root); + }); + let mut params = vec![parts::pick( + "cab", + "finish", + "Cab Finish", + &["White", "Pink", "Clear"], + 0, + )]; + for (i, pos, dist) in [(1, 0.5, 0.0), (2, 0.5, 0.0)] { + let ns = format!("mic{i}"); + params.push( + ui::param( + &format!("{ns}/position"), + &format!("Mic {i} Position"), + Range::linear(0.0, 1.0, Unit::None), + pos, + ) + .featured(), + ); + params.push(ui::param( + &format!("{ns}/distance"), + &format!("Mic {i} Distance"), + Range::linear(0.0, 1.0, Unit::None), + dist, + )); + params.push(parts::pick( + &ns, + "model", + &format!("Mic {i} Model"), + &MICS, + if i == 1 { 5 } else { 0 }, + )); + params.push(parts::on(&ns, "on", &format!("Mic {i} On"), true)); + params.push(parts::db(&ns, "level", &format!("Mic {i} Level"), -24.0, 12.0, 0.0).bipolar()); + params.push( + ui::param( + &format!("{ns}/pan"), + &format!("Mic {i} Pan"), + Range::linear(-100.0, 100.0, Unit::None), + 0.0, + ) + .bipolar(), + ); + params.push(parts::on(&ns, "phase", &format!("Mic {i} Phase"), false)); + } + params.push(parts::on("room", "on", "Room Send", false)); + params.push(parts::db( + "room", + "send", + "Room Send Level", + -60.0, + 12.0, + 0.0, + )); + Face { + file: "07-cab-two-twelve", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} diff --git a/examples/plugin-faces/src/suite_poly_clean/delay.rs b/examples/plugin-faces/src/suite_poly_clean/delay.rs new file mode 100644 index 00000000..4e68cc87 --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/delay.rs @@ -0,0 +1,449 @@ +//! `10-post-onyx-delay` — the big-box delay (BigBox class, 404 × 286): a +//! matte black floor unit. Three slot levers along the top (sync, mode, +//! type) feed a teal LCD that lists each lever's positions (the chosen one +//! bright) over the time, tempo and feedback readouts; mix and feedback +//! above, low cut, time, amount and high cut below; the engage and +//! tap-tempo stomps at the corners with their white LEDs, the name +//! between them. + +use frame::document::proto::{NodeId, NodeKind, TextAlign}; +use frame::surface::proto::{ButtonMode, Control, ControlKind, Range, SlotRole, Unit, ValueFormat}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::parts::{self, BIG_W, BLACK, PEDAL_H, jacks, knob, pedal, switch, word}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +const NS: &str = "delay"; +const W: f64 = BIG_W; +const LH: f64 = PEDAL_H - parts::LIP; +/// Reference → design: the reference unit is 836 × 635 (lid). +const SX: f64 = W / 836.0; +const SY: f64 = LH / 635.0; +const INK: &str = "#f2f2f2"; + +const SYNC: [&str; 3] = ["TAP", "DAW", "FREE"]; +const MODE: [&str; 3] = ["WIDE", "PING PONG", "NORMAL"]; +const TYPE: [&str; 3] = ["DIGITAL", "DIFFUSION", "MODERN"]; + +fn px(x: f64) -> f64 { + x * SX +} +fn py(y: f64) -> f64 { + y * SY +} + +/// One LCD column: a lever's positions, the chosen one lit (a `Select` +/// button per line — tap a line to choose it). +fn column(b: &mut Builder<'_>, lcd: NodeId, addr: &str, opts: &[&str], cx: f64, top: f64, w: f64) { + let row = 9.5; + for (i, o) in opts.iter().enumerate() { + let cy = top + row * i as f64; + let cell = b.add( + lcd, + frame::surface::kit::draw::frame( + &format!("{o} line"), + cx - w / 2.0, + cy - row / 2.0, + w, + row, + ), + ); + b.add( + cell, + ui::text( + "dim", + o, + 0.0, + row / 2.0, + w, + 6.8, + "#d8efe999", + TextAlign::Center, + 500.0, + false, + ), + ); + let lit = b.add( + cell, + ui::glowing( + ui::text( + "lit", + o, + 0.0, + row / 2.0, + w, + 6.8, + "#ffffff", + TextAlign::Center, + 800.0, + false, + ), + "#e8fff9", + 0.35, + 2.0, + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: opts.len() as u32, + }, + }) + .slot(SlotRole::Led, lit.0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(cell, c); + } +} + +pub fn face() -> Face { + let scene = canvas("Onyx Delay (BigBox pedal, 404×286)", |b, root| { + let lid = pedal( + b, + root, + W, + PEDAL_H, + ["#303032", "#29292b", "#232325"], + "#5a5a5e", + ); + // Top: the jacks' arrows and the three levers with their guide lines. + jacks(b, lid, px(417.0), py(25.0), px(223.0), INK, false); + for ((addr, label), x) in [ + ("delay/sync", "sync"), + ("delay/mode", "mode"), + ("delay/type", "type"), + ] + .iter() + .zip([257.0, 419.0, 573.0]) + { + word(b, lid, label, px(x), py(24.0), 10.5, INK, 500.0, 0.08); + switch( + b, + lid, + "switch.slot_lever", + "", + addr, + label, + 32.0, + px(x), + py(64.0), + &[], + &["knob"], + ); + ui::line( + b, + lid, + "guide", + &[(px(x), py(80.0)), (px(x), py(106.0))], + 0.9, + INK, + ); + b.add( + lid, + frame::surface::kit::draw::circle( + "guide dot", + px(x), + py(80.0), + 1.6, + frame::surface::kit::draw::solid(INK), + ), + ); + } + // The LCD. + let (dx, dy, dw, dh) = (px(187.0), py(110.0), px(460.0), py(128.0)); + let bezel = frame::surface::kit::draw::rounded( + frame::surface::kit::draw::rect( + "lcd bezel", + dx - 3.0, + dy - 3.0, + dw + 6.0, + dh + 6.0, + frame::surface::kit::draw::solid("#0c0c0d"), + ), + 4.0, + ); + b.add( + lid, + frame::surface::kit::draw::with_effects( + bezel, + vec![ + frame::surface::kit::draw::inner_shadow("#000000", 0.0, 1.0, 2.0), + frame::surface::kit::draw::drop_shadow("#ffffff26", 0.0, 1.0, 0.5), + ], + ), + ); + let mut face = frame::surface::kit::draw::frame("LCD", dx, dy, dw, dh); + face.fills = vec![ + frame::surface::kit::draw::linear( + 170.0, + &[(0.0, "#5f9990"), (0.5, "#4b837b"), (1.0, "#3c6f68")], + ), + frame::surface::kit::draw::radial_at( + 0.3, + 0.4, + 0.5, + &[(0.0, "#ffffff14"), (1.0, "#ffffff00")], + ), + frame::surface::kit::draw::radial_at( + 0.75, + 0.7, + 0.4, + &[(0.0, "#0000001a"), (1.0, "#00000000")], + ), + ]; + face.clips_content = true; + face.corner_radii = frame::document::proto::CornerRadii::uniform(2.0); + face.effects = vec![frame::surface::kit::draw::inner_shadow( + "#0000008c", + 0.0, + 1.5, + 3.0, + )]; + let lcd = b.add(lid, face); + let lcd = b.add( + lcd, + frame::surface::kit::draw::frame("backlight", 0.0, 0.0, dw, dh), + ); + if let Some(n) = b.scene.node_mut(lcd) { + n.emission = Some(frame::document::proto::Emission { + color: frame::surface::kit::draw::hex("#6fd0bf"), + intensity: 0.15, + radius: 10.0, + }); + } + let cw = dw / 3.0; + column(b, lcd, "delay/sync", &SYNC, cw * 0.5, 10.0, cw - 6.0); + column(b, lcd, "delay/mode", &MODE, cw * 1.5, 10.0, cw - 6.0); + column(b, lcd, "delay/type", &TYPE, cw * 2.5, 10.0, cw - 6.0); + for (i, addr) in ["delay/time", "delay/tempo", "delay/feedback"] + .iter() + .enumerate() + { + let r = ui::lit_readout( + b, + lcd, + &format!("{addr} readout"), + ui::bind(addr), + cw * i as f64 + 3.0, + dh * 0.8, + cw - 6.0, + 10.5, + "#f6fffc", + TextAlign::Center, + ); + if *addr == "delay/tempo" + && let Some(c) = b.controls.get_mut(&r) + { + c.format = Some(ValueFormat::decimals(1).unit("BPM")); + } + if let Some(v) = b + .controls + .get(&r) + .and_then(|c| c.slots_of(SlotRole::Value).next()) + .map(NodeId) + && let Some(NodeKind::Text(t)) = b.scene.node_mut(v).map(|n| &mut n.kind) + { + // The LCD's own dot-matrix characters. + t.font_family = Some(frame::surface::kit::draw::family::DOTO.into()); + t.font_weight = Some(800.0); + t.font_size *= 1.25; + } + } + // Lines from the LCD down to time and amount. + for (x0, x1) in [(257.0, 290.0), (573.0, 548.0)] { + let pts = [ + (px(x0), py(242.0)), + (px(x0), py(318.0)), + (px(x1), py(318.0)), + ]; + ui::line(b, lid, "guide", &pts, 0.9, INK); + b.add( + lid, + frame::surface::kit::draw::circle( + "guide dot", + px(x0), + py(242.0), + 1.6, + frame::surface::kit::draw::solid(INK), + ), + ); + } + // Knobs. + let k = 40.0; + word(b, lid, "mix", px(103.0), py(80.0), 10.5, INK, 500.0, 0.08); + word( + b, + lid, + "feedback", + px(731.0), + py(80.0), + 10.5, + INK, + 500.0, + 0.08, + ); + knob( + b, + lid, + &BLACK, + "delay/mix", + "Mix", + k, + px(106.0), + py(150.0), + "#2a1d0c", + ); + knob( + b, + lid, + &BLACK, + "delay/feedback", + "Feedback", + k, + px(731.0), + py(150.0), + "#2a1d0c", + ); + knob( + b, + lid, + &BLACK, + "delay/lowcut", + "Low Cut", + k, + px(106.0), + py(300.0), + "#2a1d0c", + ); + knob( + b, + lid, + &BLACK, + "delay/time", + "Time", + k, + px(313.0), + py(300.0), + "#2a1d0c", + ); + knob( + b, + lid, + &BLACK, + "delay/amount", + "Amount", + k, + px(525.0), + py(300.0), + "#2a1d0c", + ); + knob( + b, + lid, + &BLACK, + "delay/highcut", + "High Cut", + k, + px(731.0), + py(300.0), + "#2a1d0c", + ); + parts::cut_glyph(b, lid, px(106.0), py(385.0), 14.0, true, INK); + parts::cut_glyph(b, lid, px(731.0), py(385.0), 14.0, false, INK); + word(b, lid, "time", px(313.0), py(392.0), 10.5, INK, 500.0, 0.08); + word( + b, + lid, + "amount", + px(525.0), + py(392.0), + 10.5, + INK, + 500.0, + 0.08, + ); + // Stomps, LEDs inboard, and the name. + word( + b, + lid, + "engaged", + px(103.0), + py(472.0), + 10.0, + INK, + 500.0, + 0.02, + ); + word( + b, + lid, + "tap tempo", + px(729.0), + py(472.0), + 10.0, + INK, + 500.0, + 0.02, + ); + switch( + b, + lid, + "switch.hex_stomp", + "chrome", + "delay/on", + "Engage", + 24.0, + px(106.0), + py(540.0), + &["led=right"], + &["cap"], + ); + switch( + b, + lid, + "switch.hex_stomp", + "chrome", + "delay/tap", + "Tap Tempo", + 24.0, + px(731.0), + py(540.0), + &["led=left", "momentary"], + &["cap"], + ); + word(b, lid, "delay", px(419.0), py(530.0), 22.0, INK, 600.0, 0.1); + }); + let params = vec![ + parts::pct(NS, "mix", "Mix", 55.0).featured(), + parts::pct(NS, "feedback", "Feedback", 55.0).featured(), + ui::param( + "delay/time", + "Time", + Range::log(20.0, 2000.0, Unit::Milliseconds), + 375.0, + ) + .featured(), + parts::pct(NS, "amount", "Amount", 50.0), + parts::hz(NS, "lowcut", "Low Cut", 20.0, 1000.0, 23.0), + parts::hz(NS, "highcut", "High Cut", 1000.0, 20000.0, 17500.0), + ui::param( + "delay/tempo", + "Tempo", + Range::linear(40.0, 240.0, Unit::None), + 120.0, + ), + parts::pick(NS, "sync", "Sync", &["Tap", "DAW", "Free"], 0), + parts::pick(NS, "mode", "Mode", &["Wide", "Ping Pong", "Normal"], 2), + parts::pick(NS, "type", "Type", &["Digital", "Diffusion", "Modern"], 2), + parts::on(NS, "on", "Engage", true).featured(), + parts::on(NS, "tap", "Tap Tempo", false), + ]; + Face { + file: "10-post-onyx-delay", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} diff --git a/examples/plugin-faces/src/suite_poly_clean/eq.rs b/examples/plugin-faces/src/suite_poly_clean/eq.rs new file mode 100644 index 00000000..152c671e --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/eq.rs @@ -0,0 +1,481 @@ +//! `08-eq-nine-band-tabletop` — a nine-band graphic EQ in a tabletop case, +//! 1100 × 316 design px (a 3.9:1 case on two feet): a big knurled +//! high-pass knob and its power lever at the left, nine faders with +//! glowing caps (65 Hz … 16 kHz, ±12 dB, each with its readout above), a +//! big low-pass knob and the white pilot at the right. +//! +//! The unit comes in the rig's three colourways, one per amp — **Pink**, +//! **White** and **Black** — a choice param (`eq/finish`) picked by the +//! three swatch keys on the case's front edge. Each colourway is a layer +//! (the case, its knobs, its print) shown while the param picks it; the +//! faders, the lever and the pilot are the same on all three. + +use frame::document::proto::{CornerRadii, NodeId, TextAlign}; +use frame::surface::kit::{Place, draw}; +use frame::surface::proto::{ + ButtonMode, Condition, Control, ControlKind, ParamInfo, Range, SlotRole, Unit, ValueFormat, +}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::parts::{self, Cap, knob}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +const W: f64 = 1100.0; +const H: f64 = 316.0; +const BODY_H: f64 = 284.0; +const NS: &str = "eq"; +const BANDS: [(&str, &str); 9] = [ + ("65", "65Hz"), + ("125", "125Hz"), + ("250", "250Hz"), + ("500", "500Hz"), + ("1k", "1kHz"), + ("2k", "2kHz"), + ("4k", "4kHz"), + ("8k", "8kHz"), + ("16k", "16kHz"), +]; +const XS: [f64; 9] = [ + 256.0, 330.0, 403.0, 477.0, 550.0, 624.0, 697.0, 771.0, 845.0, +]; +/// The high-pass and low-pass ranges: each prints OFF at its open end. +const HPF: (f64, f64) = (20.0, 1000.0); +const LPF: (f64, f64) = (1000.0, 20_000.0); + +/// A colourway: name, the case's paint (top edge, top, face, bottom), its +/// lip (top, bottom), the print, and the knobs. +struct Finish { + name: &'static str, + case: [&'static str; 4], + lip: [&'static str; 2], + shade: &'static str, + ink: &'static str, + knob: Cap, +} + +const FINISHES: [Finish; 3] = [ + Finish { + name: "Pink", + case: ["#dea3b6", "#d596ab", "#c98ba0", "#b87a8f"], + lip: ["#a86a80", "#8d5569"], + shade: "#6a334855", + ink: "#4a2a35", + knob: parts::EQ_PINK, + }, + Finish { + name: "White", + case: ["#f7f7f6", "#eeeeec", "#e3e3e1", "#d2d2d0"], + lip: ["#c4c4c2", "#a9a9a7"], + shade: "#4a4a4a40", + ink: "#3c3c3e", + knob: parts::EQ_WHITE, + }, + Finish { + name: "Black", + case: ["#3a3a3d", "#2c2c2f", "#222224", "#18181a"], + lip: ["#141416", "#0b0b0c"], + shade: "#00000080", + ink: "#e9e9ea", + knob: parts::EQ_BLACK, + }, +]; + +fn finish_addr() -> String { + format!("{NS}/finish") +} + +/// A frame of `parent` covering the case, drawn only while the finish is +/// `i`: a readout of the finish param, shown by condition. +fn layer(b: &mut Builder<'_>, parent: NodeId, i: usize) -> NodeId { + let id = b.add( + parent, + draw::frame(&format!("{} finish", FINISHES[i].name), 0.0, 0.0, W, BODY_H), + ); + let mut t = draw::text( + "value", + "", + 0.0, + 0.0, + 40.0, + 4.0, + "#00000000", + TextAlign::Left, + ); + t.opacity = 0.0; + let value = b.add(id, t); + let c = Control::new(ControlKind::Readout) + .slot(SlotRole::Value, value.0) + .bound(ui::bind(&finish_addr())) + .shown_if(Condition::any_of(finish_addr(), &[i as f64])); + b.controls.insert(id, c); + id +} + +/// A plain live value (a `Readout`'s `Value` text) centred on (`cx`, `cy`). +#[allow(clippy::too_many_arguments)] +fn value( + b: &mut Builder<'_>, + parent: NodeId, + name: &str, + addr: &str, + cx: f64, + cy: f64, + w: f64, + size: f64, + ink: &str, + format: ValueFormat, +) -> NodeId { + let root = b.add( + parent, + draw::frame(name, cx - w / 2.0, cy - size, w, size * 2.0), + ); + let t = b.add( + root, + draw::lettered( + ui::text( + "value", + "", + 0.0, + size, + w, + size, + ink, + TextAlign::Center, + 500.0, + false, + ), + &parts::Hand::Ui.lettering(500.0), + ), + ); + let mut c = Control::new(ControlKind::Readout).slot(SlotRole::Value, t.0); + c.binding = Some(ui::bind(addr)); + c.format = Some(format); + b.controls.insert(root, c); + root +} + +/// The power lever: a black capsule, its chrome ball at the right when on. +fn lever(b: &mut Builder<'_>, parent: NodeId, addr: &str, cx: f64, cy: f64) { + let (w, h) = (48.0, 44.0); + let root = b.add( + parent, + draw::frame("Power", cx - w / 2.0, cy - h / 2.0, w, h), + ); + let cap = draw::rounded( + draw::rect( + "capsule", + 6.0, + h / 2.0 - 8.0, + w - 12.0, + 16.0, + draw::linear( + 180.0, + &[(0.0, "#2a2a2c"), (0.5, "#0b0b0c"), (1.0, "#1d1d1f")], + ), + ), + 8.0, + ); + b.add( + root, + draw::with_effects( + cap, + vec![ + draw::drop_shadow("#00000080", 0.0, 2.0, 3.0), + draw::inner_shadow("#ffffff26", 0.0, 1.0, 0.5), + ], + ), + ); + b.add( + root, + draw::stroked( + draw::circle("ring", 16.0, h / 2.0, 4.5, draw::solid("#00000000")), + "#6a6a6c", + 1.2, + ), + ); + let mut poses = Vec::new(); + for (name, x) in [("off", 14.0), ("on", w - 12.0)] { + let pose = b.add(root, draw::frame(name, 0.0, 0.0, w, h)); + b.add( + pose, + draw::circle( + "ball", + x, + h / 2.0, + 5.5, + draw::radial_at( + 0.35, + 0.3, + 0.85, + &[(0.0, "#ffffff"), (0.6, "#b4b5b7"), (1.0, "#4a4b4d")], + ), + ), + ); + poses.push(pose); + } + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Toggle, + }) + .slot(SlotRole::Off, poses[0].0) + .slot(SlotRole::On, poses[1].0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(root, c); +} + +/// The white pilot that glows while the EQ is on. +fn pilot(b: &mut Builder<'_>, parent: NodeId, addr: &str, cx: f64, cy: f64) { + let root = b.add( + parent, + draw::frame("Pilot", cx - 20.0, cy - 8.0, 40.0, 16.0), + ); + b.add( + root, + draw::rounded( + draw::rect("well", 2.0, 3.0, 36.0, 10.0, draw::solid("#00000040")), + 5.0, + ), + ); + let lens = draw::rounded( + draw::rect( + "lens", + 3.0, + 3.5, + 34.0, + 9.0, + draw::linear( + 180.0, + &[(0.0, "#ffffff"), (0.5, "#dfe3ea"), (1.0, "#ffffff")], + ), + ), + 4.5, + ); + let lens = draw::with_effects(lens, vec![draw::drop_shadow("#ffffffb0", 0.0, 0.0, 5.0)]); + let lens = b.add(root, draw::emissive(lens, draw::hex("#f2f4ff"), 0.5, 8.0)); + let mut c = Control::new(ControlKind::Readout).slot(SlotRole::Glow, lens.0); + c.binding = Some(ui::bind(addr)); + b.controls.insert(root, c); +} + +/// One colourway: the case in its paint, the filter knobs, and the print +/// (readouts, knees, band labels) in its ink. +fn skin(b: &mut Builder<'_>, body: NodeId, i: usize) { + let f = &FINISHES[i]; + let l = layer(b, body, i); + let [edge, top, face, bottom] = f.case; + let mut case = draw::rect( + "case", + 0.0, + 0.0, + W, + BODY_H, + draw::linear( + 180.0, + &[(0.0, edge), (0.08, top), (0.6, face), (1.0, bottom)], + ), + ); + case.fills.push(draw::radial_at( + 0.5, + 0.35, + 0.7, + &[(0.0, "#ffffff14"), (1.0, "#ffffff00")], + )); + case.corner_radii = CornerRadii { + top_left: 10.0, + top_right: 10.0, + bottom_left: 4.0, + bottom_right: 4.0, + }; + case.effects = vec![ + draw::inner_shadow("#ffffff66", 0.0, 2.0, 1.5), + draw::inner_shadow(f.shade, 0.0, -3.0, 4.0), + ]; + b.add(l, case); + b.add( + l, + draw::rect( + "bottom lip", + 0.0, + BODY_H - 6.0, + W, + 6.0, + draw::linear(180.0, &[(0.0, f.lip[0]), (1.0, f.lip[1])]), + ), + ); + // High-pass: its readout (OFF at the bottom of its travel), the knee, + // the big knob; low-pass the same, OFF at the top. + value( + b, + l, + "HPF readout", + "eq/hpf", + 146.0, + 32.0, + 90.0, + 10.0, + f.ink, + ValueFormat::decimals(0).mark(HPF.0, "OFF"), + ); + parts::cut_glyph(b, l, 146.0, 60.0, 26.0, true, f.ink); + knob( + b, + l, + &f.knob, + "eq/hpf", + "High Pass", + 98.0, + 146.0, + 139.0, + f.ink, + ); + value( + b, + l, + "LPF readout", + "eq/lpf", + 951.0, + 32.0, + 90.0, + 10.0, + f.ink, + ValueFormat::default().mark(LPF.1, "OFF"), + ); + parts::cut_glyph(b, l, 951.0, 60.0, 26.0, false, f.ink); + knob( + b, l, &f.knob, "eq/lpf", "Low Pass", 98.0, 951.0, 139.0, f.ink, + ); + for ((id, label), x) in BANDS.iter().zip(XS) { + value( + b, + l, + &format!("{label} readout"), + &format!("eq/g{id}"), + x, + 32.0, + 64.0, + 10.0, + f.ink, + ValueFormat::decimals(1).unit("db").signed(), + ); + parts::print(b, l, label, x, 247.0, 10.0, f.ink, 500.0, 0.08); + } +} + +/// The three colourway keys on the case's front edge: swatches, the +/// chosen one ringed. +fn finish_keys(b: &mut Builder<'_>, body: NodeId) { + let n = FINISHES.len(); + for (i, f) in FINISHES.iter().enumerate() { + let cx = W / 2.0 + 18.0 * (i as f64 - 1.0); + let key = b.add( + body, + draw::frame(&format!("Finish {}", f.name), cx - 8.0, 259.0, 16.0, 16.0), + ); + let swatch = draw::circle( + "swatch", + 8.0, + 8.0, + 4.5, + draw::linear(180.0, &[(0.0, f.case[0]), (1.0, f.case[2])]), + ); + b.add(key, draw::stroked(swatch, "#00000080", 0.8)); + let ring = b.add( + key, + draw::stroked( + draw::circle("chosen", 8.0, 8.0, 6.6, draw::solid("#00000000")), + "#f4f4f4", + 1.1, + ), + ); + let mut c = Control::new(ControlKind::Button { + mode: ButtonMode::Select { + index: i as u32, + count: n as u32, + }, + }) + .slot(SlotRole::Led, ring.0); + c.binding = Some(ui::bind(&finish_addr())); + b.controls.insert(key, c); + } +} + +pub fn face() -> Face { + let scene = canvas("Nine Band EQ (tabletop, 1100×316)", |b, root| { + ui::fit_root(b, root, W, H); + // Feet, then the case: a box, its top face catching the light, in + // the chosen colourway. + for fx in [0.1, 0.9] { + let foot = draw::rounded( + draw::rect( + "foot", + W * fx - 36.0, + BODY_H - 10.0, + 72.0, + H - BODY_H + 10.0, + draw::linear( + 90.0, + &[(0.0, "#050505"), (0.4, "#2b2b2d"), (1.0, "#070707")], + ), + ), + 6.0, + ); + b.add(root, foot); + } + let mut body = draw::frame("Case", 0.0, 0.0, W, BODY_H); + body.corner_radii = CornerRadii { + top_left: 10.0, + top_right: 10.0, + bottom_left: 4.0, + bottom_right: 4.0, + }; + body.effects = vec![draw::drop_shadow("#000000a0", 0.0, 10.0, 16.0)]; + body.fills = vec![draw::solid("#1a1a1c")]; + let body = b.add(root, body); + for i in 0..FINISHES.len() { + skin(b, body, i); + } + lever(b, body, "eq/on", 146.0, 230.0); + pilot(b, body, "eq/on", 951.0, 230.0); + for ((id, label), x) in BANDS.iter().zip(XS) { + let p = Place::new(format!("{label} gain")) + .size(145.0) + .bind(ui::bind(&format!("eq/g{id}"))); + let f = frame::surface::kit::place(b, body, "lever.glow_cap", &p) + .unwrap_or_else(|e| panic!("{e}")); + let fw = b.scene.node(f).map_or(0.0, |n| n.size.width); + ui::move_to(b, f, x - fw / 2.0, 44.0); + } + finish_keys(b, body); + }); + let mut params: Vec = BANDS + .iter() + .map(|(id, label)| { + ui::param( + &format!("{NS}/g{id}"), + &format!("{label} Gain"), + Range::linear(-12.0, 12.0, Unit::Db), + 0.0, + ) + .bipolar() + }) + .collect(); + params[0] = params[0].clone().featured(); + params.push(parts::hz(NS, "hpf", "High Pass", HPF.0, HPF.1, HPF.0)); + params.push(parts::hz(NS, "lpf", "Low Pass", LPF.0, LPF.1, LPF.1)); + params.push(parts::on(NS, "on", "Power", true).featured()); + params.push(parts::pick( + NS, + "finish", + "Finish", + &FINISHES.map(|f| f.name), + 0, + )); + Face { + file: "08-eq-nine-band-tabletop", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} diff --git a/examples/plugin-faces/src/suite_poly_clean/parts.rs b/examples/plugin-faces/src/suite_poly_clean/parts.rs new file mode 100644 index 00000000..20cf31a5 --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/parts.rs @@ -0,0 +1,639 @@ +//! What the suite's units share: the satin pedal enclosure (every pedal is +//! the Standard class, 156 × 286 design px; the big-box delay 404 × 286), +//! the jack glyphs printed along a pedal's top edge, lower-case geometric +//! lettering, knobs placed by their shaft, and the catalog entries. + +use frame::document::proto::{CornerRadii, NodeId, NodeKind, TextAlign}; +use frame::surface::kit::materials::paint::luma; +use frame::surface::kit::materials::texture::{self, Relief}; +use frame::surface::kit::{Place, TOUCH_MIN, draw}; +use frame::surface::proto::{ParamInfo, Range, Unit}; +use frame::surface::widgets::Builder; + +use crate::reverb::ui::{self, blend}; + +/// The Standard pedal class (1590B-sized), design px at 60 px/in. +pub const STD_W: f64 = 156.0; +/// Every pedal's height (the Standard class's depth). +pub const PEDAL_H: f64 = 286.0; +/// The BigBox class (double-wide floor unit). +pub const BIG_W: f64 = 404.0; +/// The enclosure's bottom lip below the lid. +pub const LIP: f64 = 9.0; + +/// A satin-anodised enclosure `w × h` filling `root`, painted top → bottom +/// in `paint`, with its lighter edge `rim`: the lid, and under it the +/// darker lip of the box's front side. Returns the lid (origin at the +/// root's). +pub fn pedal( + b: &mut Builder<'_>, + root: NodeId, + w: f64, + h: f64, + paint: [&str; 3], + rim: &str, +) -> NodeId { + ui::fit_root(b, root, w, h); + let mut side = draw::frame("Side", 0.0, LIP * 2.0, w, h - LIP * 2.0); + side.fills = vec![draw::linear( + 180.0, + &[ + (0.0, &blend(paint[2], "#000000", 0.45)), + (1.0, &blend(paint[2], "#000000", 0.7)), + ], + )]; + side.corner_radii = CornerRadii::uniform(10.0); + side.effects = vec![draw::drop_shadow("#00000099", 0.0, 4.0, 10.0)]; + b.add(root, side); + let mut lid = draw::frame("Lid", 0.0, 0.0, w, h - LIP); + lid.fills = vec![ + draw::linear(180.0, &[(0.0, paint[0]), (0.55, paint[1]), (1.0, paint[2])]), + draw::linear( + 100.0, + &[ + (0.0, "#ffffff00"), + (0.3, "#ffffff10"), + (0.55, "#ffffff00"), + (1.0, "#0000000c"), + ], + ), + ]; + lid.corner_radii = CornerRadii::uniform(9.0); + lid.effects = vec![ + draw::inner_shadow(rim, 0.0, 1.2, 0.6), + draw::inner_shadow("#00000040", 0.0, -1.5, 2.0), + draw::drop_shadow("#00000070", 0.0, 1.5, 1.5), + ]; + let lid = b.add(root, lid); + let edge = draw::stroked( + draw::rounded(draw::frame("edge", 0.5, 0.5, w - 1.0, h - LIP - 1.0), 8.5), + rim, + 1.0, + ); + b.add(lid, edge); + lid +} + +/// The jack glyphs along a pedal's top: ▲ input, (`plug`) the power plug, ▼ output. +pub fn jacks( + b: &mut Builder<'_>, + lid: NodeId, + cx: f64, + y: f64, + spread: f64, + ink: &str, + plug: bool, +) { + let s = 3.2; + let up = draw::polygon(&[ + (cx - spread - s, y + s * 0.8), + (cx - spread + s, y + s * 0.8), + (cx - spread, y - s * 0.8), + ]); + let down = draw::polygon(&[ + (cx + spread - s, y - s * 0.8), + (cx + spread + s, y - s * 0.8), + (cx + spread, y + s * 0.8), + ]); + b.add( + lid, + draw::path( + "jacks", + 0.0, + 0.0, + 2.0 * cx, + 2.0 * y, + format!("{up} {down}"), + draw::solid(ink), + ), + ); + if !plug { + return; + } + ui::line(b, lid, "plug", &[(cx - 9.0, y), (cx + 9.0, y)], 0.8, ink); + for dx in [-7.0, 0.0, 7.0] { + let ring = draw::circle("plug pin", cx + dx, y, 1.8, draw::solid("#00000000")); + b.add(lid, draw::stroked(ring, ink, 0.8)); + } +} + +/// A covering `(x, y, w, h)` rounded `r` in `color` with a relief texture +/// of strength `gain` (`scale` its size), lit evenly from the front — the +/// plugin's renders light the heads and cabs flat, so no side sheen; the +/// top a touch brighter. Returns the clipped frame. +#[allow(clippy::too_many_arguments)] +pub fn covering( + b: &mut Builder<'_>, + parent: NodeId, + (x, y, w, h): (f64, f64, f64, f64), + r: f64, + color: &str, + relief: Relief, + gain: f64, + scale: f64, +) -> NodeId { + let mut f = draw::rounded(draw::frame("covering", x, y, w, h), r); + f.clips_content = true; + f.fills = vec![draw::linear( + 180.0, + &[ + (0.0, &blend(color, "#ffffff", 0.08)), + (0.5, color), + (1.0, &blend(color, "#000000", 0.08)), + ], + )]; + let f = b.add(parent, f); + texture::relief(b, f, (0.0, 0.0, w, h), 0.0, relief, gain, scale); + // The grain as vectors too (a markup page carries no textures): a fine + // speckle of lit and shaded tooth. + let mut seed = 0x9e37_79b9_u64; + let mut next = || { + seed = seed + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((seed >> 11) as f64) / ((1_u64 << 53) as f64) + }; + let (mut lit, mut dark) = (String::new(), String::new()); + let n = ((w * h) / 90.0).min(6000.0) as usize; + for _ in 0..n { + let (px, py, s) = (next() * w, next() * h, 0.6 + next() * 0.9); + let d = format!("M {px:.1} {py:.1} h {s:.1} v {s:.1} h -{s:.1} Z "); + if next() < 0.5 { + lit.push_str(&d); + } else { + dark.push_str(&d); + } + } + // Light coverings show the shaded tooth, dark ones barely the lit. + let l = luma(color); + let (lit_a, dark_a) = if l > 0.6 { + (0.1, 0.08) + } else if l < 0.3 { + (0.045, 0.2) + } else { + (0.16, 0.14) + }; + b.add( + f, + draw::path( + "grain lit", + 0.0, + 0.0, + w, + h, + lit, + draw::solid(&ui::alpha("#ffffff", lit_a)), + ), + ); + b.add( + f, + draw::path( + "grain dark", + 0.0, + 0.0, + w, + h, + dark, + draw::solid(&ui::alpha("#000000", dark_a)), + ), + ); + f +} + +/// The suite's two hands: the pedals' and logos' wide geometric +/// lower-case, and the silkscreen and screen type. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Hand { + /// Wide geometric sans (Saira, extended): pedal names and legends. + Geo, + /// The UI sans (Inter): amp silkscreen, screen labels, readouts. + Ui, +} + +impl Hand { + #[must_use] + pub fn lettering(self, weight: f64) -> draw::Lettering { + match self { + Self::Geo => draw::Lettering::family(draw::family::SAIRA) + .weight(weight) + .width(118.0), + Self::Ui => draw::Lettering::family(draw::family::INTER).weight(weight), + } + } +} + +/// Lettering in `hand`, centred on (`cx`, `cy`), tracked `track` em apart. +#[allow(clippy::too_many_arguments)] +pub fn set( + b: &mut Builder<'_>, + parent: NodeId, + hand: Hand, + s: &str, + cx: f64, + cy: f64, + size: f64, + color: &str, + weight: f64, + track: f64, +) -> NodeId { + let w = s.chars().count() as f64 * size * (0.8 + track) + size * 2.0; + let mut t = draw::lettered( + ui::text( + "word", + s, + cx - w / 2.0, + cy, + w, + size, + color, + TextAlign::Center, + weight, + false, + ), + &hand.lettering(weight), + ); + if let NodeKind::Text(d) = &mut t.kind { + d.letter_spacing = size * track; + } + b.add(parent, t) +} + +/// The pedals' wide geometric lower-case, centred on (`cx`, `cy`). +#[allow(clippy::too_many_arguments)] +pub fn word( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + cx: f64, + cy: f64, + size: f64, + color: &str, + weight: f64, + track: f64, +) -> NodeId { + set(b, parent, Hand::Geo, s, cx, cy, size, color, weight, track) +} + +/// Silkscreen and screen type (the UI sans), centred on (`cx`, `cy`). +#[allow(clippy::too_many_arguments)] +pub fn print( + b: &mut Builder<'_>, + parent: NodeId, + s: &str, + cx: f64, + cy: f64, + size: f64, + color: &str, + weight: f64, + track: f64, +) -> NodeId { + set(b, parent, Hand::Ui, s, cx, cy, size, color, weight, track) +} + +/// A knob's look: the component that draws it, its variant (the cap) and +/// its options. Every knob in the suite is placed through [`knob`] with one +/// of these, so a new knob renderer is a change here and nowhere else. +#[derive(Clone, Copy)] +pub struct Cap { + pub id: &'static str, + pub variant: &'static str, + pub opts: &'static [&'static str], + /// How far (a fraction of the knob's size) the shaft sits below the + /// point asked for: a tall cap seen from the front stands up off its + /// shaft, so its top lands where a flat knob's face would. + pub drop: f64, +} + +impl Cap { + /// A GPU-rendered knob (`knob.3d`, `knob.render` without the GPU): a + /// preset and its options. + const fn gpu(preset: &'static str, opts: &'static [&'static str]) -> Self { + Self { + id: "knob.3d", + variant: preset, + opts, + drop: 0.0, + } + } + + const fn drop(mut self, f: f64) -> Self { + self.drop = f; + self + } +} + +/// Gold knurled cylinders (the boost and compressor): polished gold, a +/// tall diamond-knurled side, a dark dot. +pub const GOLD: Cap = Cap::gpu( + "gold_knurl", + &[ + "color=#d9ae6a", + "pointer=#2a1d0c", + "ticks=none", + "height=0.74", + "tilt=36", + "ribs=44", + "index_w=0.09", + ], +) +.drop(0.24); +/// Rose-gold knurled cylinders (the overdrive). +pub const ROSE: Cap = Cap::gpu( + "gold_knurl", + &[ + "color=#e4ab96", + "pointer=#2a1d0c", + "ticks=none", + "height=0.74", + "tilt=36", + "ribs=44", + "index_w=0.09", + ], +) +.drop(0.24); +/// A big pearl-white cap, knurled round its foot (the chorus). +pub const PEARL: Cap = Cap::gpu( + "white_knurl", + &[ + "color=#e6e5e0", + "pointer=#2f3432", + "ticks=none", + "diamond=0", + "knurl_band=0.3", + "ribs=70", + "dome=0.06", + "height=0.7", + "tilt=34", + ], +) +.drop(0.08); +/// Magenta anodised knurled caps (the reverb). +pub const MAGENTA: Cap = Cap::gpu( + "gold_knurl", + &[ + "color=#b0305f", + "pointer=#1c0a12", + "ticks=none", + "rough=0.26", + "height=0.74", + "tilt=36", + "ribs=44", + "index_w=0.09", + ], +) +.drop(0.24); +/// Black knurled caps with glossy domed tops, a white dot (the delay). +pub const BLACK: Cap = Cap::gpu( + "white_knurl", + &[ + "color=#1e1e20", + "pointer=#f2f2f2", + "ticks=none", + "diamond=0", + "ribs=44", + "rough=0.2", + "coat=0.7", + "dome=0.16", + "knurl_band=0.62", + "height=0.8", + "tilt=36", + "index_w=0.1", + "index_in=0.56", + "index_out=0.56", + ], +) +.drop(0.04); +/// The lead head: pink spun-metal caps in black skirts, a dark dot, a +/// printed tick ring. +pub const LEAD: Cap = Cap::gpu( + "spun_metal", + &[ + "color=#e7aebf", + "pointer=#2a1219", + "skirt_color=#1b1a1b", + "index=dot", + "index_w=0.07", + "index_in=0.62", + "index_out=0.62", + "ticks=lines", + "material=chrome", + ], +); +/// The rhythm head: soft white cylinders on white skirts, a printed tick ring. +pub const RHYTHM: Cap = Cap::gpu( + "flat", + &[ + "color=#efefed", + "pointer=#cfcfcc", + "skirt_color=#e2e2e0", + "rough=0.7", + "coat=0", + "spec=0.5", + "index=dot", + "index_in=0.6", + "index_out=0.6", + "ticks=lines", + ], +); +/// The acoustic head: black knurled caps, a spun top, a white dot, a +/// printed tick ring. +pub const ACOUSTIC: Cap = Cap::gpu( + "knurled", + &[ + "color=#1f1f21", + "pointer=#eeeeee", + "index=dot", + "index_w=0.08", + "index_in=0.6", + "index_out=0.6", + "cap_r=0.86", + "height=0.55", + "knurl_band=0.55", + "ribs=64", + "aniso=0.7", + "ticks=lines", + ], +); +/// The EQ: big pink caps with a milled foot, a dark dot. +pub const EQ_PINK: Cap = Cap::gpu( + "white_knurl", + &[ + "color=#d98f9e", + "pointer=#3a1f28", + "ticks=none", + "diamond=0", + "knurl_band=1", + "rough=0.6", + "dome=0.02", + "index_in=0.6", + "index_out=0.6", + "index_w=0.09", + "tilt=26", + "height=0.5", + "ribs=80", + ], +); +/// The white EQ's caps. +pub const EQ_WHITE: Cap = Cap::gpu( + "white_knurl", + &[ + "color=#eeeeec", + "pointer=#2a2a2c", + "ticks=none", + "diamond=0", + "knurl_band=1", + "rough=0.6", + "dome=0.02", + "index_in=0.6", + "index_out=0.6", + "index_w=0.09", + "tilt=26", + "height=0.5", + "ribs=80", + ], +); +/// The black EQ's caps. +pub const EQ_BLACK: Cap = Cap::gpu( + "white_knurl", + &[ + "color=#202022", + "pointer=#eeeeee", + "ticks=none", + "diamond=0", + "knurl_band=1", + "rough=0.45", + "dome=0.02", + "index_in=0.6", + "index_out=0.6", + "index_w=0.09", + "tilt=26", + "height=0.5", + "ribs=80", + ], +); +/// The cab page's plugin-style flat arc knobs. +pub const SCREEN: Cap = Cap { + id: "knob.arc_disc", + variant: "dark", + opts: &[], + drop: 0.0, +}; + +/// A knob in `cap`'s look with its shaft at (`cx`, `cy`), a tight touch +/// box, no printed legend (the unit letters its own); ticks in `ink`. +#[allow(clippy::too_many_arguments)] +pub fn knob( + b: &mut Builder<'_>, + parent: NodeId, + cap: &Cap, + addr: &str, + name: &str, + d: f64, + cx: f64, + cy: f64, + ink: &str, +) -> NodeId { + let p = Place::new(name) + .size(d) + .variant(cap.variant) + .bind(ui::bind(addr)) + .legend("") + .touch(TOUCH_MIN) + .options(cap.opts) + .ink(frame::surface::kit::Ink::Color(ink.into())); + let k = ui::put(b, parent, cap.id, &p, cx, cy + cap.drop * d); + ui::strip_legend(b, k); + k +} + +/// A switch component (footswitch, lever) centred on its child named one +/// of `anchor` at (`cx`, `cy`). +#[allow(clippy::too_many_arguments)] +pub fn switch( + b: &mut Builder<'_>, + parent: NodeId, + id: &str, + variant: &str, + addr: &str, + name: &str, + d: f64, + cx: f64, + cy: f64, + opts: &[&str], + anchor: &[&str], +) -> NodeId { + let mut p = Place::new(name).size(d).bind(ui::bind(addr)).options(opts); + if !variant.is_empty() { + p = p.variant(variant); + } + ui::put_by(b, parent, id, &p, anchor, cx, cy) +} + +/// The printed high-pass / low-pass glyph (a knee), `w` wide at (`cx`, `cy`). +pub fn cut_glyph( + b: &mut Builder<'_>, + parent: NodeId, + cx: f64, + cy: f64, + w: f64, + high_pass: bool, + ink: &str, +) { + let h = w * 0.45; + let pts = if high_pass { + vec![ + (cx - w / 2.0, cy + h / 2.0), + (cx - w * 0.1, cy - h / 2.0), + (cx + w / 2.0, cy - h / 2.0), + ] + } else { + vec![ + (cx - w / 2.0, cy - h / 2.0), + (cx + w * 0.1, cy - h / 2.0), + (cx + w / 2.0, cy + h / 2.0), + ] + }; + ui::line(b, parent, "cut glyph", &pts, 1.3, ink); +} + +// ── catalog ────────────────────────────────────────────────────────────── + +pub fn pct(ns: &str, id: &str, name: &str, default: f64) -> ParamInfo { + ui::percent(&format!("{ns}/{id}"), name, default) +} + +pub fn db(ns: &str, id: &str, name: &str, lo: f64, hi: f64, default: f64) -> ParamInfo { + ui::param( + &format!("{ns}/{id}"), + name, + Range::linear(lo, hi, Unit::Db), + default, + ) +} + +/// An amp's 0–10 dial. +pub fn dial10(ns: &str, id: &str, name: &str, default: f64) -> ParamInfo { + ui::param( + &format!("{ns}/{id}"), + name, + Range::linear(0.0, 10.0, Unit::None), + default, + ) +} + +pub fn hz(ns: &str, id: &str, name: &str, lo: f64, hi: f64, default: f64) -> ParamInfo { + ui::param( + &format!("{ns}/{id}"), + name, + Range::log(lo, hi, Unit::Hz), + default, + ) +} + +pub fn on(ns: &str, id: &str, name: &str, default: bool) -> ParamInfo { + ui::toggle(&format!("{ns}/{id}"), name, default) +} + +pub fn pick(ns: &str, id: &str, name: &str, options: &[&str], default: usize) -> ParamInfo { + ui::choice(&format!("{ns}/{id}"), name, options, default) +} diff --git a/examples/plugin-faces/src/suite_poly_clean/pedals.rs b/examples/plugin-faces/src/suite_poly_clean/pedals.rs new file mode 100644 index 00000000..02afedea --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/pedals.rs @@ -0,0 +1,461 @@ +//! The Standard-class pedals (156 × 286): three before the amp, two after. +//! +//! - `01-pre-gold-boost` — a champagne-gold boost: gain, level, bass, +//! treble on gold knurled knobs. +//! - `02-pre-bronze-compressor` — a bronze compressor: comp and level, an +//! attack lever (slow / fast). +//! - `03-pre-rose-overdrive` — a dusty-rose overdrive: drive, level, tone. +//! - `09-post-mint-chorus` — a mint chorus with one big pearl mix knob. +//! - `11-post-lilac-reverb` — a lilac reverb: mix, decay, low and high cut +//! on magenta knobs, a shimmer lever. +//! +//! Each pedal's lettering is lower-case, geometric and tracked wide, the +//! jack glyphs printed along the top, the stomp low with its white LED to +//! the right. + +use frame::document::proto::NodeId; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::parts::{ + self, GOLD, MAGENTA, PEARL, PEDAL_H, ROSE, STD_W, jacks, knob, pedal, switch, word, +}; +use crate::face::{Face, canvas}; + +const LH: f64 = PEDAL_H - parts::LIP; +const W: f64 = STD_W; + +fn x(f: f64) -> f64 { + W * f +} +fn y(f: f64) -> f64 { + LH * f +} + +/// The stomp and its LED. +fn stomp(b: &mut Builder<'_>, lid: NodeId, ns: &str, finish: &str) { + switch( + b, + lid, + "switch.hex_stomp", + finish, + &format!("{ns}/on"), + "Bypass", + 27.0, + x(0.5), + y(0.83), + &["led=right"], + &["cap"], + ); +} + +pub fn boost() -> Face { + const NS: &str = "boost"; + let ink = "#fbf6ef"; + let scene = canvas("Gold Boost (Standard pedal, 156×286)", |b, root| { + let lid = pedal( + b, + root, + W, + PEDAL_H, + ["#c6b39c", "#ac9880", "#94806a"], + "#f1e3cf", + ); + jacks(b, lid, x(0.5), y(0.045), 26.0, ink, true); + word(b, lid, "gain", x(0.24), y(0.088), 10.0, ink, 700.0, 0.12); + word(b, lid, "level", x(0.76), y(0.088), 10.0, ink, 700.0, 0.12); + knob( + b, + lid, + &GOLD, + "boost/gain", + "Gain", + 36.0, + x(0.24), + y(0.19), + "#2a1d0c", + ); + knob( + b, + lid, + &GOLD, + "boost/level", + "Level", + 36.0, + x(0.76), + y(0.19), + "#2a1d0c", + ); + knob( + b, + lid, + &GOLD, + "boost/bass", + "Bass", + 36.0, + x(0.24), + y(0.385), + "#2a1d0c", + ); + knob( + b, + lid, + &GOLD, + "boost/treble", + "Treble", + 36.0, + x(0.76), + y(0.385), + "#2a1d0c", + ); + word(b, lid, "bass", x(0.24), y(0.535), 10.0, ink, 700.0, 0.12); + word(b, lid, "treble", x(0.76), y(0.535), 10.0, ink, 700.0, 0.12); + word(b, lid, "boost", x(0.5), y(0.63), 22.0, ink, 700.0, 0.07); + stomp(b, lid, NS, "gold"); + }); + let params = vec![ + parts::db(NS, "gain", "Gain", 0.0, 24.0, 8.5).featured(), + parts::db(NS, "level", "Level", -12.0, 12.0, 0.0).bipolar(), + parts::db(NS, "bass", "Bass", -12.0, 12.0, 0.0).bipolar(), + parts::db(NS, "treble", "Treble", -12.0, 12.0, 0.0).bipolar(), + parts::on(NS, "on", "Bypass", true).featured(), + ]; + Face { + file: "01-pre-gold-boost", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} + +pub fn compressor() -> Face { + const NS: &str = "comp"; + let ink = "#fbf4ea"; + let scene = canvas("Bronze Compressor (Standard pedal, 156×286)", |b, root| { + let lid = pedal( + b, + root, + W, + PEDAL_H, + ["#a07a4e", "#8a6538", "#76532b"], + "#d9b88c", + ); + jacks(b, lid, x(0.5), y(0.045), 26.0, ink, true); + word(b, lid, "comp", x(0.22), y(0.088), 10.0, ink, 700.0, 0.12); + word(b, lid, "level", x(0.78), y(0.088), 10.0, ink, 700.0, 0.12); + knob( + b, + lid, + &GOLD, + "comp/comp", + "Comp", + 36.0, + x(0.22), + y(0.19), + "#2a1d0c", + ); + knob( + b, + lid, + &GOLD, + "comp/level", + "Level", + 36.0, + x(0.78), + y(0.19), + "#2a1d0c", + ); + switch( + b, + lid, + "switch.mini_lever", + "gold", + "comp/attack", + "Attack", + 22.0, + x(0.5), + y(0.42), + &[], + &["bushing"], + ); + word(b, lid, "slow", x(0.345), y(0.375), 7.5, ink, 600.0, 0.04); + word(b, lid, "fast", x(0.655), y(0.375), 7.5, ink, 600.0, 0.04); + parts::cut_glyph(b, lid, x(0.36), y(0.405), 5.0, false, ink); + word(b, lid, "attack", x(0.5), y(0.495), 10.0, ink, 700.0, 0.12); + word( + b, + lid, + "compressor", + x(0.5), + y(0.63), + 16.5, + ink, + 700.0, + 0.07, + ); + stomp(b, lid, NS, "gold"); + }); + let params = vec![ + parts::pct(NS, "comp", "Comp", 40.0).featured(), + parts::db(NS, "level", "Level", -12.0, 12.0, 0.0).bipolar(), + parts::on(NS, "attack", "Attack Fast", false), + parts::on(NS, "on", "Bypass", true).featured(), + ]; + Face { + file: "02-pre-bronze-compressor", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} + +pub fn overdrive() -> Face { + const NS: &str = "drive"; + let ink = "#fbf3f5"; + let scene = canvas("Rose Overdrive (Standard pedal, 156×286)", |b, root| { + let lid = pedal( + b, + root, + W, + PEDAL_H, + ["#c09aa7", "#a67b8b", "#916676"], + "#ecd2da", + ); + jacks(b, lid, x(0.5), y(0.045), 26.0, ink, true); + word(b, lid, "drive", x(0.23), y(0.088), 10.0, ink, 700.0, 0.12); + word(b, lid, "level", x(0.77), y(0.088), 10.0, ink, 700.0, 0.12); + knob( + b, + lid, + &ROSE, + "drive/drive", + "Drive", + 36.0, + x(0.23), + y(0.19), + "#2a1d0c", + ); + knob( + b, + lid, + &ROSE, + "drive/level", + "Level", + 36.0, + x(0.77), + y(0.19), + "#2a1d0c", + ); + knob( + b, + lid, + &ROSE, + "drive/tone", + "Tone", + 36.0, + x(0.5), + y(0.36), + "#2a1d0c", + ); + word(b, lid, "tone", x(0.5), y(0.52), 10.0, ink, 700.0, 0.12); + word( + b, + lid, + "overdrive", + x(0.5), + y(0.635), + 18.0, + ink, + 700.0, + 0.07, + ); + stomp(b, lid, NS, "gold"); + }); + let params = vec![ + parts::pct(NS, "drive", "Drive", 40.0).featured(), + parts::db(NS, "level", "Level", -12.0, 12.0, 0.0).bipolar(), + parts::pct(NS, "tone", "Tone", 50.0), + parts::on(NS, "on", "Bypass", true).featured(), + ]; + Face { + file: "03-pre-rose-overdrive", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} + +pub fn chorus() -> Face { + const NS: &str = "chorus"; + let ink = "#3f4745"; + let scene = canvas("Mint Chorus (Standard pedal, 156×286)", |b, root| { + let lid = pedal( + b, + root, + W, + PEDAL_H, + ["#d5e2de", "#c5d4d0", "#b3c4bf"], + "#f4fbf9", + ); + jacks(b, lid, x(0.5), y(0.045), 26.0, ink, true); + word(b, lid, "mix", x(0.5), y(0.11), 10.0, ink, 500.0, 0.12); + knob( + b, + lid, + &PEARL, + "chorus/mix", + "Mix", + 58.0, + x(0.5), + y(0.25), + "#2a1d0c", + ); + word(b, lid, "chorus", x(0.5), y(0.545), 22.0, ink, 500.0, 0.12); + switch( + b, + lid, + "switch.hex_stomp", + "chrome", + "chorus/on", + "Bypass", + 27.0, + x(0.5), + y(0.83), + &["led=right"], + &["cap"], + ); + }); + let params = vec![ + parts::pct(NS, "mix", "Mix", 42.0).featured(), + parts::on(NS, "on", "Bypass", true).featured(), + ]; + Face { + file: "09-post-mint-chorus", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} + +pub fn reverb() -> Face { + const NS: &str = "verb"; + let ink = "#8e6f7d"; + let scene = canvas("Lilac Reverb (Standard pedal, 156×286)", |b, root| { + let lid = pedal( + b, + root, + W, + PEDAL_H, + ["#efe2ec", "#e3d2df", "#d5c1d0"], + "#fff8fd", + ); + jacks(b, lid, x(0.5), y(0.045), 26.0, "#3b2f36", true); + word(b, lid, "mix", x(0.23), y(0.085), 10.0, ink, 700.0, 0.1); + word(b, lid, "decay", x(0.77), y(0.085), 10.0, ink, 500.0, 0.12); + knob( + b, + lid, + &MAGENTA, + "verb/mix", + "Mix", + 36.0, + x(0.23), + y(0.195), + "#2a1d0c", + ); + knob( + b, + lid, + &MAGENTA, + "verb/decay", + "Decay", + 36.0, + x(0.77), + y(0.195), + "#2a1d0c", + ); + knob( + b, + lid, + &MAGENTA, + "verb/low", + "Low Cut", + 36.0, + x(0.23), + y(0.37), + "#2a1d0c", + ); + knob( + b, + lid, + &MAGENTA, + "verb/high", + "High Cut", + 36.0, + x(0.77), + y(0.37), + "#2a1d0c", + ); + switch( + b, + lid, + "switch.mini_lever", + "magenta", + "verb/shimmer", + "Shimmer", + 16.0, + x(0.5), + y(0.3), + &[], + &["bushing"], + ); + word(b, lid, "off", x(0.43), y(0.21), 7.0, "#3b2f36", 500.0, 0.04); + word(b, lid, "on", x(0.585), y(0.21), 7.0, "#3b2f36", 500.0, 0.04); + word( + b, + lid, + "shimmer", + x(0.5), + y(0.39), + 7.5, + "#3b2f36", + 600.0, + 0.04, + ); + parts::cut_glyph(b, lid, x(0.23), y(0.49), 14.0, true, ink); + parts::cut_glyph(b, lid, x(0.77), y(0.49), 14.0, false, ink); + word(b, lid, "reverb", x(0.5), y(0.575), 22.0, ink, 700.0, 0.1); + switch( + b, + lid, + "switch.hex_stomp", + "magenta", + "verb/on", + "Bypass", + 27.0, + x(0.5), + y(0.83), + &["led=right"], + &["cap"], + ); + }); + let params = vec![ + parts::pct(NS, "mix", "Mix", 8.0).featured(), + parts::pct(NS, "decay", "Decay", 58.0).featured(), + parts::hz(NS, "low", "Low Cut", 20.0, 1000.0, 24.0), + parts::hz(NS, "high", "High Cut", 1000.0, 20000.0, 17000.0), + parts::on(NS, "shimmer", "Shimmer", false), + parts::on(NS, "on", "Bypass", true).featured(), + ]; + Face { + file: "11-post-lilac-reverb", + plugin: PLUGIN, + scene, + params, + streams: vec![], + } +} diff --git a/examples/plugin-faces/src/suite_poly_clean/voices.rs b/examples/plugin-faces/src/suite_poly_clean/voices.rs new file mode 100644 index 00000000..497f66cf --- /dev/null +++ b/examples/plugin-faces/src/suite_poly_clean/voices.rs @@ -0,0 +1,982 @@ +//! `12-post-voices-harmonizer` — the four-voice harmonizer: a mint floor +//! unit (a custom outline at the pedals' height: 480 × 286, wider than the +//! BigBox class to hold its screen) whose dark screen carries the tuning, +//! key, scale, voicing and quantize bar; four voice columns (on, interval, +//! and two rows that follow the view tab); a view with two tabs — LEVEL/PAN +//! (a stem per voice and the dry signal) and DETUNE/DELAY (a handle per +//! voice, dragged across for detune and down for delay, the columns then +//! printing each voice's cents and milliseconds); and the unison, width, +//! tone and output knobs. MIDI and engage stomps at the corners, the name +//! between them. + +use frame::document::proto::{CornerRadii, NodeId, TextAlign}; +use frame::surface::kit::draw; +use frame::surface::proto::{ + Axis, Binding, ButtonMode, Condition, Control, ControlKind, HandleAxis, Orientation, Range, + SlotRole, Unit, ValueFormat, +}; +use frame::surface::widgets::Builder; + +use super::PLUGIN; +use super::parts::{self, Hand, PEDAL_H, knob, pedal, switch, word}; +use crate::face::{Face, canvas}; +use crate::reverb::ui; + +const NS: &str = "voices"; +const W: f64 = 480.0; +const PURPLE: &str = "#a066e8"; +const CYAN: &str = "#1fb6d6"; +const MAGENTA: &str = "#e21fb4"; +const TEXT: &str = "#e6e6e8"; +const DIM: &str = "#8a8a90"; +const VIEW: &str = "voices/view"; +const INTERVALS: [&str; 15] = [ + "-8TH", "-7TH", "-6TH", "-5TH", "-4TH", "-3RD", "-2ND", "UNISON", "+2ND", "+3RD", "+4TH", + "+5TH", "+6TH", "+7TH", "+8TH", +]; +const KEYS: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", +]; +const SCALES: [&str; 5] = [ + "Major", + "Natural Minor", + "Harmonic Minor", + "Dorian", + "Mixolydian", +]; +const VOICINGS: [&str; 5] = ["