diff --git a/.agents/skills/research/SKILL.md b/.agents/skills/research/SKILL.md new file mode 100644 index 0000000000..45548f9cd9 --- /dev/null +++ b/.agents/skills/research/SKILL.md @@ -0,0 +1,244 @@ +--- +name: research +description: > + Research upstream and peer projects to inform Grida's design and + implementation. Use when investigating how Chromium, Skia, Servo, + or peer canvas editors solve a problem before writing code. Covers + source-code exploration, research document authoring, and the + study-adapt-differ pattern used in .plan.md files. Relevant dirs: + docs/wg/research/, docs/wg/feat-2d/, crates/grida-canvas/. +--- + +# Code Research Skill + +Workflow for going from "how does X work?" to a documented, actionable +plan grounded in prior art from upstream and peer projects. + +## When to Use This Skill + +- Investigating how a browser engine solves a rendering/layout/compositing problem +- Looking up undocumented Skia API behavior +- Understanding CSS feature semantics before adding support +- Comparing canvas editor architectures (excalidraw, tldraw) +- Writing or extending `docs/wg/research/` documents or `.plan.md` files + +--- + +## How to Orient Yourself + +Before touching any external repo, check what Grida already knows. + +### 1. Check existing research + +```text +docs/wg/research/chromium/ # 15 docs covering the full compositor pipeline +├── index.md # START HERE — topic map +├── glossary.md ├── compositor-architecture.md +├── property-trees.md ├── render-surfaces.md +├── damage-tracking.md ├── paint-recording.md +├── tiling-and-rasterization.md ├── tiling-deep-dive.md +├── memory-and-priority.md ├── scheduler.md +├── interaction-and-quality.md +├── resolution-scaling-during-interaction.md +├── pinch-zoom-deep-dive.md └── effect-optimizations.md +``` + +### 2. Check plan documents + +Key plan docs with distilled research: + +- `docs/wg/feat-2d/zoom-compositor-strategy.plan.md` — Chromium pinch-zoom adaptation +- `docs/wg/feat-2d/renderer-rewrite.plan.md` — Chromium compositor mapping +- `docs/wg/feat-2d/optimization.md` — master optimization catalog + +### 3. Check code cross-references + +```sh +grep "chromium\|servo\|adapted from\|ported from\|based on" --include="*.rs" +``` + +Known citations: + +- `crates/grida-canvas/src/runtime/effect_tree.rs` — Chromium EffectTree/EffectNode +- `crates/csscascade/src/rcdom/mod.rs` — Servo html5ever rcdom +- `crates/csscascade/` — Servo style system (`style::servo::*`) +- `third_party/usvg/src/text/layout.rs` — Chromium font metrics +- `packages/grida-cmath/index.ts` — Snap.svg arc-to-cubic + +### Discovery queries + +| What you need | How to find it | +| ---------------------------- | ------------------------------------------------------------- | +| Existing research on a topic | `docs/wg/research/chromium/index.md` | +| Plan docs with external refs | `grep "Chromium\|servo\|upstream" docs/wg/**/*.plan.md` | +| Code that cites sources | `grep "based on\|adapted from\|ported from" --include="*.rs"` | +| Vendored third-party code | `ls third_party/` | +| Feature docs for a subsystem | `ls docs/wg/feat-*/` | + +--- + +## Reference Repositories + +### Graphics & Rendering + +| Repo | Lang | When to reference | Key paths | +| --------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | ----------------------------------------------------- | +| [chromium](https://github.com/chromium/chromium) | C++ | Skia usage, compositing, layer trees, paint scheduling, tiling, GPU resources | `cc/` `third_party/blink/renderer/` `components/viz/` | +| [skia](https://github.com/google/skia) | C++ | Undocumented API behavior, GPU internals, filter details | `src/gpu/` `src/core/` `src/effects/` | +| [rust-skia](https://github.com/rust-skia/rust-skia) | Rust | Rust binding ergonomics — our direct `skia-safe` dependency | `skia-safe/src/` | +| [resvg](https://github.com/linebender/resvg) | Rust | SVG rendering, path conversion, filter effects | `crates/resvg/src/` `crates/usvg/src/` | + +### Web Standards & CSS + +| Repo | Lang | When to reference | Key paths | +| --------------------------------------- | ---- | ------------------------------------------------------------------------- | ---------------------------------------- | +| [servo](https://github.com/servo/servo) | Rust | CSS layout, DOM, Rust browser-engine patterns. We vendor its style system | `components/style/` `components/layout/` | +| [stylo](https://github.com/servo/stylo) | Rust | CSS parsing and style resolution | `style/` | + +### Canvas Editor Peers + +| Repo | Lang | When to reference | Key paths | +| ------------------------------------------------------ | ---- | ---------------------------------------------------------- | -------------------------------------------------------------- | +| [excalidraw](https://github.com/excalidraw/excalidraw) | TS | Canvas API optimization, rendering heuristics, interaction | `packages/excalidraw/renderer/` `packages/excalidraw/element/` | +| [tldraw](https://github.com/tldraw/tldraw) | TS | CRDT state model, modular SDK, DOM/SVG canvas | `packages/editor/` `packages/store/` | + +### Searching large repos + +**Chromium** — use https://source.chromium.org/ for fast browsing. Narrow to: `cc/layers/` `cc/tiles/` `cc/trees/` (compositing), `cc/paint/` (recording), `cc/scheduler/` (frame scheduling). + +**Servo** — `components/style/properties/` (CSS props), `components/style/stylist.rs` (resolution), `components/layout/` (layout). + +**Skia** — headers in `include/core/` `include/effects/`, GPU in `src/gpu/ganesh/` (GL) or `src/gpu/graphite/` (Metal/Vulkan). + +--- + +## The Research Workflow + +### Step 1: Frame the question + +Write a specific, bounded question. Bad: "how does Chromium handle rendering?" Good: "how does Chromium decide which layers get their own composited surface?" + +### Step 2: Check existing knowledge + +1. Read `docs/wg/research/chromium/index.md` +2. `grep "" docs/wg/**/*.plan.md` +3. `grep "" --include="*.rs" crates/` +4. Read `docs/wg/feat-2d/optimization.md` + +If already documented, cite it and move on. + +### Step 3: Explore the source + +Use targeted searches — do not read entire codebases: + +```sh +grep "struct LayerTreeHost" --include="*.h" -r cc/ # find the owning type +grep "ShouldCreateRenderSurface" --include="*.cc" -r cc/ # find decision points +grep "kDefault.*Tile\|kMax.*Memory" --include="*.h" -r cc/ # find design constants +``` + +### Step 4: Extract findings + +For each finding, record: **what** (mechanism + file path), **why** (rationale), **constants** (thresholds/heuristics), **applicability** (maps to our architecture?). + +### Step 5: Document or apply + +| Scope | Action | +| ---------------------------- | --------------------------------------------- | +| Quick answer | Code comment citing the source | +| Reusable subsystem knowledge | Research doc in `docs/wg/research//` | +| New feature design | "Reference Approach" section in `.plan.md` | +| Confirming existing approach | Update the relevant research doc | + +--- + +## Writing Research Documents + +Docs live in `docs/wg/research//`. Create new subdirectories as needed (`servo/`, `skia/`). + +Every research document must contain: + +1. **Title and scope** — what subsystem, what questions it answers +2. **Source references** — upstream file paths (with commit hash if volatile) +3. **Architecture description** — how the subsystem works, with diagrams for pipelines +4. **Key data structures** — important types and relationships (use upstream names) +5. **Constants and heuristics** — magic numbers and their reasoning +6. **Relevance to Grida** — how this maps or doesn't map to our architecture + +**Conventions:** Use upstream terminology. Include short code excerpts (5-15 lines) with file path citations. Organize by concept, not by file. Update `index.md` when adding new docs. File names: lowercase, hyphenated, topic-descriptive. + +--- + +## Applying Research to Plan Documents + +Grida uses a **study-adapt-differ** pattern. Every `.plan.md` referencing external work needs three sections: + +### 1. "Reference Approach" (study) + +Describe how upstream solves it — cite the research doc, name types/algorithms, include key constants: + +```markdown +## Chromium's Approach (Reference) + +Chromium uses a CoverageIterator that walks tilings from highest to +lowest resolution. During pinch-zoom, TreePriority is set to +SMOOTHNESS_TAKES_PRIORITY to show stretched tiles rather than checkerboard. +See: `docs/wg/research/chromium/pinch-zoom-deep-dive.md` +``` + +### 2. "What We Borrow" (adapt) + +Mapping table from upstream concepts to our types: + +```markdown +| Chromium | Grida | Notes | +| ----------------------------- | ------------------------------- | ---------------------------- | +| Stale-tile GPU stretching | `LayerImage` reuse at old scale | Per-node instead of per-tile | +| Power-of-2 raster scale steps | `snap_to_power_of_two()` | Reduces re-rasterization | +``` + +### 3. "What We Do Differently" (differ) + +What we're NOT adopting and why. This prevents future contributors from "fixing" intentional divergences: + +```markdown +- **No multiple concurrent tilings.** We cache per-node, not per-tile. + WASM memory constraints make multi-tiling impractical. +- **No worker-thread rasterization.** Single-threaded WASM constraint. + We compensate with time-budgeted incremental re-raster. +``` + +--- + +## Pitfalls + +**Researching what's already documented.** Check `docs/wg/research/`, plan docs, and code comments first. The Chromium research alone is 15 documents. + +**Cargo-culting without understanding constraints.** Always filter upstream approaches through our constraints: + +- Single thread (WASM) +- Per-node cache (not spatial tiles) +- Infinite canvas (viewport culling is primary) +- Stable scene graph (no CSS reflow on zoom) + +**Reading too broadly.** Chromium is 30M+ lines. Arrive with a specific question, find the code path, extract the answer, leave. Use source.chromium.org. + +**Skipping the "differ" section.** Most dangerous omission. Without it, future contributors will "fix" intentional divergences from Chromium. + +**Confusing Skia docs with Skia behavior.** Skia's documentation is minimal and sometimes wrong. Read the implementation for performance characteristics and edge cases. + +**Stale source references.** Reference stable concepts (struct names, enum variants) over line numbers. Include enough context to relocate if files move. + +**Mixing terminologies.** Research docs: upstream terms. Plan docs: mapping tables. Code comments: our terms with parenthetical upstream reference. + +--- + +## Checklist + +- [ ] Framed a specific, bounded question +- [ ] Checked existing research, plan docs, and code comments +- [ ] Identified the right repo and narrowed to specific directories +- [ ] Extracted findings with file paths, rationale, and constants +- [ ] Assessed applicability against our constraints +- [ ] Documented findings (research doc, plan section, or code comment) +- [ ] Updated `index.md` if a new research doc was created diff --git a/crates/grida-canvas/AGENTS.md b/crates/grida-canvas/AGENTS.md index db32a9c461..2b813e33d4 100644 --- a/crates/grida-canvas/AGENTS.md +++ b/crates/grida-canvas/AGENTS.md @@ -63,25 +63,37 @@ cargo run -p cg --example headless_gpu --features native-gl-context --release ## Tools -### `tool_io_grida` - Grida File Validator +### `tool_io_grida` — Grida File Inspector -A CLI tool for validating `.grida` files and debugging parsing issues. +Unified CLI tool for inspecting and validating `.grida` / `.grida1` files +in any format (FlatBuffers, ZIP, or legacy JSON). Includes a layout-engine +check to diagnose "Container must have layout result" panics. **Usage:** ```sh -cargo run --example tool_io_grida +# Basic inspection (auto-detects format) +cargo run --example tool_io_grida -- path/to/file.grida + +# List scenes (FBS/ZIP multi-scene files) +cargo run --example tool_io_grida -- path/to/file.grida --list-scenes + +# Inspect a specific scene with full node tree +cargo run --example tool_io_grida -- path/to/file.grida --scene 0 --verbose + +# Run layout check (detect containers missing layout results) +cargo run --example tool_io_grida -- path/to/file.grida --layout-check ``` **Features:** -- Validates `.grida` file structure and parses all nodes -- Reports total node count, scene references, and entry scene -- Provides node type breakdown (container, text, image, etc.) -- Detects parsing errors with detailed error messages -- Handles legacy file formats gracefully (missing fields, typos, etc.) - -**Example:** +- Auto-detects file format (FlatBuffers, ZIP archive, JSON) +- Validates file structure and parses all nodes +- Reports per-scene node counts and type breakdown +- Shows ID mapping info for FBS/ZIP files (internal NodeId to string ID) +- `--layout-check`: runs the layout engine and reports containers missing layout results +- `--verbose`: prints the full node tree with string IDs +- Handles legacy JSON formats gracefully (missing fields, typos, etc.) See [examples/tool_io_grida.rs](./examples/tool_io_grida.rs) for full documentation. diff --git a/crates/grida-canvas/Cargo.toml b/crates/grida-canvas/Cargo.toml index 941f3c89ef..7b89c94375 100644 --- a/crates/grida-canvas/Cargo.toml +++ b/crates/grida-canvas/Cargo.toml @@ -42,6 +42,12 @@ usvg = { path = "../../third_party/usvg" } # (+0.25mb wasm32-unknown-emscripten@opt-level=3) pulldown-cmark = "0.13.0" +# .grida ZIP file support +# "deflate" enables both read (flate2) and write (zopfli) paths; there is no +# read-only feature in zip v2. Adds ~3 small crates (zopfli, bumpalo, +# simd-adler32) — all pure Rust, wasm-safe, and eliminated by LTO if unused. +zip = { version = "2", default-features = false, features = ["deflate"] } + # toolings async-trait = "0.1" futures = "0.3.31" @@ -133,3 +139,7 @@ path = "examples/skia_bench/skia_bench_cache_image.rs" name = "skia_bench_cache_text" path = "examples/skia_bench/skia_bench_cache_text.rs" +# ── IO tools ───────────────────────────────────────────────────── +[[example]] +name = "tool_io_grida" + diff --git a/crates/grida-canvas/cover.png b/crates/grida-canvas/cover.png index 5e6146d33e..c227b191b2 100644 Binary files a/crates/grida-canvas/cover.png and b/crates/grida-canvas/cover.png differ diff --git a/crates/grida-canvas/examples/fixtures/README.md b/crates/grida-canvas/examples/fixtures/README.md index 5d9ae9ea97..a81d8ef141 100644 --- a/crates/grida-canvas/examples/fixtures/README.md +++ b/crates/grida-canvas/examples/fixtures/README.md @@ -219,6 +219,39 @@ over a stripe pattern (same approach as `golden_liquid_glass.rs`). --- +## L0-effects-progressive-blur + +Progressive blur (gradient-varying blur radius) as both layer blur and backdrop blur. + +```text +scene "L0 Effects Progressive Blur" +├─ rectangle 200×200 layer progressive blur: top-left clear → bottom-right blurred (radius 0→20) +├─ rectangle 200×200 layer progressive blur: top clear → bottom blurred (radius 0→30) +├─ rectangle 200×200 layer progressive blur: left→right, both ends blurred (radius 5→25) +└─ rectangle 200×200 backdrop progressive blur: center clear → edges blurred (radius 0→15) +``` + +**Exercises:** FeProgressiveBlur (start, end, radius, radius2), FeLayerBlur with Progressive variant, FeBackdropBlur with Progressive variant, varying gradient directions. + +--- + +## L0-strokes-varwidth + +Variable-width stroke profiles on vector paths with different taper and width patterns. + +```text +scene "L0 Strokes VarWidth" +├─ vector S-curve, taper profile (thick start → thin end, 2 stops) +├─ vector S-curve, reverse taper (thin start → thick end, 2 stops) +├─ vector S-curve, bulge profile (thin → thick → thin, 3 stops) +├─ vector S-curve, multi-stop profile (irregular widths, 4 stops) +└─ vector S-curve, uniform profile (constant width, 2 stops) +``` + +**Exercises:** VarWidthProfile (base, stops), WidthStop (u, r), taper, reverse taper, bulge, multi-stop, uniform fallback. + +--- + ## L0-type Core typography: font sizes, weights, alignment, decoration, spacing, transforms, stroke, effects. @@ -351,16 +384,31 @@ scene "L0 Layout Flex" ## L0-layout-transform -Rotation and affine transforms on containers and shapes. +Comprehensive affine transform coverage: rotations at key angles, custom transform origins, nested container+child composition, and non-rectangular shape rotations. ```text scene "L0 Layout Transform" -├─ container 200×150 rotated 90°, Cartesian at (300, 50), blue fill -├─ rectangle 120×80 rotated 45°, at (50, 50), red fill -└─ line 200px rotated 45°, at (400, 200), black stroke 2px +├─ rectangle 100×60 rotation=0° (identity baseline) +├─ rectangle 100×60 rotation=15° (small angle) +├─ rectangle 100×60 rotation=45° +├─ rectangle 100×60 rotation=90° +├─ rectangle 100×60 rotation=180° (flip) +├─ rectangle 100×60 rotation=270° (≡ -90°) +├─ ellipse 120×60 rotation=30° +├─ line 150px rotation=60° +├─ line 150px rotation=330° (≡ -30°) +├─ rectangle 100×60 rotation=45°, origin=CENTER (default) +├─ rectangle 100×60 rotation=45°, origin=TOP_LEFT (0,0) +├─ rectangle 100×60 rotation=45°, origin=BOTTOM_RIGHT (1,1) +├─ container 200×120 rotation=90°, Cartesian position +│ └─ rectangle 80×50 child (inherits parent rotation) +├─ container 200×120 rotation=30°, Cartesian position +│ └─ rectangle 80×50 rotation=45° (world rotation=75°) +└─ container 120×120 rotation=45°, Inset position, corner_radius=12, clip=true + └─ rectangle 100×100 child (clipped by rotated container) ``` -**Exercises:** Container rotation, shape rotation via AffineTransform, line rotation. +**Exercises:** Rotation at 0/15/45/90/180/270°, ellipse rotation, line rotation (positive and negative angles), custom transform origin via `from_box()` (center, top-left, bottom-right), container rotation with child (inherited transform), nested rotation composition (30°+45°=75°), rotated container with Inset positioning, clipped rotated container. --- diff --git a/crates/grida-canvas/examples/fixtures/cover.rs b/crates/grida-canvas/examples/fixtures/cover.rs new file mode 100644 index 0000000000..72c82ac1eb --- /dev/null +++ b/crates/grida-canvas/examples/fixtures/cover.rs @@ -0,0 +1,530 @@ +//! Cover image scene builder. +//! +//! Builds a visually rich 1600×900 composition that showcases the cg engine's +//! capabilities: multi-stop gradients, corner smoothing, blend modes, drop and +//! inner shadows, noise grain, sweep gradients, and typography — composed into +//! something that looks like a premium design-tool hero image. + +use super::*; +use cg::cg::color::CGColor; +use cg::cg::fe::*; +use cg::cg::stroke_width::SingularStrokeWidth; + +// ═══════════════════════════════════════════════════════════════════════════ +// Palette +// ═══════════════════════════════════════════════════════════════════════════ + +const BG: CGColor = CGColor { r: 14, g: 14, b: 18, a: 255 }; + +// Deep purples / blues / ambers +const PURPLE_DEEP: CGColor = CGColor { r: 88, g: 28, b: 180, a: 255 }; +const PURPLE_LIGHT: CGColor = CGColor { r: 168, g: 85, b: 247, a: 255 }; +const BLUE_ELECTRIC: CGColor = CGColor { r: 56, g: 130, b: 255, a: 255 }; +const BLUE_DEEP: CGColor = CGColor { r: 30, g: 64, b: 175, a: 255 }; +const CYAN_GLOW: CGColor = CGColor { r: 34, g: 211, b: 238, a: 255 }; +const AMBER: CGColor = CGColor { r: 245, g: 158, b: 11, a: 255 }; +const AMBER_WARM: CGColor = CGColor { r: 251, g: 191, b: 36, a: 255 }; +const ROSE: CGColor = CGColor { r: 244, g: 63, b: 94, a: 255 }; +const WHITE_DIM: CGColor = CGColor { r: 255, g: 255, b: 255, a: 18 }; +const WHITE_FAINT: CGColor = CGColor { r: 255, g: 255, b: 255, a: 8 }; + +// ═══════════════════════════════════════════════════════════════════════════ +// Scene +// ═══════════════════════════════════════════════════════════════════════════ + +pub const WIDTH: f32 = 1600.0; +pub const HEIGHT: f32 = 900.0; + +pub fn build() -> Scene { + let mut nodes: Vec<(NodeId, Node)> = Vec::new(); + let links: std::collections::HashMap> = std::collections::HashMap::new(); + let mut id = 0u64; + let mut next_id = || { id += 1; id }; + + // ── 1. Background fill ────────────────────────────────────────────── + let bg_id = next_id(); + nodes.push((bg_id, rect(0.0, 0.0, WIDTH, HEIGHT, Paint::Solid(SolidPaint { + active: true, + color: BG, + blend_mode: BlendMode::Normal, + })))); + + // ── 2. Large ambient glow — radial gradient, bottom-left ──────────── + let glow1_id = next_id(); + nodes.push((glow1_id, Node::Ellipse(EllipseNodeRec { + active: true, + opacity: 0.55, + blend_mode: LayerBlendMode::Blend(BlendMode::Screen), + mask: None, + transform: AffineTransform::from_box_center(-200.0, 300.0, 1100.0, 1100.0, 0.0), + size: Size { width: 1100.0, height: 1100.0 }, + fills: Paints::new(vec![Paint::RadialGradient(RadialGradientPaint { + active: true, + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: PURPLE_DEEP }, + GradientStop { offset: 0.5, color: CGColor { r: 88, g: 28, b: 180, a: 120 } }, + GradientStop { offset: 1.0, color: CGColor { r: 88, g: 28, b: 180, a: 0 } }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + tile_mode: TileMode::default(), + })]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + inner_radius: None, + start_angle: 0.0, + angle: None, + corner_radius: None, + effects: LayerEffects::default(), + layout_child: None, + }))); + + // ── 3. Ambient glow — top-right, blue ─────────────────────────────── + let glow2_id = next_id(); + nodes.push((glow2_id, Node::Ellipse(EllipseNodeRec { + active: true, + opacity: 0.40, + blend_mode: LayerBlendMode::Blend(BlendMode::Screen), + mask: None, + transform: AffineTransform::from_box_center(900.0, -300.0, 1000.0, 1000.0, 0.0), + size: Size { width: 1000.0, height: 1000.0 }, + fills: Paints::new(vec![Paint::RadialGradient(RadialGradientPaint { + active: true, + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: BLUE_ELECTRIC }, + GradientStop { offset: 0.55, color: CGColor { r: 56, g: 130, b: 255, a: 80 } }, + GradientStop { offset: 1.0, color: CGColor { r: 56, g: 130, b: 255, a: 0 } }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + tile_mode: TileMode::default(), + })]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + inner_radius: None, + start_angle: 0.0, + angle: None, + corner_radius: None, + effects: LayerEffects::default(), + layout_child: None, + }))); + + // ── 4. Hero rounded rectangle — purple→blue linear gradient ───────── + // with corner smoothing (G2), drop shadow, inner shadow + let hero_id = next_id(); + nodes.push((hero_id, Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box_center(180.0, 160.0, 520.0, 580.0, 0.0), + size: Size { width: 520.0, height: 580.0 }, + corner_radius: RectangularCornerRadius::circular(48.0), + corner_smoothing: CornerSmoothing(0.6), + fills: Paints::new(vec![ + Paint::LinearGradient(LinearGradientPaint { + active: true, + xy1: Alignment(-1.0, -1.0), // top-left + xy2: Alignment(1.0, 1.0), // bottom-right + tile_mode: TileMode::default(), + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: PURPLE_DEEP }, + GradientStop { offset: 0.45, color: BLUE_DEEP }, + GradientStop { offset: 1.0, color: BLUE_ELECTRIC }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + }), + ]), + strokes: Paints::new(vec![Paint::Solid(SolidPaint { + active: true, + color: CGColor { r: 255, g: 255, b: 255, a: 15 }, + blend_mode: BlendMode::Normal, + })]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects { + shadows: vec![ + FilterShadowEffect::DropShadow(FeShadow { + dx: 0.0, dy: 24.0, blur: 80.0, spread: -8.0, + color: CGColor { r: 0, g: 0, b: 0, a: 160 }, + active: true, + }), + FilterShadowEffect::InnerShadow(FeShadow { + dx: 0.0, dy: 2.0, blur: 40.0, spread: 0.0, + color: CGColor { r: 255, g: 255, b: 255, a: 30 }, + active: true, + }), + ], + ..LayerEffects::default() + }, + layout_child: None, + }))); + + // ── 5. Second card — overlapping, amber→rose gradient, rotated ────── + let card2_id = next_id(); + nodes.push((card2_id, Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 0.90, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box_center(520.0, 240.0, 440.0, 520.0, -6.0), + size: Size { width: 440.0, height: 520.0 }, + corner_radius: RectangularCornerRadius::circular(40.0), + corner_smoothing: CornerSmoothing(0.6), + fills: Paints::new(vec![ + Paint::LinearGradient(LinearGradientPaint { + active: true, + xy1: Alignment(0.0, -1.0), // top-center + xy2: Alignment(0.0, 1.0), // bottom-center + tile_mode: TileMode::default(), + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: AMBER }, + GradientStop { offset: 0.6, color: ROSE }, + GradientStop { offset: 1.0, color: PURPLE_DEEP }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + }), + ]), + strokes: Paints::new(vec![Paint::Solid(SolidPaint { + active: true, + color: CGColor { r: 255, g: 255, b: 255, a: 12 }, + blend_mode: BlendMode::Normal, + })]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects { + shadows: vec![ + FilterShadowEffect::DropShadow(FeShadow { + dx: 0.0, dy: 16.0, blur: 64.0, spread: -4.0, + color: CGColor { r: 0, g: 0, b: 0, a: 140 }, + active: true, + }), + FilterShadowEffect::InnerShadow(FeShadow { + dx: 0.0, dy: 1.0, blur: 24.0, spread: 0.0, + color: CGColor { r: 255, g: 255, b: 255, a: 25 }, + active: true, + }), + ], + ..LayerEffects::default() + }, + layout_child: None, + }))); + + // ── 6. Third card — smaller, cyan→blue, further right ─────────────── + let card3_id = next_id(); + nodes.push((card3_id, Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 0.85, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box_center(880.0, 120.0, 380.0, 460.0, 4.0), + size: Size { width: 380.0, height: 460.0 }, + corner_radius: RectangularCornerRadius::circular(36.0), + corner_smoothing: CornerSmoothing(0.6), + fills: Paints::new(vec![ + Paint::LinearGradient(LinearGradientPaint { + active: true, + xy1: Alignment(-1.0, 0.0), + xy2: Alignment(1.0, 0.0), + tile_mode: TileMode::default(), + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: CYAN_GLOW }, + GradientStop { offset: 0.5, color: BLUE_ELECTRIC }, + GradientStop { offset: 1.0, color: BLUE_DEEP }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + }), + ]), + strokes: Paints::new(vec![Paint::Solid(SolidPaint { + active: true, + color: CGColor { r: 255, g: 255, b: 255, a: 10 }, + blend_mode: BlendMode::Normal, + })]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects { + shadows: vec![ + FilterShadowEffect::DropShadow(FeShadow { + dx: 0.0, dy: 20.0, blur: 60.0, spread: -6.0, + color: CGColor { r: 0, g: 0, b: 0, a: 150 }, + active: true, + }), + ], + ..LayerEffects::default() + }, + layout_child: None, + }))); + + // ── 7. Sweep gradient circle — accent, top-right area ─────────────── + let sweep_id = next_id(); + nodes.push((sweep_id, Node::Ellipse(EllipseNodeRec { + active: true, + opacity: 0.75, + blend_mode: LayerBlendMode::Blend(BlendMode::Screen), + mask: None, + transform: AffineTransform::from_box_center(1100.0, 80.0, 340.0, 340.0, 0.0), + size: Size { width: 340.0, height: 340.0 }, + fills: Paints::new(vec![Paint::SweepGradient(SweepGradientPaint { + active: true, + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: AMBER_WARM }, + GradientStop { offset: 0.25, color: ROSE }, + GradientStop { offset: 0.5, color: PURPLE_LIGHT }, + GradientStop { offset: 0.75, color: BLUE_ELECTRIC }, + GradientStop { offset: 1.0, color: AMBER_WARM }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + })]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + inner_radius: None, + start_angle: 0.0, + angle: None, + corner_radius: None, + effects: LayerEffects { + shadows: vec![ + FilterShadowEffect::DropShadow(FeShadow { + dx: 0.0, dy: 8.0, blur: 48.0, spread: 0.0, + color: CGColor { r: 245, g: 158, b: 11, a: 100 }, + active: true, + }), + ], + ..LayerEffects::default() + }, + layout_child: None, + }))); + + // ── 8. Small floating hexagon — bottom-right ──────────────────────── + let hex_id = next_id(); + nodes.push((hex_id, Node::RegularPolygon(RegularPolygonNodeRec { + active: true, + opacity: 0.5, + blend_mode: LayerBlendMode::Blend(BlendMode::Screen), + mask: None, + effects: LayerEffects::default(), + transform: AffineTransform::from_box_center(1300.0, 560.0, 180.0, 180.0, 15.0), + size: Size { width: 180.0, height: 180.0 }, + point_count: 6, + corner_radius: 12.0, + fills: Paints::new(vec![Paint::LinearGradient(LinearGradientPaint { + active: true, + xy1: Alignment(-1.0, -1.0), + xy2: Alignment(1.0, 1.0), + tile_mode: TileMode::default(), + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: PURPLE_LIGHT }, + GradientStop { offset: 1.0, color: BLUE_ELECTRIC }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + })]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + layout_child: None, + }))); + + // ── 9. 4-point star — small accent, left side ─────────────────────── + let star_id = next_id(); + nodes.push((star_id, Node::RegularStarPolygon(RegularStarPolygonNodeRec { + active: true, + opacity: 0.35, + blend_mode: LayerBlendMode::Blend(BlendMode::Screen), + mask: None, + effects: LayerEffects::default(), + transform: AffineTransform::from_box_center(80.0, 680.0, 120.0, 120.0, 22.0), + size: Size { width: 120.0, height: 120.0 }, + point_count: 4, + inner_radius: 0.35, + corner_radius: 4.0, + fills: Paints::new(vec![Paint::Solid(SolidPaint { + active: true, + color: AMBER_WARM, + blend_mode: BlendMode::Normal, + })]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + layout_child: None, + }))); + + // ── 10. Thin decorative lines — horizontal rule accents ───────────── + let line1_id = next_id(); + nodes.push((line1_id, rect(120.0, 800.0, 400.0, 1.0, Paint::Solid(SolidPaint { + active: true, + color: WHITE_DIM, + blend_mode: BlendMode::Normal, + })))); + + let line2_id = next_id(); + nodes.push((line2_id, rect(1080.0, 800.0, 400.0, 1.0, Paint::Solid(SolidPaint { + active: true, + color: WHITE_FAINT, + blend_mode: BlendMode::Normal, + })))); + + // ── 11. "cg" typography — large, with gradient fill ───────────────── + let text_id = next_id(); + nodes.push((text_id, Node::TextSpan(TextSpanNodeRec { + active: true, + transform: AffineTransform::new(120.0, 790.0, 0.0), + width: None, + height: None, + layout_child: None, + text: "cg".to_owned(), + text_style: { + let mut ts = TextStyleRec::from_font("Geist", 72.0); + ts.font_weight = FontWeight(800); + ts.letter_spacing = TextLetterSpacing::Fixed(-2.0); + ts + }, + text_align: TextAlign::Left, + text_align_vertical: TextAlignVertical::Top, + max_lines: None, + ellipsis: None, + fills: Paints::new(vec![ + Paint::LinearGradient(LinearGradientPaint { + active: true, + xy1: Alignment(-1.0, 0.0), + xy2: Alignment(1.0, 0.0), + tile_mode: TileMode::default(), + transform: AffineTransform::default(), + stops: vec![ + GradientStop { offset: 0.0, color: CGColor { r: 255, g: 255, b: 255, a: 180 } }, + GradientStop { offset: 1.0, color: CGColor { r: 255, g: 255, b: 255, a: 60 } }, + ], + opacity: 1.0, + blend_mode: BlendMode::Normal, + }), + ]), + strokes: Paints::new(vec![]), + stroke_width: 0.0, + stroke_align: StrokeAlign::Center, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + effects: LayerEffects::default(), + }))); + + // ── 12. Subtitle text ─────────────────────────────────────────────── + let sub_id = next_id(); + nodes.push((sub_id, Node::TextSpan(TextSpanNodeRec { + active: true, + transform: AffineTransform::new(240.0, 815.0, 0.0), + width: None, + height: None, + layout_child: None, + text: "grida canvas".to_owned(), + text_style: { + let mut ts = TextStyleRec::from_font("Geist", 20.0); + ts.font_weight = FontWeight(400); + ts.letter_spacing = TextLetterSpacing::Fixed(6.0); + ts.text_transform = TextTransform::Uppercase; + ts + }, + text_align: TextAlign::Left, + text_align_vertical: TextAlignVertical::Top, + max_lines: None, + ellipsis: None, + fills: Paints::new(vec![Paint::Solid(SolidPaint { + active: true, + color: CGColor { r: 255, g: 255, b: 255, a: 80 }, + blend_mode: BlendMode::Normal, + })]), + strokes: Paints::new(vec![]), + stroke_width: 0.0, + stroke_align: StrokeAlign::Center, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + effects: LayerEffects::default(), + }))); + + // ── 13. Floating small circles (decorative particles) ─────────────── + let particle_positions: &[(f32, f32, f32, f32)] = &[ + // (x, y, size, opacity) + (350.0, 100.0, 8.0, 0.4), + (750.0, 50.0, 6.0, 0.3), + (1050.0, 500.0, 10.0, 0.25), + (200.0, 500.0, 5.0, 0.35), + (1400.0, 200.0, 7.0, 0.3), + (600.0, 700.0, 9.0, 0.2), + (1350.0, 750.0, 6.0, 0.25), + ]; + + for &(px, py, ps, po) in particle_positions { + let pid = next_id(); + nodes.push((pid, Node::Ellipse(EllipseNodeRec { + active: true, + opacity: po, + blend_mode: LayerBlendMode::Blend(BlendMode::Screen), + mask: None, + transform: AffineTransform::from_box_center(px, py, ps, ps, 0.0), + size: Size { width: ps, height: ps }, + fills: Paints::new(vec![Paint::Solid(SolidPaint { + active: true, + color: CGColor { r: 255, g: 255, b: 255, a: 255 }, + blend_mode: BlendMode::Normal, + })]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + inner_radius: None, + start_angle: 0.0, + angle: None, + corner_radius: None, + effects: LayerEffects::default(), + layout_child: None, + }))); + } + + // ── 14. Full-canvas noise grain overlay ────────────────────────────── + let noise_id = next_id(); + nodes.push((noise_id, Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box_center(0.0, 0.0, WIDTH, HEIGHT, 0.0), + size: Size { width: WIDTH, height: HEIGHT }, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![]), // transparent — only noise + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::None, + effects: LayerEffects { + noises: vec![FeNoiseEffect { + active: true, + noise_size: 1.5, + density: 0.35, + num_octaves: 3, + seed: 7.0, + coloring: NoiseEffectColors::Mono { + color: CGColor { r: 255, g: 255, b: 255, a: 18 }, + }, + blend_mode: BlendMode::Normal, + }], + ..LayerEffects::default() + }, + layout_child: None, + }))); + + // ── Assemble ──────────────────────────────────────────────────────── + let roots: Vec = nodes.iter().map(|(nid, _)| *nid).collect(); + build_scene("Cover", Some(BG), nodes, links, roots) +} diff --git a/crates/grida-canvas/examples/fixtures/l0_effects_progressive_blur.rs b/crates/grida-canvas/examples/fixtures/l0_effects_progressive_blur.rs new file mode 100644 index 0000000000..72f1eb463e --- /dev/null +++ b/crates/grida-canvas/examples/fixtures/l0_effects_progressive_blur.rs @@ -0,0 +1,105 @@ +use super::*; +use cg::cg::fe::*; + +fn effect_rect(x: f32, effects: LayerEffects) -> Node { + Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box_center(x, 0.0, 200.0, 200.0, 0.0), + size: Size { width: 200.0, height: 200.0 }, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![solid(180, 180, 180, 255)]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::None, + effects, + layout_child: None, + }) +} + +pub fn build() -> Scene { + let gap = 220.0; + + // Layer progressive blur: top-left clear → bottom-right blurred + let layer_tl_br = effect_rect(0.0, LayerEffects { + blur: Some(FeLayerBlur { + active: true, + blur: FeBlur::Progressive(FeProgressiveBlur { + start: Alignment::TOP_LEFT, + end: Alignment::BOTTOM_RIGHT, + radius: 0.0, + radius2: 20.0, + }), + }), + ..LayerEffects::default() + }); + + // Layer progressive blur: top clear → bottom blurred (vertical gradient) + let layer_top_bottom = effect_rect(gap, LayerEffects { + blur: Some(FeLayerBlur { + active: true, + blur: FeBlur::Progressive(FeProgressiveBlur { + start: Alignment::TOP_CENTER, + end: Alignment::BOTTOM_CENTER, + radius: 0.0, + radius2: 30.0, + }), + }), + ..LayerEffects::default() + }); + + // Layer progressive blur: both ends blurred (non-zero start radius) + let layer_both = effect_rect(gap * 2.0, LayerEffects { + blur: Some(FeLayerBlur { + active: true, + blur: FeBlur::Progressive(FeProgressiveBlur { + start: Alignment::CENTER_LEFT, + end: Alignment::CENTER_RIGHT, + radius: 5.0, + radius2: 25.0, + }), + }), + ..LayerEffects::default() + }); + + // Background content behind the backdrop card so FeBackdropBlur samples + // real pixels. Uses a contrasting fill to make the blur visually apparent. + let backdrop_bg = Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box_center(gap * 3.0, 0.0, 200.0, 200.0, 0.0), + size: Size { width: 200.0, height: 200.0 }, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![solid(220, 59, 59, 255)]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::None, + effects: LayerEffects::default(), + layout_child: None, + }); + + // Backdrop progressive blur: center clear → edges blurred + let backdrop = effect_rect(gap * 3.0, LayerEffects { + backdrop_blur: Some(FeBackdropBlur { + active: true, + blur: FeBlur::Progressive(FeProgressiveBlur { + start: Alignment::CENTER, + end: Alignment::BOTTOM_RIGHT, + radius: 0.0, + radius2: 15.0, + }), + }), + ..LayerEffects::default() + }); + + flat_scene( + "L0 Effects Progressive Blur", + vec![layer_tl_br, layer_top_bottom, layer_both, backdrop_bg, backdrop], + ) +} diff --git a/crates/grida-canvas/examples/fixtures/l0_layout_transform.rs b/crates/grida-canvas/examples/fixtures/l0_layout_transform.rs index 8aee256c0a..38fcf91a7c 100644 --- a/crates/grida-canvas/examples/fixtures/l0_layout_transform.rs +++ b/crates/grida-canvas/examples/fixtures/l0_layout_transform.rs @@ -1,18 +1,129 @@ use super::*; +use std::collections::HashMap; +/// Comprehensive affine transform coverage: rotations at various angles, +/// custom transform origins, nested transform composition, non-rectangular +/// shape rotations, and flips (180°). pub fn build() -> Scene { - // Container rotated 90°, Cartesian position - let rotated_container = Node::Container(ContainerNodeRec { + // ── Row 1: Shape rotations at key angles ──────────────────────────── + + // [1] Rectangle 0° (identity — baseline reference) + let r_0 = rect_rotated(0.0, 0.0, 100.0, 60.0, 0.0, solid(200, 200, 200, 255)); + + // [2] Rectangle 15° (small rotation) + let r_15 = rect_rotated(130.0, 0.0, 100.0, 60.0, 15.0, solid(220, 59, 59, 255)); + + // [3] Rectangle 45° + let r_45 = rect_rotated(260.0, 0.0, 100.0, 60.0, 45.0, solid(59, 100, 220, 255)); + + // [4] Rectangle 90° + let r_90 = rect_rotated(390.0, 0.0, 100.0, 60.0, 90.0, solid(59, 180, 75, 255)); + + // [5] Rectangle 180° (flip) + let r_180 = rect_rotated(520.0, 0.0, 100.0, 60.0, 180.0, solid(255, 200, 40, 255)); + + // [6] Rectangle 270° (equivalent to -90°) + let r_270 = rect_rotated(650.0, 0.0, 100.0, 60.0, 270.0, solid(128, 60, 200, 255)); + + // ── Row 2: Non-rectangular shapes rotated ─────────────────────────── + + // [7] Ellipse rotated 30° + let e_30 = Node::Ellipse(EllipseNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box_center(0.0, 100.0, 120.0, 60.0, 30.0), + size: Size { width: 120.0, height: 60.0 }, + fills: Paints::new(vec![solid(59, 100, 220, 200)]), + strokes: Paints::new(vec![]), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + inner_radius: None, + start_angle: 0.0, + angle: None, + corner_radius: None, + effects: LayerEffects::default(), + layout_child: None, + }); + + // [8] Line rotated 60° + let l_60 = line(200.0, 100.0, 150.0, 60.0, 2.0); + + // [9] Line rotated -30° (330°) + let l_neg30 = line(400.0, 100.0, 150.0, 330.0, 2.0); + + // ── Row 3: Custom transform origin ────────────────────────────────── + + // [10] Rectangle rotated 45° around CENTER (default) — for comparison + let r_center_origin = Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + // from_box_center uses origin (0.5, 0.5) + transform: AffineTransform::from_box_center(0.0, 220.0, 100.0, 60.0, 45.0), + size: Size { width: 100.0, height: 60.0 }, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![solid(220, 59, 59, 180)]), + strokes: Paints::new(vec![solid(0, 0, 0, 255)]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects::default(), + layout_child: None, + }); + + // [11] Rectangle rotated 45° around TOP-LEFT origin (0, 0) + let r_tl_origin = Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box(180.0, 220.0, 100.0, 60.0, 45.0, 0.0, 0.0), + size: Size { width: 100.0, height: 60.0 }, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![solid(59, 100, 220, 180)]), + strokes: Paints::new(vec![solid(0, 0, 0, 255)]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects::default(), + layout_child: None, + }); + + // [12] Rectangle rotated 45° around BOTTOM-RIGHT origin (1, 1) + let r_br_origin = Node::Rectangle(RectangleNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + transform: AffineTransform::from_box(360.0, 220.0, 100.0, 60.0, 45.0, 1.0, 1.0), + size: Size { width: 100.0, height: 60.0 }, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![solid(59, 180, 75, 180)]), + strokes: Paints::new(vec![solid(0, 0, 0, 255)]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects::default(), + layout_child: None, + }); + + // ── Row 4: Container rotation + rotated container with child ──────── + + // [13] Container rotated 90°, Cartesian position, with a child rect + let container_90 = Node::Container(ContainerNodeRec { active: true, opacity: 1.0, blend_mode: LayerBlendMode::PassThrough, mask: None, rotation: 90.0, - position: LayoutPositioningBasis::Cartesian(CGPoint::new(300.0, 50.0)), + position: LayoutPositioningBasis::Cartesian(CGPoint::new(0.0, 360.0)), layout_container: LayoutContainerStyle::default(), layout_dimensions: LayoutDimensionStyle { layout_target_width: Some(200.0), - layout_target_height: Some(150.0), + layout_target_height: Some(120.0), layout_min_width: None, layout_max_width: None, layout_min_height: None, layout_max_height: None, layout_target_aspect_ratio: None, @@ -20,19 +131,121 @@ pub fn build() -> Scene { layout_child: None, corner_radius: RectangularCornerRadius::default(), corner_smoothing: CornerSmoothing(0.0), - fills: Paints::new(vec![solid(59, 100, 220, 255)]), - strokes: Paints::new(vec![]), + fills: Paints::new(vec![solid(59, 100, 220, 80)]), + strokes: Paints::new(vec![solid(0, 0, 0, 255)]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects::default(), + clip: false, + }); + + // [14] Child rect inside rotated container (inherits parent rotation) + let child_in_90 = rect(10.0, 10.0, 80.0, 50.0, solid(220, 59, 59, 255)); + + // ── Row 4 continued: Nested rotation (container 30° + child 45°) ──── + + // [15] Container rotated 30° + let container_30 = Node::Container(ContainerNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + rotation: 30.0, + position: LayoutPositioningBasis::Cartesian(CGPoint::new(300.0, 360.0)), + layout_container: LayoutContainerStyle::default(), + layout_dimensions: LayoutDimensionStyle { + layout_target_width: Some(200.0), + layout_target_height: Some(120.0), + layout_min_width: None, layout_max_width: None, + layout_min_height: None, layout_max_height: None, + layout_target_aspect_ratio: None, + }, + layout_child: None, + corner_radius: RectangularCornerRadius::default(), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![solid(59, 180, 75, 80)]), + strokes: Paints::new(vec![solid(0, 0, 0, 255)]), stroke_style: StrokeStyle::default(), - stroke_width: StrokeWidth::None, + stroke_width: StrokeWidth::Uniform(1.0), effects: LayerEffects::default(), clip: false, }); - // Rectangle rotated 45° - let rotated_rect = rect_rotated(50.0, 50.0, 120.0, 80.0, 45.0, solid(220, 59, 59, 255)); + // [16] Child rect rotated 45° inside the 30°-rotated container + // World rotation = 30° + 45° = 75° + let child_rotated_in_30 = rect_rotated(20.0, 20.0, 80.0, 50.0, 45.0, solid(128, 60, 200, 255)); + + // ── Row 4 continued: Container with Inset position, rotated 45° ───── + + // [17] Container rotated 45° with Inset position + let container_45_inset = Node::Container(ContainerNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + rotation: 45.0, + position: LayoutPositioningBasis::Inset(EdgeInsets { + top: 360.0, right: 0.0, bottom: 0.0, left: 600.0, + }), + layout_container: LayoutContainerStyle::default(), + layout_dimensions: LayoutDimensionStyle { + layout_target_width: Some(120.0), + layout_target_height: Some(120.0), + layout_min_width: None, layout_max_width: None, + layout_min_height: None, layout_max_height: None, + layout_target_aspect_ratio: None, + }, + layout_child: None, + corner_radius: RectangularCornerRadius::circular(12.0), + corner_smoothing: CornerSmoothing(0.0), + fills: Paints::new(vec![solid(255, 200, 40, 120)]), + strokes: Paints::new(vec![solid(0, 0, 0, 255)]), + stroke_style: StrokeStyle::default(), + stroke_width: StrokeWidth::Uniform(1.0), + effects: LayerEffects::default(), + clip: true, + }); + + // [18] Child inside clipped rotated container + let child_in_45 = rect(10.0, 10.0, 100.0, 100.0, solid(220, 59, 59, 200)); + + // ── Tree structure ────────────────────────────────────────────────── + + let pairs: Vec<(u64, Node)> = vec![ + // Row 1: rotation angles (all root-level) + (1, r_0), + (2, r_15), + (3, r_45), + (4, r_90), + (5, r_180), + (6, r_270), + // Row 2: non-rect shapes (root-level) + (7, e_30), + (8, l_60), + (9, l_neg30), + // Row 3: custom origins (root-level) + (10, r_center_origin), + (11, r_tl_origin), + (12, r_br_origin), + // Row 4: containers with children + (13, container_90), + (14, child_in_90), + (15, container_30), + (16, child_rotated_in_30), + (17, container_45_inset), + (18, child_in_45), + ]; - // Line rotated 45° - let rotated_line = line(400.0, 200.0, 200.0, 45.0, 2.0); + let mut links: HashMap> = HashMap::new(); + links.insert(13, vec![14]); // container 90° → child + links.insert(15, vec![16]); // container 30° → child rotated 45° + links.insert(17, vec![18]); // container 45° inset → child - flat_scene("L0 Layout Transform", vec![rotated_container, rotated_rect, rotated_line]) + build_scene( + "L0 Layout Transform", + None, + pairs, + links, + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 17], + ) } diff --git a/crates/grida-canvas/examples/fixtures/l0_strokes_varwidth.rs b/crates/grida-canvas/examples/fixtures/l0_strokes_varwidth.rs new file mode 100644 index 0000000000..9aae807776 --- /dev/null +++ b/crates/grida-canvas/examples/fixtures/l0_strokes_varwidth.rs @@ -0,0 +1,94 @@ +use super::*; +use cg::vectornetwork::*; + +/// Variable-width stroke profiles on vector paths: taper, bulge, multi-stop, asymmetric. +pub fn build() -> Scene { + // Shared S-curve path (4 vertices, 3 segments) + fn s_curve(x: f32, y: f32, profile: cg::cg::varwidth::VarWidthProfile) -> Node { + Node::Vector(VectorNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::PassThrough, + mask: None, + effects: LayerEffects::default(), + transform: AffineTransform::from_box_center(x, y, 200.0, 100.0, 0.0), + network: VectorNetwork { + vertices: vec![(0.0, 100.0), (66.0, 0.0), (133.0, 100.0), (200.0, 0.0)], + segments: vec![ + VectorNetworkSegment { a: 0, b: 1, ta: (22.0, -40.0), tb: (-22.0, 20.0) }, + VectorNetworkSegment { a: 1, b: 2, ta: (22.0, 20.0), tb: (-22.0, -40.0) }, + VectorNetworkSegment { a: 2, b: 3, ta: (22.0, -40.0), tb: (-22.0, 20.0) }, + ], + regions: vec![], + }, + corner_radius: 0.0, + fills: Paints::new(vec![]), + strokes: Paints::new(vec![solid(0, 0, 0, 255)]), + stroke_width: 6.0, + stroke_width_profile: Some(profile), + stroke_align: StrokeAlign::Center, + stroke_cap: StrokeCap::Round, + stroke_join: StrokeJoin::Round, + stroke_miter_limit: StrokeMiterLimit(4.0), + stroke_dash_array: None, + marker_start_shape: StrokeMarkerPreset::None, + marker_end_shape: StrokeMarkerPreset::None, + layout_child: None, + }) + } + + let gap_y = 130.0; + + // Taper: thick start → thin end + let taper = s_curve(0.0, 0.0, cg::cg::varwidth::VarWidthProfile { + base: 3.0, + stops: vec![ + cg::cg::varwidth::WidthStop { u: 0.0, r: 6.0 }, + cg::cg::varwidth::WidthStop { u: 1.0, r: 0.5 }, + ], + }); + + // Reverse taper: thin start → thick end + let reverse_taper = s_curve(0.0, gap_y, cg::cg::varwidth::VarWidthProfile { + base: 3.0, + stops: vec![ + cg::cg::varwidth::WidthStop { u: 0.0, r: 0.5 }, + cg::cg::varwidth::WidthStop { u: 1.0, r: 6.0 }, + ], + }); + + // Bulge: thin → thick → thin (calligraphy / brush-pen feel) + let bulge = s_curve(0.0, gap_y * 2.0, cg::cg::varwidth::VarWidthProfile { + base: 3.0, + stops: vec![ + cg::cg::varwidth::WidthStop { u: 0.0, r: 1.0 }, + cg::cg::varwidth::WidthStop { u: 0.5, r: 8.0 }, + cg::cg::varwidth::WidthStop { u: 1.0, r: 1.0 }, + ], + }); + + // Multi-stop: irregular width profile (4 stops) + let multi_stop = s_curve(0.0, gap_y * 3.0, cg::cg::varwidth::VarWidthProfile { + base: 3.0, + stops: vec![ + cg::cg::varwidth::WidthStop { u: 0.0, r: 2.0 }, + cg::cg::varwidth::WidthStop { u: 0.25, r: 7.0 }, + cg::cg::varwidth::WidthStop { u: 0.6, r: 1.0 }, + cg::cg::varwidth::WidthStop { u: 1.0, r: 5.0 }, + ], + }); + + // Uniform profile: single stop (should render like a regular constant-width stroke) + let uniform = s_curve(0.0, gap_y * 4.0, cg::cg::varwidth::VarWidthProfile { + base: 3.0, + stops: vec![ + cg::cg::varwidth::WidthStop { u: 0.0, r: 3.0 }, + cg::cg::varwidth::WidthStop { u: 1.0, r: 3.0 }, + ], + }); + + flat_scene( + "L0 Strokes VarWidth", + vec![taper, reverse_taper, bulge, multi_stop, uniform], + ) +} diff --git a/crates/grida-canvas/examples/fixtures/mod.rs b/crates/grida-canvas/examples/fixtures/mod.rs index ed5a5a0610..1982bfdedb 100644 --- a/crates/grida-canvas/examples/fixtures/mod.rs +++ b/crates/grida-canvas/examples/fixtures/mod.rs @@ -2,14 +2,20 @@ //! //! Re-exports shared helpers from `fixture_helpers` so that //! `use super::*;` in each L0 module resolves correctly. +//! +//! Multiple example binaries share this module but each only uses a subset of +//! the sub-modules, so unused warnings are expected and suppressed here. + +#![allow(dead_code)] pub use crate::fixture_helpers::*; pub mod l0_boolean_operation; pub mod l0_container; pub mod l0_effects; -pub mod l0_group; pub mod l0_effects_glass; +pub mod l0_effects_progressive_blur; +pub mod l0_group; pub mod l0_image; pub mod l0_image_filters; pub mod l0_layout_flex; @@ -23,7 +29,9 @@ pub mod l0_shape_polygon; pub mod l0_shapes; pub mod l0_strokes; pub mod l0_strokes_rect; +pub mod l0_strokes_varwidth; pub mod l0_type; pub mod l0_type_features; pub mod l0_type_fvar; pub mod l0_vector; +pub mod cover; diff --git a/crates/grida-canvas/examples/tool_gen_bench_fixture.rs b/crates/grida-canvas/examples/tool_gen_bench_fixture.rs index 6f96d432e1..61dcb215ee 100644 --- a/crates/grida-canvas/examples/tool_gen_bench_fixture.rs +++ b/crates/grida-canvas/examples/tool_gen_bench_fixture.rs @@ -623,6 +623,46 @@ fn scene_blur_children_in_container() -> Scene { ) } +/// Progressive-blurred rectangles (1 000 nodes). +/// Each has a layer progressive blur with varying gradient directions. +fn scene_progressive_blur_grid() -> Scene { + let cols = 40; + let rows = 25; + let cell = 50.0_f32; + let gap = 10.0_f32; + + let mut nodes = Vec::with_capacity(cols * rows); + for row in 0..rows { + for col in 0..cols { + let x = col as f32 * (cell + gap); + let y = row as f32 * (cell + gap); + let r = ((col * 11) % 256) as u8; + let g = ((row * 9) % 256) as u8; + let b = 160; + + // Rotate gradient direction per node for variety + let angle = (col + row) as f32 * 0.3; + let sx = angle.cos(); + let sy = angle.sin(); + + let effects = LayerEffects { + blur: Some(FeLayerBlur { + active: true, + blur: FeBlur::Progressive(FeProgressiveBlur { + start: Alignment(-sx, -sy), + end: Alignment(sx, sy), + radius: 0.0, + radius2: 8.0 + (col % 8) as f32 * 2.0, + }), + }), + ..LayerEffects::default() + }; + nodes.push(rect_with_effects(x, y, cell, cell, solid(r, g, b, 255), effects)); + } + } + flat_scene("bench-progressive-blur-grid", nodes) +} + /// Opacity grid: 5 000 rects with fill only, varying opacity (0.1–0.9). /// Exercises the save_layer path for per-node opacity. fn scene_opacity_fill_only() -> Scene { @@ -691,6 +731,7 @@ fn main() { ("bench-shadow-container", scene_shadow_container()), ("bench-blur-container", scene_blur_container()), ("bench-blur-children-in-container", scene_blur_children_in_container()), + ("bench-progressive-blur-grid", scene_progressive_blur_grid()), ("bench-opacity-fill-only", scene_opacity_fill_only()), ("bench-opacity-fill-stroke", scene_opacity_fill_stroke()), ]; diff --git a/crates/grida-canvas/examples/tool_gen_cover.rs b/crates/grida-canvas/examples/tool_gen_cover.rs new file mode 100644 index 0000000000..d6aa9a3aae --- /dev/null +++ b/crates/grida-canvas/examples/tool_gen_cover.rs @@ -0,0 +1,63 @@ +//! Cover Image Generator +//! +//! Builds a `.grida` file from the cover scene and renders it to `cover.png` +//! using the cg renderer pipeline. +//! +//! ## Usage +//! +//! ```bash +//! cargo run --package cg --example tool_gen_cover +//! ``` +//! +//! ## Output +//! +//! - `fixtures/test-grida/cover.grida` — the scene in `.grida` format +//! - `crates/grida-canvas/cover.png` — the rendered cover image + +mod fixture_helpers; +mod fixtures; + +use cg::runtime::camera::Camera2D; +use cg::runtime::scene::{Backend, Renderer, RendererOptions}; +use math2::rect::Rectangle; +use skia_safe as sk; + +fn main() { + // ── 1. Build the scene ────────────────────────────────────────────── + let scene = fixtures::cover::build(); + + // ── 2. Write .grida file ──────────────────────────────────────────── + let scenes: Vec<(&str, _)> = vec![("cover", scene.clone())]; + fixture_helpers::write_multi_fixture(&scenes, "cover"); + + // ── 3. Render to PNG ──────────────────────────────────────────────── + let width = fixtures::cover::WIDTH; + let height = fixtures::cover::HEIGHT; + + let mut renderer = Renderer::new_with_options( + Backend::new_from_raster(width as i32, height as i32), + None, + Camera2D::new_from_bounds(Rectangle::from_xywh(0.0, 0.0, width, height)), + RendererOptions { + use_embedded_fonts: true, + }, + ); + + renderer.load_scene(scene); + + let surface = unsafe { &mut *renderer.backend.get_surface() }; + let canvas = surface.canvas(); + renderer.render_to_canvas(canvas, width, height); + + let image = surface.image_snapshot(); + let data = image + .encode(None, sk::EncodedImageFormat::PNG, None) + .expect("encode cover.png"); + + let out_path = concat!(env!("CARGO_MANIFEST_DIR"), "/cover.png"); + std::fs::write(out_path, data.as_bytes()).expect("write cover.png"); + + eprintln!("✓ Rendered cover image to {}", out_path); + + renderer.free(); +} diff --git a/crates/grida-canvas/examples/tool_gen_fixtures.rs b/crates/grida-canvas/examples/tool_gen_fixtures.rs index 7f6107bf41..733595a262 100644 --- a/crates/grida-canvas/examples/tool_gen_fixtures.rs +++ b/crates/grida-canvas/examples/tool_gen_fixtures.rs @@ -26,10 +26,12 @@ fn main() { ("L0-paints-stack", fixtures::l0_paints_stack::build()), ("L0-strokes", fixtures::l0_strokes::build()), ("L0-strokes-rect", fixtures::l0_strokes_rect::build()), + ("L0-strokes-varwidth", fixtures::l0_strokes_varwidth::build()), ("L0-image", fixtures::l0_image::build()), ("L0-image-filters", fixtures::l0_image_filters::build()), ("L0-effects", fixtures::l0_effects::build()), ("L0-effects-glass", fixtures::l0_effects_glass::build()), + ("L0-effects-progressive-blur", fixtures::l0_effects_progressive_blur::build()), ("L0-type", fixtures::l0_type::build()), ("L0-type-fvar", fixtures::l0_type_fvar::build()), ("L0-type-features", fixtures::l0_type_features::build()), diff --git a/crates/grida-canvas/examples/tool_io_grida.rs b/crates/grida-canvas/examples/tool_io_grida.rs index f290d4599b..f2dc30aeec 100644 --- a/crates/grida-canvas/examples/tool_io_grida.rs +++ b/crates/grida-canvas/examples/tool_io_grida.rs @@ -1,90 +1,472 @@ -//! Grida File Validation Tool +//! Grida File Inspector //! -//! This tool validates .grida files by parsing them and reporting success or failure. -//! It's useful for debugging file format issues and verifying that legacy files can be parsed correctly. +//! Validates and inspects `.grida` / `.grida1` files in any format +//! (FlatBuffers, ZIP archive, or legacy JSON). //! //! ## Usage //! //! ```bash -//! cargo run --example tool_io_grida +//! # Basic inspection (decode + node stats) +//! cargo run --example tool_io_grida -- path/to/file.grida +//! +//! # Also accepts .grida1 extension +//! cargo run --example tool_io_grida -- path/to/file.grida1 +//! +//! # List scenes (FBS/ZIP multi-scene files) +//! cargo run --example tool_io_grida -- path/to/file.grida --list-scenes +//! +//! # Inspect a specific scene (0-based index) +//! cargo run --example tool_io_grida -- path/to/file.grida --scene 1 +//! +//! # Run layout check (detect containers missing layout results) +//! cargo run --example tool_io_grida -- path/to/file.grida --layout-check +//! +//! # Verbose: print full node tree with IDs +//! cargo run --example tool_io_grida -- path/to/file.grida --verbose //! ``` //! -//! ## Output +//! ## Diagnostics +//! +//! The `--layout-check` flag runs the layout engine on the selected scene(s) +//! and reports every `Container` node that is missing from the layout result. +//! This is the direct cause of: //! -//! On success, the tool prints: -//! - Total number of nodes parsed -//! - Scene references -//! - Entry scene ID -//! - Breakdown of node types (group, container, text, etc.) +//! ```text +//! Container must have layout result when layout engine is used +//! ``` //! -//! On failure, the tool prints: -//! - Error message describing what failed to parse +//! Use the reported string IDs / ancestor paths to correlate with the source +//! document (e.g. fig2grida converter output). //! //! ## Exit Codes //! -//! - `0` - Success -//! - `1` - Parse error or file read error +//! - `0` — Success (all checks pass) +//! - `1` — Decode error, file read error, or layout-check failures found -use cg::io::io_grida::parse; -use std::env; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; +use std::path::PathBuf; + +use clap::Parser; + +use cg::io::io_grida; +use cg::io::io_grida_file::{self, Format}; +use cg::layout::engine::LayoutEngine; +use cg::node::id::NodeId; +use cg::node::scene_graph::SceneGraph; +use cg::node::schema::{Node, Scene, Size}; + +#[derive(Parser, Debug)] +#[command( + author, + version, + about = "Inspect and validate .grida / .grida1 files (FBS, ZIP, or JSON)." +)] +struct Cli { + /// Path to a `.grida` or `.grida1` file. + path: PathBuf, + + /// List available scenes and exit (FBS/ZIP multi-scene files). + #[arg(long = "list-scenes")] + list_scenes: bool, + + /// Scene index to inspect (0-based). Defaults to all scenes. + #[arg(long = "scene")] + scene_index: Option, + + /// Run layout-engine check: report containers missing layout results. + #[arg(long = "layout-check")] + layout_check: bool, + + /// Print the full node tree with string IDs (FBS/ZIP) or node details (JSON). + #[arg(long = "verbose")] + verbose: bool, +} fn main() { - let args: Vec = env::args().collect(); + let cli = Cli::parse(); - if args.len() < 2 { - eprintln!("Usage: cargo run --example tool_io_grida "); + // ── Read & detect format ──────────────────────────────────────────── + let bytes = match fs::read(&cli.path) { + Ok(b) => b, + Err(e) => { + eprintln!("error: failed to read {}: {e}", cli.path.display()); + std::process::exit(1); + } + }; + + let format = io_grida_file::detect(&bytes); + println!("File: {}", cli.path.display()); + println!("Size: {} bytes", bytes.len()); + println!("Format: {}", format); + println!(); + + match format { + Format::Json => inspect_json(&bytes, &cli), + Format::RawFbs | Format::Zip => inspect_fbs(&bytes, &cli), + Format::Unknown => { + eprintln!( + "error: unrecognized format. Expected .grida FlatBuffers, ZIP, or JSON." + ); + std::process::exit(1); + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// JSON inspection path +// ═════════════════════════════════════════════════════════════════════════════ + +fn inspect_json(bytes: &[u8], cli: &Cli) { + // --list-scenes and --scene are FBS/ZIP-only features; reject them for + // legacy JSON input to avoid silent misuse. + if cli.list_scenes { + eprintln!("error: --list-scenes is not supported for legacy JSON files (single scene only)."); + std::process::exit(1); + } + if cli.scene_index.is_some() { + eprintln!("error: --scene is not supported for legacy JSON files (single scene only)."); std::process::exit(1); } - let file_path = &args[1]; - - println!("Parsing file: {}", file_path); - - match fs::read_to_string(file_path) { - Ok(content) => match parse(&content) { - Ok(file) => { - println!("✓ Successfully parsed {} nodes", file.document.nodes.len()); - println!(" Scenes: {:?}", file.document.scenes_ref); - println!(" Entry scene: {:?}", file.document.entry_scene_id); - - // Count node types - let mut node_types: std::collections::HashMap = - std::collections::HashMap::new(); - for node in file.document.nodes.values() { - let type_name = match node { - cg::io::io_grida::JSONNode::Group(_) => "group", - cg::io::io_grida::JSONNode::Container(_) => "container", - cg::io::io_grida::JSONNode::Vector(_) => "vector", - cg::io::io_grida::JSONNode::Path(_) => "path", - cg::io::io_grida::JSONNode::Ellipse(_) => "ellipse", - cg::io::io_grida::JSONNode::Rectangle(_) => "rectangle", - cg::io::io_grida::JSONNode::RegularPolygon(_) => "polygon", - cg::io::io_grida::JSONNode::RegularStarPolygon(_) => "star", - cg::io::io_grida::JSONNode::Line(_) => "line", - cg::io::io_grida::JSONNode::TextSpan(_) => "tspan", - cg::io::io_grida::JSONNode::BooleanOperation(_) => "boolean", - cg::io::io_grida::JSONNode::Image(_) => "image", - cg::io::io_grida::JSONNode::Scene(_) => "scene", - cg::io::io_grida::JSONNode::Unknown(_) => "unknown", - }; - *node_types.entry(type_name.to_string()).or_insert(0) += 1; - } - if !node_types.is_empty() { - println!(" Node types:"); - for (name, count) in node_types.iter() { - println!(" {}: {}", name, count); - } - } - } + let content = match std::str::from_utf8(bytes) { + Ok(s) => s, + Err(e) => { + eprintln!("error: invalid UTF-8: {e}"); + std::process::exit(1); + } + }; + + let file = match io_grida::parse(content) { + Ok(f) => f, + Err(e) => { + eprintln!("error: failed to parse JSON: {e}"); + std::process::exit(1); + } + }; + + println!("Nodes: {}", file.document.nodes.len()); + println!("Scenes: {:?}", file.document.scenes_ref); + println!("Entry scene: {:?}", file.document.entry_scene_id); + + // Node type breakdown + let mut type_counts: BTreeMap<&str, usize> = BTreeMap::new(); + for node in file.document.nodes.values() { + let type_name = classify_json_node(node); + *type_counts.entry(type_name).or_default() += 1; + } + + if !type_counts.is_empty() { + println!("Node types:"); + for (name, count) in &type_counts { + println!(" {:<22} {}", name, count); + } + } + + if cli.verbose { + println!("\nNode details:"); + for (id, node) in &file.document.nodes { + println!(" {} -> {}", id, classify_json_node(node)); + } + } + + if cli.layout_check { + // For JSON, we need to convert to Scene first and then run layout check. + let scenes = match io_grida_file::decode_all(bytes) { + Ok(s) => s, Err(e) => { - eprintln!("✗ Failed to parse: {}", e); + eprintln!("error: failed to convert JSON to scene: {e}"); std::process::exit(1); } - }, + }; + + let empty_id_map = HashMap::new(); + let mut has_failures = false; + for (i, scene) in scenes.iter().enumerate() { + println!("\n━━━ Scene [{}] \"{}\" ━━━", i, scene.name); + if !run_layout_check(scene, &empty_id_map) { + has_failures = true; + } + } + if has_failures { + std::process::exit(1); + } + } +} + +fn classify_json_node(node: &io_grida::JSONNode) -> &'static str { + match node { + io_grida::JSONNode::Group(_) => "group", + io_grida::JSONNode::Container(_) => "container", + io_grida::JSONNode::Vector(_) => "vector", + io_grida::JSONNode::Path(_) => "path", + io_grida::JSONNode::Ellipse(_) => "ellipse", + io_grida::JSONNode::Rectangle(_) => "rectangle", + io_grida::JSONNode::RegularPolygon(_) => "polygon", + io_grida::JSONNode::RegularStarPolygon(_) => "star", + io_grida::JSONNode::Line(_) => "line", + io_grida::JSONNode::TextSpan(_) => "tspan", + io_grida::JSONNode::BooleanOperation(_) => "boolean", + io_grida::JSONNode::Image(_) => "image", + io_grida::JSONNode::Scene(_) => "scene", + io_grida::JSONNode::Unknown(_) => "unknown", + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// FBS / ZIP inspection path +// ═════════════════════════════════════════════════════════════════════════════ + +fn inspect_fbs(bytes: &[u8], cli: &Cli) { + let decoded = match io_grida_file::decode_with_id_map(bytes) { + Ok(d) => d, Err(e) => { - eprintln!("✗ Failed to read file: {}", e); + eprintln!("error: failed to decode: {e}"); + std::process::exit(1); + } + }; + + let scenes = &decoded.scenes; + let id_map = &decoded.id_map; + let position_map = &decoded.position_map; + + println!("Scenes: {}", scenes.len()); + println!("ID map entries: {}", id_map.len()); + println!("Position map entries: {}", position_map.len()); + println!(); + + // ── List scenes ───────────────────────────────────────────────────── + if cli.list_scenes { + print_scene_list(scenes); + return; + } + + // ── Determine which scenes to inspect ─────────────────────────────── + let scene_indices: Vec = if let Some(idx) = cli.scene_index { + if idx >= scenes.len() { + eprintln!( + "error: scene index {} out of range (0..{}). Use --list-scenes.", + idx, + scenes.len() + ); std::process::exit(1); } + vec![idx] + } else { + (0..scenes.len()).collect() + }; + + let mut has_failures = false; + + for &idx in &scene_indices { + let scene = &scenes[idx]; + println!("━━━ Scene [{}] \"{}\" ━━━", idx, scene.name); + print_scene_stats(scene, id_map, cli.verbose); + + if cli.layout_check && !run_layout_check(scene, id_map) { + has_failures = true; + } + + println!(); + } + + if has_failures { + std::process::exit(1); + } +} + +fn print_scene_list(scenes: &[Scene]) { + println!("Available scenes:"); + for (i, s) in scenes.iter().enumerate() { + println!(" [{}] \"{}\" ({} nodes)", i, s.name, s.graph.node_count()); + } +} + +fn print_scene_stats(scene: &Scene, id_map: &HashMap, verbose: bool) { + let graph = &scene.graph; + let reachable = collect_reachable(graph); + + println!( + " Total nodes in graph: {} (reachable from roots: {})", + graph.node_count(), + reachable.len() + ); + println!(" Roots: {}", graph.roots().len()); + + // Node type breakdown + let mut type_counts: BTreeMap<&str, usize> = BTreeMap::new(); + for (node_id, node) in graph.nodes_iter() { + if !reachable.contains(node_id) { + continue; + } + let label = classify_node(node); + *type_counts.entry(label).or_default() += 1; + } + + if !type_counts.is_empty() { + println!(" Node types:"); + for (name, count) in &type_counts { + println!(" {:<22} {}", name, count); + } + } + + // Verbose: print full tree + if verbose { + println!(" Node tree:"); + for root in graph.roots() { + print_node_tree(graph, id_map, root, 2); + } + } +} + +fn print_node_tree( + graph: &SceneGraph, + id_map: &HashMap, + id: &NodeId, + depth: usize, +) { + let Ok(node) = graph.get_node(id) else { + return; + }; + let indent = " ".repeat(depth); + let string_id = id_map + .get(id) + .map(|s| s.as_str()) + .unwrap_or(""); + println!( + "{}{} {:?} ({})", + indent, + classify_node(node), + id, + string_id, + ); + + if let Some(children) = graph.get_children(id) { + for child in children { + print_node_tree(graph, id_map, child, depth + 1); + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Layout check (shared by both paths) +// ═════════════════════════════════════════════════════════════════════════════ + +/// Runs the layout engine on the given scene and reports containers that are +/// missing from the layout result. Returns `true` if all containers have +/// layout results, `false` otherwise. +fn run_layout_check(scene: &Scene, id_map: &HashMap) -> bool { + let viewport_size = Size { + width: 1920.0, + height: 1080.0, + }; + + let mut engine = LayoutEngine::new(); + engine.compute(scene, viewport_size, None); + let layout_result = engine.result(); + + let graph = &scene.graph; + let reachable = collect_reachable(graph); + + let mut missing: Vec<(NodeId, String, Vec)> = Vec::new(); + + for (node_id, node) in graph.nodes_iter() { + if !reachable.contains(node_id) { + continue; + } + if let Node::Container(_) = node { + if layout_result.get(node_id).is_none() { + let string_id = id_map + .get(node_id) + .cloned() + .unwrap_or_else(|| format!("{:?}", node_id)); + + let mut ancestor_ids: Vec = graph.ancestors(node_id).unwrap_or_default(); + ancestor_ids.reverse(); + ancestor_ids.push(*node_id); + let path: Vec = ancestor_ids + .iter() + .map(|id| { + id_map + .get(id) + .cloned() + .unwrap_or_else(|| format!("{:?}", id)) + }) + .collect(); + + missing.push((*node_id, string_id, path)); + } + } + } + + let total_containers = graph + .nodes_iter() + .filter(|(id, n)| reachable.contains(id) && matches!(n, Node::Container(_))) + .count(); + + if missing.is_empty() { + println!( + " Layout check: OK ({} container(s) all have layout results)", + total_containers + ); + return true; + } + + eprintln!( + " Layout check: FAIL — {}/{} container(s) missing layout result", + missing.len(), + total_containers, + ); + eprintln!(" This causes: \"Container must have layout result when layout engine is used\""); + eprintln!(" Correlate the string_id / path below with your source document.\n"); + + for (internal_id, string_id, path) in &missing { + let path_str = if path.is_empty() { + string_id.clone() + } else { + path.join(" / ") + }; + eprintln!(" internal_id: {:?}", internal_id); + eprintln!(" string_id: {}", string_id); + eprintln!(" path: {}", path_str); + eprintln!(); + } + + false +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Helpers +// ═════════════════════════════════════════════════════════════════════════════ + +/// Collects all node IDs reachable from the scene's roots (preorder walk). +fn collect_reachable(graph: &SceneGraph) -> HashSet { + let mut reachable = HashSet::new(); + for root_id in graph.roots() { + reachable.insert(*root_id); + let _ = graph.walk_preorder(root_id, &mut |id| { + reachable.insert(*id); + }); + } + reachable +} + +fn classify_node(node: &Node) -> &'static str { + match node { + Node::InitialContainer(_) => "initial_container", + Node::Container(_) => "container", + Node::Group(_) => "group", + Node::Vector(_) => "vector", + Node::Path(_) => "path", + Node::BooleanOperation(_) => "boolean", + Node::Rectangle(_) => "rectangle", + Node::Ellipse(_) => "ellipse", + Node::Polygon(_) => "polygon", + Node::RegularPolygon(_) => "regular_polygon", + Node::RegularStarPolygon(_) => "star_polygon", + Node::Line(_) => "line", + Node::Image(_) => "image", + Node::TextSpan(_) => "tspan", + Node::Error(_) => "error", } } diff --git a/crates/grida-canvas/src/io/io_grida_fbs.rs b/crates/grida-canvas/src/io/io_grida_fbs.rs index 6b42dddffd..f09d2cfbc6 100644 --- a/crates/grida-canvas/src/io/io_grida_fbs.rs +++ b/crates/grida-canvas/src/io/io_grida_fbs.rs @@ -29,19 +29,22 @@ use crate::cg::{ color::CGColor, fe::{ FeBackdropBlur, FeBlur, FeGaussianBlur, FeLayerBlur, FeLiquidGlass, FeNoiseEffect, - FeShadow, FilterShadowEffect, NoiseEffectColors, + FeProgressiveBlur, FeShadow, FilterShadowEffect, NoiseEffectColors, }, stroke_dasharray::StrokeDashArray, stroke_width::{RectangularStrokeWidth, SingularStrokeWidth, StrokeWidth}, tilemode::TileMode, + varwidth, types::{ Axis, BlendMode, BooleanPathOperation, CGPoint, ContainerClipFlag, CornerSmoothing, - CrossAxisAlignment, EdgeInsets, FontWeight, GradientStop, ImageFilters, ImagePaint, - ImagePaintFit, LayerBlendMode, LayerMaskType, LayoutGap, LayoutMode, LayoutPositioning, - LayoutWrap, LinearGradientPaint, MainAxisAlignment, Paint, Paints, RadialGradientPaint, + CrossAxisAlignment, DiamondGradientPaint, EdgeInsets, FontFeature, FontOpticalSizing, + FontVariation, FontWeight, GradientStop, ImageFilters, ImagePaint, ImagePaintFit, + LayerBlendMode, LayerMaskType, LayoutGap, LayoutMode, LayoutPositioning, LayoutWrap, + LinearGradientPaint, MainAxisAlignment, Paint, Paints, RadialGradientPaint, RectangularCornerRadius, ResourceRef, SolidPaint, StrokeAlign, StrokeCap, StrokeJoin, StrokeMarkerPreset, StrokeMiterLimit, SweepGradientPaint, TextAlign, TextAlignVertical, - TextStyleRec, + TextDecorationLine, TextDecorationRec, TextDecorationStyle, TextLetterSpacing, + TextLineHeight, TextStyleRec, TextTransform, TextWordSpacing, }, }; use crate::node::{ @@ -570,7 +573,7 @@ fn decode_paint_item(item: &fbs::PaintStackItem<'_>) -> Option { active: lgp.active(), xy1, xy2, - tile_mode: TileMode::default(), + tile_mode: decode_tile_mode(lgp.tile_mode()), transform, stops, opacity: lgp.opacity(), @@ -590,7 +593,7 @@ fn decode_paint_item(item: &fbs::PaintStackItem<'_>) -> Option { stops, opacity: rgp.opacity(), blend_mode: decode_blend_mode(rgp.blend_mode()), - tile_mode: TileMode::default(), + tile_mode: decode_tile_mode(rgp.tile_mode()), })) } fbs::Paint::SweepGradientPaint => { @@ -608,6 +611,21 @@ fn decode_paint_item(item: &fbs::PaintStackItem<'_>) -> Option { blend_mode: decode_blend_mode(sgp.blend_mode()), })) } + fbs::Paint::DiamondGradientPaint => { + let dgp = item.paint_as_diamond_gradient_paint()?; + let stops = dgp.stops().map(decode_gradient_stops).unwrap_or_default(); + let transform = dgp + .transform() + .map(decode_fbs_transform) + .unwrap_or_default(); + Some(Paint::DiamondGradient(DiamondGradientPaint { + active: dgp.active(), + transform, + stops, + opacity: dgp.opacity(), + blend_mode: decode_blend_mode(dgp.blend_mode()), + })) + } fbs::Paint::ImagePaint => { let ip = item.paint_as_image_paint()?; let image_ref = if let Some(hash_ref) = ip.image_as_resource_ref_hash() { @@ -630,7 +648,15 @@ fn decode_paint_item(item: &fbs::PaintStackItem<'_>) -> Option { fit, opacity: ip.opacity(), blend_mode: decode_blend_mode(ip.blend_mode()), - filters: ImageFilters::default(), + filters: ip.filters().map(|f| ImageFilters { + exposure: f.exposure(), + contrast: f.contrast(), + saturation: f.saturation(), + temperature: f.temperature(), + tint: f.tint(), + highlights: f.highlights(), + shadows: f.shadows(), + }).unwrap_or_default(), })) } _ => None, @@ -679,10 +705,59 @@ fn decode_paints_vec( // Effects decoding // ───────────────────────────────────────────────────────────────────────────── -/// Decode a blur union (currently always Gaussian) from the FBS blur payload. -fn decode_fe_blur(gaussian: Option>) -> FeBlur { +/// Decode the `FeBlur` union from an `FeLayerBlur` or `FeBackdropBlur` table. +/// +/// Checks the `blur_type` discriminant first; falls back to Gaussian if +/// the variant is unrecognised or the payload is missing. +fn decode_fe_blur_from_layer(lb: &fbs::FeLayerBlur<'_>) -> FeBlur { + match lb.blur_type() { + fbs::FeBlur::FeProgressiveBlur => { + if let Some(p) = lb.blur_as_fe_progressive_blur() { + return decode_progressive_blur(&p); + } + } + _ => {} + } + // Default / Gaussian path FeBlur::Gaussian(FeGaussianBlur { - radius: gaussian.map(|g| g.radius()).unwrap_or(0.0), + radius: lb + .blur_as_fe_gaussian_blur() + .map(|g| g.radius()) + .unwrap_or(0.0), + }) +} + +fn decode_fe_blur_from_backdrop(bb: &fbs::FeBackdropBlur<'_>) -> FeBlur { + match bb.blur_type() { + fbs::FeBlur::FeProgressiveBlur => { + if let Some(p) = bb.blur_as_fe_progressive_blur() { + return decode_progressive_blur(&p); + } + } + _ => {} + } + FeBlur::Gaussian(FeGaussianBlur { + radius: bb + .blur_as_fe_gaussian_blur() + .map(|g| g.radius()) + .unwrap_or(0.0), + }) +} + +fn decode_progressive_blur(p: &fbs::FeProgressiveBlur<'_>) -> FeBlur { + let start = p + .start() + .map(|a| Alignment(a.x(), a.y())) + .unwrap_or(Alignment(0.0, 0.0)); + let end = p + .end() + .map(|a| Alignment(a.x(), a.y())) + .unwrap_or(Alignment(0.0, 0.0)); + FeBlur::Progressive(FeProgressiveBlur { + start, + end, + radius: p.radius(), + radius2: p.radius2(), }) } @@ -696,14 +771,14 @@ fn decode_layer_effects(effects: Option>) -> LayerEffects if let Some(lb) = effects.fe_blur() { out.blur = Some(FeLayerBlur { - blur: decode_fe_blur(lb.blur_as_fe_gaussian_blur()), + blur: decode_fe_blur_from_layer(&lb), active: lb.active(), }); } if let Some(bb) = effects.fe_backdrop_blur() { out.backdrop_blur = Some(FeBackdropBlur { - blur: decode_fe_blur(bb.blur_as_fe_gaussian_blur()), + blur: decode_fe_blur_from_backdrop(&bb), active: bb.active(), }); } @@ -827,15 +902,46 @@ enum_map!(decode_text_align, encode_text_align, fbs::TextAlign, TextAlign, TextA enum_map!(decode_text_align_vertical, encode_text_align_vertical, fbs::TextAlignVertical, TextAlignVertical, TextAlignVertical::Top, { Top, Center, Bottom, }); +enum_map!(decode_tile_mode, encode_tile_mode, fbs::TileMode, TileMode, TileMode::Clamp, { + Clamp, Repeated, Mirror, Decal, +}); +enum_map!(decode_text_transform, encode_text_transform, fbs::TextTransform, TextTransform, TextTransform::None, { + None, Uppercase, Lowercase, Capitalize, +}); +enum_map!(decode_text_decoration_line, encode_text_decoration_line, fbs::TextDecorationLine, TextDecorationLine, TextDecorationLine::None, { + None, Underline, Overline, LineThrough, +}); +enum_map!(decode_text_decoration_style, encode_text_decoration_style, fbs::TextDecorationStyle, TextDecorationStyle, TextDecorationStyle::Solid, { + Solid, Double, Dotted, Dashed, Wavy, +}); -/// Decode a `StrokeGeometryTrait` into `(StrokeStyle, f32 stroke_width)`. -fn decode_stroke_geometry_trait(sg: Option>) -> (StrokeStyle, f32) { +/// Decode a `StrokeGeometryTrait` into `(StrokeStyle, f32, Option)`. +fn decode_stroke_geometry_trait( + sg: Option>, +) -> (StrokeStyle, f32, Option) { let sg = match sg { Some(s) => s, - None => return (StrokeStyle::default(), 0.0), + None => return (StrokeStyle::default(), 0.0, None), }; let style = decode_stroke_style_from_fbs(sg.stroke_style()); - (style, sg.stroke_width()) + let profile = sg.stroke_width_profile().map(|p| { + let stops = p + .stops() + .map(|stops_vec| { + (0..stops_vec.len()) + .map(|i| { + let s = stops_vec.get(i); + varwidth::WidthStop { u: s.u(), r: s.r() } + }) + .collect::>() + }) + .unwrap_or_default(); + varwidth::VarWidthProfile { + base: sg.stroke_width() * 0.5, + stops, + } + }); + (style, sg.stroke_width(), profile) } /// Decode a `RectangularStrokeGeometryTrait` into `(StrokeStyle, StrokeWidth)`. @@ -984,7 +1090,10 @@ fn decode_layout_dimension_style(ls: &fbs::LayoutStyle<'_>) -> LayoutDimensionSt layout_max_width: None, layout_min_height: None, layout_max_height: None, - layout_target_aspect_ratio: None, + layout_target_aspect_ratio: dim + .as_ref() + .and_then(|d| d.layout_target_aspect_ratio()) + .map(|s| (s.width(), s.height())), } } @@ -1269,14 +1378,29 @@ fn decode_basic_shape_node( transform: sl.transform, size: sl.size, corner_radius, - corner_smoothing: CornerSmoothing::default(), + corner_smoothing: CornerSmoothing(bsn.corner_smoothing()), fills, strokes, stroke_style, - stroke_width: if stroke_width_f32 == 0.0 { - StrokeWidth::None - } else { - StrokeWidth::Uniform(stroke_width_f32) + stroke_width: { + if let Some(rsw) = bsn.rectangular_stroke_width() { + let top = rsw.stroke_top_width(); + let right = rsw.stroke_right_width(); + let bottom = rsw.stroke_bottom_width(); + let left = rsw.stroke_left_width(); + if top == right && right == bottom && bottom == left { + if top == 0.0 { StrokeWidth::None } else { StrokeWidth::Uniform(top) } + } else { + StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: top, stroke_right_width: right, + stroke_bottom_width: bottom, stroke_left_width: left, + }) + } + } else if stroke_width_f32 == 0.0 { + StrokeWidth::None + } else { + StrokeWidth::Uniform(stroke_width_f32) + } }, effects: lc.effects.clone(), layout_child: lc.layout_child.clone(), @@ -1312,8 +1436,10 @@ fn decode_basic_shape_node( inner_radius, start_angle, angle, - // corner_radius is not in the FBS schema; defaults to None - corner_radius: None, + corner_radius: { + let cr = bsn.corner_radius(); + if cr == 0.0 { None } else { Some(cr) } + }, effects: lc.effects.clone(), layout_child: lc.layout_child.clone(), }) @@ -1394,7 +1520,8 @@ fn decode_vector_node( vn: &fbs::VectorNode<'_>, ) -> Node { let sl = decode_shape_layout(layer, lc.rotation_cos_sin); - let (stroke_style, stroke_width_f32) = decode_stroke_geometry_trait(vn.stroke_geometry()); + let (stroke_style, stroke_width_f32, stroke_width_profile) = + decode_stroke_geometry_trait(vn.stroke_geometry()); Node::Vector(VectorNodeRec { active: lc.active, @@ -1403,11 +1530,11 @@ fn decode_vector_node( mask: lc.mask, transform: sl.transform, network: decode_vector_network(vn.vector_network_data()), - corner_radius: 0.0, + corner_radius: vn.corner_radius().and_then(|cr| cr.corner_radius()).map(|r| r.rx()).unwrap_or(0.0), fills: decode_paints_vec(vn.fill_paints()), strokes: decode_paints_vec(vn.stroke_paints()), stroke_width: stroke_width_f32, - stroke_width_profile: None, + stroke_width_profile, stroke_align: stroke_style.stroke_align, stroke_cap: stroke_style.stroke_cap, stroke_join: stroke_style.stroke_join, @@ -1487,7 +1614,8 @@ fn decode_boolean_operation_node( .corner_radius() .and_then(|cr| cr.corner_radius()) .map(|r| r.rx()); - let (stroke_style, stroke_width_f32) = decode_stroke_geometry_trait(bon.stroke_geometry()); + let (stroke_style, stroke_width_f32, _profile) = + decode_stroke_geometry_trait(bon.stroke_geometry()); Node::BooleanOperation(BooleanPathOperationNodeRec { active: lc.active, @@ -1529,6 +1657,66 @@ fn decode_text_span_node( .map(|ts| { let mut rec = TextStyleRec::from_font(ts.font_family(), ts.font_size()); rec.font_weight = FontWeight(ts.font_weight().value()); + rec.font_style_italic = ts.font_style_italic(); + rec.font_kerning = ts.font_kerning(); + let fw = ts.font_width(); + rec.font_width = if fw == 0.0 { None } else { Some(fw) }; + rec.text_transform = decode_text_transform(ts.text_transform()); + rec.font_optical_sizing = ts + .font_optical_sizing() + .map(|fos| match fos.kind() { + fbs::FontOpticalSizingKind::None => FontOpticalSizing::None, + fbs::FontOpticalSizingKind::Fixed => FontOpticalSizing::Fixed(fos.value()), + _ => FontOpticalSizing::Auto, + }) + .unwrap_or(FontOpticalSizing::Auto); + rec.text_decoration = ts.text_decoration().map(|td| TextDecorationRec { + text_decoration_line: decode_text_decoration_line(td.text_decoration_line()), + text_decoration_color: td.text_decoration_color().map(|c| decode_rgba32f_to_cg_color(c)), + text_decoration_style: Some(decode_text_decoration_style(td.text_decoration_style())), + text_decoration_skip_ink: Some(td.text_decoration_skip_ink()), + text_decoration_thickness: { + let t = td.text_decoration_thickness(); + if t == 0.0 { None } else { Some(t) } + }, + }); + rec.letter_spacing = ts.letter_spacing().map(|td| { + match td.kind() { + fbs::TextDimensionKind::Factor => TextLetterSpacing::Factor(td.value().unwrap_or(0.0)), + _ => TextLetterSpacing::Fixed(td.value().unwrap_or(0.0)), + } + }).unwrap_or_default(); + rec.word_spacing = ts.word_spacing().map(|td| { + match td.kind() { + fbs::TextDimensionKind::Factor => TextWordSpacing::Factor(td.value().unwrap_or(0.0)), + _ => TextWordSpacing::Fixed(td.value().unwrap_or(0.0)), + } + }).unwrap_or_default(); + rec.line_height = ts.line_height().map(|td| { + match td.kind() { + fbs::TextDimensionKind::Normal => TextLineHeight::Normal, + fbs::TextDimensionKind::Factor => TextLineHeight::Factor(td.value().unwrap_or(1.0)), + _ => TextLineHeight::Fixed(td.value().unwrap_or(0.0)), + } + }).unwrap_or_default(); + rec.font_features = ts.font_features().map(|ff| { + (0..ff.len()).filter_map(|i| { + let f = ff.get(i); + f.open_type_feature_tag().map(|tag| FontFeature { + tag: String::from_utf8_lossy(&[tag.a(), tag.b(), tag.c(), tag.d()]).into_owned(), + value: f.open_type_feature_value(), + }) + }).collect() + }); + rec.font_variations = ts.font_variations().map(|fv| { + (0..fv.len()).map(|i| { + let v = fv.get(i); + FontVariation { + axis: v.variation_axis().to_owned(), + value: v.variation_value(), + } + }).collect() + }); rec }) .unwrap_or_else(|| TextStyleRec::from_font("Inter", 14.0)); @@ -1573,8 +1761,8 @@ fn decode_text_span_node( text_style, text_align, text_align_vertical, - max_lines: None, - ellipsis: None, + max_lines: props.as_ref().map(|p| p.max_lines()).and_then(|v| if v == 0 { None } else { Some(v as usize) }), + ellipsis: props.as_ref().and_then(|p| p.ellipsis()).map(|s| s.to_owned()), fills: fill_paints, strokes: stroke_paints, stroke_width, @@ -2118,7 +2306,7 @@ fn encode_paint_item<'a, A: flatbuffers::Allocator + 'a>( opacity: lg.opacity, blend_mode: encode_blend_mode(lg.blend_mode), transform: Some(&transform), - ..Default::default() + tile_mode: encode_tile_mode(lg.tile_mode), }); Some(fbs::PaintStackItem::create(fbb, &fbs::PaintStackItemArgs { paint_type: fbs::Paint::LinearGradientPaint, @@ -2134,7 +2322,7 @@ fn encode_paint_item<'a, A: flatbuffers::Allocator + 'a>( opacity: rg.opacity, blend_mode: encode_blend_mode(rg.blend_mode), transform: Some(&transform), - ..Default::default() + tile_mode: encode_tile_mode(rg.tile_mode), }); Some(fbs::PaintStackItem::create(fbb, &fbs::PaintStackItemArgs { paint_type: fbs::Paint::RadialGradientPaint, @@ -2171,6 +2359,11 @@ fn encode_paint_item<'a, A: flatbuffers::Allocator + 'a>( }; let alignment = fbs::Alignment::new(ip.alignement.0, ip.alignement.1); let (fit_type, fit_value) = encode_image_paint_fit(fbb, &ip.fit); + let fbs_filters = fbs::ImageFilters::new( + ip.filters.exposure, ip.filters.contrast, ip.filters.saturation, + ip.filters.temperature, ip.filters.tint, ip.filters.highlights, + ip.filters.shadows, + ); let ip_offset = fbs::ImagePaint::create(fbb, &fbs::ImagePaintArgs { active: ip.active, image_type: image_ref_offset.0, @@ -2181,14 +2374,28 @@ fn encode_paint_item<'a, A: flatbuffers::Allocator + 'a>( fit: Some(fit_value), opacity: ip.opacity, blend_mode: encode_blend_mode(ip.blend_mode), - ..Default::default() + filters: Some(&fbs_filters), }); Some(fbs::PaintStackItem::create(fbb, &fbs::PaintStackItemArgs { paint_type: fbs::Paint::ImagePaint, paint: Some(ip_offset.as_union_value()), })) } - _ => None, + Paint::DiamondGradient(dg) => { + let stops = encode_gradient_stops(fbb, &dg.stops); + let transform = encode_affine_to_cg_transform(&dg.transform); + let dgp = fbs::DiamondGradientPaint::create(fbb, &fbs::DiamondGradientPaintArgs { + active: dg.active, + stops: Some(stops), + opacity: dg.opacity, + blend_mode: encode_blend_mode(dg.blend_mode), + transform: Some(&transform), + }); + Some(fbs::PaintStackItem::create(fbb, &fbs::PaintStackItemArgs { + paint_type: fbs::Paint::DiamondGradientPaint, + paint: Some(dgp.as_union_value()), + })) + } } } @@ -2244,10 +2451,93 @@ fn encode_affine_to_cg_transform(t: &AffineTransform) -> fbs::CGTransform2D { ) } +// ───────────────────────────────────────────────────────────────────────────── +// Text dimension encoding helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn encode_text_dimension_from_letter_spacing<'a, A: flatbuffers::Allocator + 'a>( + fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, + ls: &TextLetterSpacing, +) -> Option>> { + let (kind, value) = match ls { + TextLetterSpacing::Fixed(v) => { + if *v == 0.0 { return None; } + (fbs::TextDimensionKind::Fixed, *v) + } + TextLetterSpacing::Factor(v) => (fbs::TextDimensionKind::Factor, *v), + }; + Some(fbs::TextDimension::create(fbb, &fbs::TextDimensionArgs { + kind, + value: Some(value), + })) +} + +fn encode_text_dimension_from_word_spacing<'a, A: flatbuffers::Allocator + 'a>( + fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, + ws: &TextWordSpacing, +) -> Option>> { + let (kind, value) = match ws { + TextWordSpacing::Fixed(v) => { + if *v == 0.0 { return None; } + (fbs::TextDimensionKind::Fixed, *v) + } + TextWordSpacing::Factor(v) => (fbs::TextDimensionKind::Factor, *v), + }; + Some(fbs::TextDimension::create(fbb, &fbs::TextDimensionArgs { + kind, + value: Some(value), + })) +} + +fn encode_text_dimension_from_line_height<'a, A: flatbuffers::Allocator + 'a>( + fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, + lh: &TextLineHeight, +) -> Option>> { + match lh { + TextLineHeight::Normal => None, + TextLineHeight::Fixed(v) => Some(fbs::TextDimension::create(fbb, &fbs::TextDimensionArgs { + kind: fbs::TextDimensionKind::Fixed, + value: Some(*v), + })), + TextLineHeight::Factor(v) => Some(fbs::TextDimension::create(fbb, &fbs::TextDimensionArgs { + kind: fbs::TextDimensionKind::Factor, + value: Some(*v), + })), + } +} + // ───────────────────────────────────────────────────────────────────────────── // Effects encoding // ───────────────────────────────────────────────────────────────────────────── +/// Encode an `FeBlur` variant into its FBS union discriminant + payload. +fn encode_fe_blur<'a, A: flatbuffers::Allocator + 'a>( + fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, + blur: &FeBlur, +) -> (fbs::FeBlur, flatbuffers::WIPOffset) { + match blur { + FeBlur::Gaussian(g) => { + let offset = + fbs::FeGaussianBlur::create(fbb, &fbs::FeGaussianBlurArgs { radius: g.radius }); + (fbs::FeBlur::FeGaussianBlur, offset.as_union_value()) + } + FeBlur::Progressive(p) => { + let fbs_start = fbs::Alignment::new(p.start.0, p.start.1); + let fbs_end = fbs::Alignment::new(p.end.0, p.end.1); + let offset = fbs::FeProgressiveBlur::create( + fbb, + &fbs::FeProgressiveBlurArgs { + start: Some(&fbs_start), + end: Some(&fbs_end), + radius: p.radius, + radius2: p.radius2, + }, + ); + (fbs::FeBlur::FeProgressiveBlur, offset.as_union_value()) + } + } +} + fn encode_layer_effects<'a, A: flatbuffers::Allocator + 'a>( fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, effects: &LayerEffects, @@ -2262,22 +2552,20 @@ fn encode_layer_effects<'a, A: flatbuffers::Allocator + 'a>( } let blur_offset = effects.blur.as_ref().map(|lb| { - let radius = match &lb.blur { FeBlur::Gaussian(g) => g.radius, _ => 0.0 }; - let gaussian = fbs::FeGaussianBlur::create(fbb, &fbs::FeGaussianBlurArgs { radius }); + let (blur_type, blur_union) = encode_fe_blur(fbb, &lb.blur); fbs::FeLayerBlur::create(fbb, &fbs::FeLayerBlurArgs { active: lb.active, - blur_type: fbs::FeBlur::FeGaussianBlur, - blur: Some(gaussian.as_union_value()), + blur_type, + blur: Some(blur_union), }) }); let backdrop_blur_offset = effects.backdrop_blur.as_ref().map(|bb| { - let radius = match &bb.blur { FeBlur::Gaussian(g) => g.radius, _ => 0.0 }; - let gaussian = fbs::FeGaussianBlur::create(fbb, &fbs::FeGaussianBlurArgs { radius }); + let (blur_type, blur_union) = encode_fe_blur(fbb, &bb.blur); fbs::FeBackdropBlur::create(fbb, &fbs::FeBackdropBlurArgs { active: bb.active, - blur_type: fbs::FeBlur::FeGaussianBlur, - blur: Some(gaussian.as_union_value()), + blur_type, + blur: Some(blur_union), }) }); @@ -2432,15 +2720,25 @@ fn encode_dimensions<'a, A: flatbuffers::Allocator + 'a>( fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, w: Option, h: Option, +) -> flatbuffers::WIPOffset> { + encode_dimensions_with_aspect(fbb, w, h, None) +} + +fn encode_dimensions_with_aspect<'a, A: flatbuffers::Allocator + 'a>( + fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, + w: Option, + h: Option, + aspect_ratio: Option<(f32, f32)>, ) -> flatbuffers::WIPOffset> { let dim_w = encode_dim_px(fbb, w); let dim_h = encode_dim_px(fbb, h); + let ar = aspect_ratio.map(|(aw, ah)| fbs::CGSize::new(aw, ah)); fbs::LayoutDimensionStyle::create( fbb, &fbs::LayoutDimensionStyleArgs { layout_target_width: dim_w, layout_target_height: dim_h, - layout_target_aspect_ratio: None, + layout_target_aspect_ratio: ar.as_ref(), }, ) } @@ -2597,7 +2895,7 @@ fn encode_container_layout<'a, A: flatbuffers::Allocator + 'a>( }, )); - let dims = encode_dimensions(fbb, dimensions.layout_target_width, dimensions.layout_target_height); + let dims = encode_dimensions_with_aspect(fbb, dimensions.layout_target_width, dimensions.layout_target_height, dimensions.layout_target_aspect_ratio); let child = encode_layout_child_style(fbb, layout_child); fbs::LayoutStyle::create( fbb, @@ -2628,19 +2926,40 @@ fn make_node_slot<'a, A: flatbuffers::Allocator + 'a>( ) } -/// Create a `StrokeGeometryTrait` from a `StrokeStyle` and a scalar width. +/// Create a `StrokeGeometryTrait` from a `StrokeStyle`, scalar width, and +/// optional variable-width profile. fn encode_stroke_geometry<'a, A: flatbuffers::Allocator + 'a>( fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>, ss: &StrokeStyle, width: f32, + profile: Option<&varwidth::VarWidthProfile>, ) -> flatbuffers::WIPOffset> { let stroke_style_offset = encode_stroke_style(fbb, ss); + let profile_offset = profile.map(|p| { + let stop_offsets: Vec<_> = p + .stops + .iter() + .map(|s| { + fbs::VariableWidthStop::create( + fbb, + &fbs::VariableWidthStopArgs { u: s.u, r: s.r }, + ) + }) + .collect(); + let stops_vec = fbb.create_vector(&stop_offsets); + fbs::VariableWidthProfile::create( + fbb, + &fbs::VariableWidthProfileArgs { + stops: Some(stops_vec), + }, + ) + }); fbs::StrokeGeometryTrait::create( fbb, &fbs::StrokeGeometryTraitArgs { stroke_style: Some(stroke_style_offset), stroke_width: width, - stroke_width_profile: None, + stroke_width_profile: profile_offset, }, ) } @@ -2863,10 +3182,10 @@ fn encode_basic_shape_node<'a, A: flatbuffers::Allocator + 'a>( let fill_offsets = encode_paints(fbb, fills); let stroke_offsets = encode_paints(fbb, strokes); - // Corner radius (scalar for polygon/star, rectangular for rectangle) + // Corner radius (scalar for polygon/star/ellipse, rectangular for rectangle) let scalar_cr = match &fields { BasicShapeFields::Rectangle(r) => r.corner_radius.tl.rx, - BasicShapeFields::Ellipse(_) => 0.0, + BasicShapeFields::Ellipse(e) => e.corner_radius.unwrap_or(0.0), BasicShapeFields::RegularPolygon(p) => p.corner_radius, BasicShapeFields::RegularStarPolygon(s) => s.corner_radius, }; @@ -2925,6 +3244,18 @@ fn encode_basic_shape_node<'a, A: flatbuffers::Allocator + 'a>( } }; + // Corner smoothing (only meaningful for rectangles) + let corner_smoothing = match &fields { + BasicShapeFields::Rectangle(r) => r.corner_smoothing.0, + _ => 0.0, + }; + + // Rectangular stroke width (only for rectangles) + let rect_sw = match &fields { + BasicShapeFields::Rectangle(r) => encode_rectangular_stroke_width(&r.stroke_width), + _ => None, + }; + let bsn = fbs::BasicShapeNode::create(fbb, &fbs::BasicShapeNodeArgs { node: Some(sys), layer: Some(layer), type_: node_type, shape_type, shape: Some(shape_offset), @@ -2932,6 +3263,8 @@ fn encode_basic_shape_node<'a, A: flatbuffers::Allocator + 'a>( stroke_style: Some(stroke_style_offset), stroke_width: stroke_width_f32, rectangular_corner_radius: rect_cr.as_ref(), stroke_paints: stroke_offsets, + corner_smoothing, + rectangular_stroke_width: rect_sw.as_ref(), ..Default::default() }); @@ -2972,7 +3305,7 @@ fn encode_line_node<'a, A: flatbuffers::Allocator + 'a>( stroke_join: StrokeJoin::Miter, stroke_miter_limit: r.stroke_miter_limit, stroke_dash_array: r.stroke_dash_array.clone(), - }, r.stroke_width); + }, r.stroke_width, None); let stroke_offsets = encode_paints(fbb, &r.strokes); @@ -3021,7 +3354,7 @@ fn encode_vector_node<'a, A: flatbuffers::Allocator + 'a>( stroke_join: r.stroke_join, stroke_miter_limit: r.stroke_miter_limit, stroke_dash_array: r.stroke_dash_array.clone(), - }, r.stroke_width); + }, r.stroke_width, r.stroke_width_profile.as_ref()); let fill_offsets = encode_paints(fbb, &r.fills); let stroke_offsets = encode_paints(fbb, &r.strokes); @@ -3065,13 +3398,68 @@ fn encode_text_span_node<'a, A: flatbuffers::Allocator + 'a>( ); // Text style - let font_family_str = fbb.create_string(&r.text_style.font_family); - let font_weight = fbs::FontWeight::new(r.text_style.font_weight.0); + let ts = &r.text_style; + let font_family_str = fbb.create_string(&ts.font_family); + let font_weight = fbs::FontWeight::new(ts.font_weight.0); + let fbs_font_optical_sizing = match ts.font_optical_sizing { + FontOpticalSizing::Auto => fbs::FontOpticalSizing::new(fbs::FontOpticalSizingKind::Auto, 0.0), + FontOpticalSizing::None => fbs::FontOpticalSizing::new(fbs::FontOpticalSizingKind::None, 0.0), + FontOpticalSizing::Fixed(v) => fbs::FontOpticalSizing::new(fbs::FontOpticalSizingKind::Fixed, v), + }; + let text_decoration_offset = ts.text_decoration.as_ref().map(|td| { + let color = td.text_decoration_color.as_ref().map(|c| encode_color_to_rgba32f(c)); + fbs::TextDecorationRec::create(fbb, &fbs::TextDecorationRecArgs { + text_decoration_line: encode_text_decoration_line(td.text_decoration_line), + text_decoration_style: encode_text_decoration_style(td.text_decoration_style.unwrap_or_default()), + text_decoration_skip_ink: td.text_decoration_skip_ink.unwrap_or(true), + text_decoration_thickness: td.text_decoration_thickness.unwrap_or(0.0), + text_decoration_color: color.as_ref(), + }) + }); + let letter_spacing_offset = encode_text_dimension_from_letter_spacing(fbb, &ts.letter_spacing); + let word_spacing_offset = encode_text_dimension_from_word_spacing(fbb, &ts.word_spacing); + let line_height_offset = encode_text_dimension_from_line_height(fbb, &ts.line_height); + let font_features_offset = ts.font_features.as_ref().map(|features| { + let items: Vec<_> = features.iter().map(|f| { + let bytes = f.tag.as_bytes(); + let tag = fbs::OpenTypeFeatureTag::new( + *bytes.first().unwrap_or(&0), + *bytes.get(1).unwrap_or(&0), + *bytes.get(2).unwrap_or(&0), + *bytes.get(3).unwrap_or(&0), + ); + fbs::FontFeature::create(fbb, &fbs::FontFeatureArgs { + open_type_feature_tag: Some(&tag), + open_type_feature_value: f.value, + }) + }).collect(); + fbb.create_vector(&items) + }); + let font_variations_offset = ts.font_variations.as_ref().map(|variations| { + let items: Vec<_> = variations.iter().map(|v| { + let axis_str = fbb.create_string(&v.axis); + fbs::FontVariation::create(fbb, &fbs::FontVariationArgs { + variation_axis: Some(axis_str), + variation_value: v.value, + }) + }).collect(); + fbb.create_vector(&items) + }); let text_style = fbs::TextStyleRec::create(fbb, &fbs::TextStyleRecArgs { font_family: Some(font_family_str), - font_size: r.text_style.font_size, + font_size: ts.font_size, font_weight: Some(&font_weight), - ..Default::default() + font_style_italic: ts.font_style_italic, + font_kerning: ts.font_kerning, + font_width: ts.font_width.unwrap_or(0.0), + font_optical_sizing: Some(&fbs_font_optical_sizing), + text_transform: encode_text_transform(ts.text_transform), + text_decoration: text_decoration_offset, + letter_spacing: letter_spacing_offset, + word_spacing: word_spacing_offset, + line_height: line_height_offset, + font_features: font_features_offset, + font_variations: font_variations_offset, }); let text_str = fbb.create_string(&r.text); @@ -3084,15 +3472,17 @@ fn encode_text_span_node<'a, A: flatbuffers::Allocator + 'a>( stroke_join: StrokeJoin::Miter, stroke_miter_limit: StrokeMiterLimit::default(), stroke_dash_array: None, - }, r.stroke_width); + }, r.stroke_width, None); + let ellipsis_offset = r.ellipsis.as_ref().map(|s| fbb.create_string(s)); let props = fbs::TextSpanNodeProperties::create(fbb, &fbs::TextSpanNodePropertiesArgs { text: Some(text_str), text_style: Some(text_style), text_align: encode_text_align(r.text_align), text_align_vertical: encode_text_align_vertical(r.text_align_vertical), fill_paints: fill_offsets, stroke_paints: stroke_offsets, stroke_geometry: Some(sg), - ..Default::default() + max_lines: r.max_lines.map(|v| v as u32).unwrap_or(0), + ellipsis: ellipsis_offset, }); let tn = fbs::TextSpanNode::create(fbb, &fbs::TextSpanNodeArgs { @@ -3139,7 +3529,7 @@ fn encode_boolean_operation_node<'a, A: flatbuffers::Allocator + 'a>( cr_trait }); - let sg = encode_stroke_geometry(fbb, &r.stroke_style, r.stroke_width.0.unwrap_or(0.0)); + let sg = encode_stroke_geometry(fbb, &r.stroke_style, r.stroke_width.0.unwrap_or(0.0), None); let fill_offsets = encode_paints(fbb, &r.fills); let stroke_offsets = encode_paints(fbb, &r.strokes); diff --git a/crates/grida-canvas/src/io/io_grida_file.rs b/crates/grida-canvas/src/io/io_grida_file.rs new file mode 100644 index 0000000000..d5c01f97aa --- /dev/null +++ b/crates/grida-canvas/src/io/io_grida_file.rs @@ -0,0 +1,261 @@ +//! `.grida` / `.grida1` file format detection and unified decoding. +//! +//! A `.grida` file can be one of three variants: +//! +//! | Variant | Magic / Signature | +//! |----------------|------------------------------------------| +//! | Raw FlatBuffers| `"GRID"` identifier at bytes 4–7 | +//! | ZIP archive | PK magic (`\x50\x4b\x03\x04`) | +//! | JSON (legacy) | Starts with `{` or `[` (UTF-8) | +//! +//! This module provides: +//! - [`detect`] — sniff the format from raw bytes. +//! - [`decode`] / [`decode_all`] — unified decode that auto-dispatches. +//! - [`decode_with_id_map`] — full decode with internal→string ID mapping +//! (FBS/ZIP only). +//! +//! # Example +//! +//! ```no_run +//! use cg::io::io_grida_file; +//! +//! let bytes = std::fs::read("scene.grida").unwrap(); +//! let scenes = io_grida_file::decode_all(&bytes).unwrap(); +//! println!("decoded {} scene(s)", scenes.len()); +//! ``` + +use std::fmt; +use std::io::{Cursor, Read}; + +use crate::io::{id_converter::IdConverter, io_grida, io_grida_fbs}; +use crate::node::schema::Scene; + +// ───────────────────────────────────────────────────────────────────────────── +// Format detection +// ───────────────────────────────────────────────────────────────────────────── + +/// Detected variant of a `.grida` / `.grida1` file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// Raw FlatBuffers binary (`"GRID"` file identifier at bytes 4–7). + RawFbs, + /// ZIP archive containing `manifest.json` + `document.grida`. + Zip, + /// Legacy JSON format (starts with `{` or `[`). + Json, + /// Unrecognized format. + Unknown, +} + +impl fmt::Display for Format { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Format::RawFbs => write!(f, "Raw FlatBuffers"), + Format::Zip => write!(f, "ZIP archive"), + Format::Json => write!(f, "JSON"), + Format::Unknown => write!(f, "Unknown"), + } + } +} + +/// Detect the file format from raw bytes. +pub fn detect(bytes: &[u8]) -> Format { + // ZIP magic: PK\x03\x04 or PK\x05\x06 (end-of-central-dir) + if bytes.len() >= 4 + && bytes[0] == 0x50 + && bytes[1] == 0x4b + && ((bytes[2] == 0x03 && bytes[3] == 0x04) || (bytes[2] == 0x05 && bytes[3] == 0x06)) + { + return Format::Zip; + } + // Raw FlatBuffers: file identifier "GRID" at bytes 4–7 + if bytes.len() >= 8 && &bytes[4..8] == b"GRID" { + return Format::RawFbs; + } + // JSON: first non-whitespace/BOM byte is '{' or '[' + let first_significant = bytes + .iter() + .position(|&b| !b.is_ascii_whitespace() && b != 0xEF && b != 0xBB && b != 0xBF) + .map(|i| bytes[i]) + .unwrap_or(0); + if first_significant == b'{' || first_significant == b'[' { + return Format::Json; + } + Format::Unknown +} + +// ───────────────────────────────────────────────────────────────────────────── +// Error type +// ───────────────────────────────────────────────────────────────────────────── + +/// Errors that can occur when decoding a `.grida` file. +#[derive(Debug)] +pub enum DecodeError { + /// The file format could not be recognized. + UnrecognizedFormat, + /// An error occurred while reading the ZIP archive. + Zip(String), + /// FlatBuffers decode error. + Fbs(io_grida_fbs::FbsDecodeError), + /// JSON parse error. + Json(String), + /// JSON-to-scene conversion error. + Conversion(String), + /// Attempted an FBS-only operation on a JSON file. + JsonNotSupported, + /// The decoded file contained no scenes. + NoScenes, +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DecodeError::UnrecognizedFormat => write!( + f, + "unrecognized file format: expected .grida FlatBuffers, ZIP, or JSON" + ), + DecodeError::Zip(msg) => write!(f, "ZIP error: {msg}"), + DecodeError::Fbs(e) => write!(f, "FlatBuffers decode error: {e}"), + DecodeError::Json(msg) => write!(f, "JSON parse error: {msg}"), + DecodeError::Conversion(msg) => write!(f, "scene conversion error: {msg}"), + DecodeError::JsonNotSupported => write!( + f, + "this operation requires FlatBuffers or ZIP format (JSON is not supported)" + ), + DecodeError::NoScenes => write!(f, "no scenes found in .grida file"), + } + } +} + +impl std::error::Error for DecodeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + DecodeError::Fbs(e) => Some(e), + _ => None, + } + } +} + +impl From for DecodeError { + fn from(e: io_grida_fbs::FbsDecodeError) -> Self { + DecodeError::Fbs(e) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// ZIP extraction +// ───────────────────────────────────────────────────────────────────────────── + +/// Hard limit on the uncompressed size of `document.grida` inside a ZIP +/// archive. Protects against zip bombs and absurd allocations. +/// Set to 8 GiB (2³³) — design files with many embedded images can be large. +const MAX_UNCOMPRESSED_SIZE: u64 = 1 << 33; // 8 GiB + +fn extract_fbs_from_zip(bytes: &[u8]) -> Result, DecodeError> { + let cursor = Cursor::new(bytes); + let mut archive = + zip::ZipArchive::new(cursor).map_err(|e| DecodeError::Zip(format!("open: {e}")))?; + + if archive.by_name("manifest.json").is_err() { + return Err(DecodeError::Zip("missing manifest.json".into())); + } + + let mut doc = archive + .by_name("document.grida") + .map_err(|e| DecodeError::Zip(format!("missing document.grida: {e}")))?; + + // Reject files whose announced uncompressed size exceeds the limit. + let announced_size = doc.size(); + if announced_size > MAX_UNCOMPRESSED_SIZE { + return Err(DecodeError::Zip(format!( + "document.grida uncompressed size ({announced_size} bytes) exceeds limit ({MAX_UNCOMPRESSED_SIZE} bytes)" + ))); + } + + // Use a bounded reader to guard against actual data exceeding the limit + // (the announced size may be smaller than the real content in a crafted ZIP). + let alloc_hint = std::cmp::min(announced_size, MAX_UNCOMPRESSED_SIZE) as usize; + let mut fbs_bytes = Vec::with_capacity(alloc_hint); + // Read::take() on a mutable reference avoids consuming `doc`. + (&mut doc) + .take(MAX_UNCOMPRESSED_SIZE + 1) + .read_to_end(&mut fbs_bytes) + .map_err(|e| DecodeError::Zip(format!("read document.grida: {e}")))?; + + if fbs_bytes.len() as u64 > MAX_UNCOMPRESSED_SIZE { + return Err(DecodeError::Zip(format!( + "document.grida actual size ({} bytes) exceeds limit ({MAX_UNCOMPRESSED_SIZE} bytes)", + fbs_bytes.len() + ))); + } + + Ok(fbs_bytes) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Resolve raw FBS bytes from any format (FBS passthrough, ZIP extraction) +// ───────────────────────────────────────────────────────────────────────────── + +/// Given raw file bytes, resolve to the FlatBuffers payload. +/// +/// - `RawFbs` → returns the bytes as-is. +/// - `Zip` → extracts `document.grida` from the archive. +/// - `Json` / `Unknown` → returns an error (no FBS payload). +fn resolve_fbs_bytes(bytes: &[u8]) -> Result, DecodeError> { + match detect(bytes) { + Format::RawFbs => Ok(bytes.to_vec()), + Format::Zip => extract_fbs_from_zip(bytes), + Format::Json => Err(DecodeError::JsonNotSupported), + Format::Unknown => Err(DecodeError::UnrecognizedFormat), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Decode — JSON path +// ───────────────────────────────────────────────────────────────────────────── + +fn decode_json(bytes: &[u8]) -> Result, DecodeError> { + let json_str = std::str::from_utf8(bytes) + .map_err(|e| DecodeError::Json(format!("invalid UTF-8: {e}")))?; + let file = io_grida::parse(json_str).map_err(|e| DecodeError::Json(format!("{e}")))?; + let mut converter = IdConverter::new(); + let scene = converter + .convert_json_canvas_file(file) + .map_err(|e| DecodeError::Conversion(e.to_string()))?; + Ok(vec![scene]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Decode the first scene from a `.grida` file (any format). +pub fn decode(bytes: &[u8]) -> Result { + decode_all(bytes)? + .into_iter() + .next() + .ok_or(DecodeError::NoScenes) +} + +/// Decode all scenes from a `.grida` file (any format). +pub fn decode_all(bytes: &[u8]) -> Result, DecodeError> { + match detect(bytes) { + Format::RawFbs => Ok(io_grida_fbs::decode_all(bytes)?), + Format::Zip => { + let fbs_bytes = extract_fbs_from_zip(bytes)?; + Ok(io_grida_fbs::decode_all(&fbs_bytes)?) + } + Format::Json => decode_json(bytes), + Format::Unknown => Err(DecodeError::UnrecognizedFormat), + } +} + +/// Decode a `.grida` file (FBS or ZIP) and return scenes plus the +/// internal→string ID mapping. +/// +/// JSON files are not supported here because the JSON codec does not preserve +/// the FBS-level ID map. Use [`decode_all`] for JSON files. +pub fn decode_with_id_map(bytes: &[u8]) -> Result { + let fbs_bytes = resolve_fbs_bytes(bytes)?; + Ok(io_grida_fbs::decode_with_id_map(&fbs_bytes)?) +} diff --git a/crates/grida-canvas/src/io/mod.rs b/crates/grida-canvas/src/io/mod.rs index 70f8ad0c24..2d2923886f 100644 --- a/crates/grida-canvas/src/io/mod.rs +++ b/crates/grida-canvas/src/io/mod.rs @@ -5,6 +5,7 @@ pub mod io_grida_fbs; pub mod id_converter; pub mod io_css; pub mod io_grida; +pub mod io_grida_file; pub mod io_grida_patch; pub mod io_markdown; pub mod io_svg; diff --git a/crates/grida-canvas/src/layout/engine.rs b/crates/grida-canvas/src/layout/engine.rs index 6564fb3abd..1f76eeed8a 100644 --- a/crates/grida-canvas/src/layout/engine.rs +++ b/crates/grida-canvas/src/layout/engine.rs @@ -88,7 +88,10 @@ impl LayoutEngine { // Build and compute layout for each root subtree for root_id in &roots { - if let Some(root_taffy_id) = self.build_taffy_subtree(root_id, graph, viewport_size) { + let mut extra_roots = Vec::new(); + if let Some(root_taffy_id) = + self.build_taffy_subtree(root_id, graph, viewport_size, &mut extra_roots) + { // Compute layout with viewport as available space let _ = self.tree.compute_layout( root_taffy_id, @@ -98,10 +101,27 @@ impl LayoutEngine { }, text_measure.as_mut(), ); + } - // Extract all computed layouts - self.extract_all_layouts(root_id, graph); + // Compute layout for Taffy subtrees discovered under non-Taffy + // parents (e.g. Containers nested under Group/BooleanOperation). + for extra_taffy_id in extra_roots { + let _ = self.tree.compute_layout( + extra_taffy_id, + taffy::Size { + width: AvailableSpace::Definite(viewport_size.width), + height: AvailableSpace::Definite(viewport_size.height), + }, + text_measure.as_mut(), + ); } + + // Extract layouts for ALL roots, including those that don't participate + // in Taffy (e.g. BooleanOperation, Group). extract_all_layouts() handles + // non-Taffy nodes by creating manual layout results from schema data. + // Without this, descendants of non-Taffy root nodes would be orphaned + // from layout results, causing panics in GeometryCache. + self.extract_all_layouts(root_id, graph); } &self.result @@ -220,20 +240,37 @@ impl LayoutEngine { /// This universal method handles all node types without switch-case logic. /// Each node gets an appropriate Taffy style based on its type and properties. /// - /// Nodes without layout_child support are skipped from Taffy tree but still - /// get layout results created manually from their schema. + /// Nodes without layout_child support (Group, BooleanOperation) are skipped + /// from the Taffy tree themselves but their children are still visited so + /// that Taffy-capable descendants (e.g. Containers) receive proper flex + /// layout instead of only schema fallbacks. Any Taffy subtrees discovered + /// under non-Taffy parents are collected in `extra_roots` so the caller + /// can compute layout for them. fn build_taffy_subtree( &mut self, node_id: &NodeId, graph: &SceneGraph, viewport_size: Size, + extra_roots: &mut Vec, ) -> Option { let node = graph.get_node(node_id).ok()?; - // Nodes that don't participate in Taffy layout (Vector, SVGPath, Group, etc.) - // are skipped and get manual layout results created in extract_all_layouts() + // Nodes that don't participate in Taffy layout (Group, BooleanOperation) + // are skipped themselves, but we still recurse into their children so + // Taffy-capable descendants get proper layout computation. if !Self::should_participate_in_taffy(node) { - return None; // Skip Taffy, use manual layout result from schema + if let Some(children) = graph.get_children(node_id) { + for child_id in children { + if let Some(taffy_id) = + self.build_taffy_subtree(child_id, graph, viewport_size, extra_roots) + { + // This child forms an independent Taffy subtree root + // under a non-Taffy parent; collect it for layout computation. + extra_roots.push(taffy_id); + } + } + } + return None; // This node itself is not in the Taffy tree } // Get style for this node (universal mapping) @@ -260,7 +297,9 @@ impl LayoutEngine { // Build children recursively, filtering out those that shouldn't participate let taffy_children: Vec = children .iter() - .filter_map(|child_id| self.build_taffy_subtree(child_id, graph, viewport_size)) + .filter_map(|child_id| { + self.build_taffy_subtree(child_id, graph, viewport_size, extra_roots) + }) .collect(); // Create parent with children @@ -1475,4 +1514,208 @@ mod tests { "Vector should be positioned at transform.y" ); } + + /// Test: Root-level BooleanOperation with Container descendants + /// + /// Reproduces the panic from geometry.rs:307: + /// "Container must have layout result when layout engine is used" + /// + /// BooleanOperation nodes don't participate in Taffy layout, so when one is + /// a scene root, build_taffy_subtree() returns None. Previously, compute() + /// would skip extract_all_layouts() entirely for that root, leaving all + /// descendant containers without layout results. + #[test] + fn test_root_boolean_operation_with_container_descendants() { + use crate::vectornetwork::*; + let nf = NodeFactory::new(); + let mut graph = SceneGraph::new(); + + // Root: BooleanOperation (does NOT participate in Taffy) + let boolean_node = BooleanPathOperationNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::default(), + mask: None, + effects: LayerEffects::default(), + transform: Some(AffineTransform::new(10.0, 20.0, 0.0)), + op: BooleanPathOperation::Union, + corner_radius: None, + fills: Paints::default(), + strokes: Paints::default(), + stroke_style: StrokeStyle::default(), + stroke_width: SingularStrokeWidth(None), + }; + let bool_id = graph.append_child(Node::BooleanOperation(boolean_node), Parent::Root); + + // Child 1: Container (must get layout result) + let mut container = nf.create_container_node(); + container.layout_dimensions.layout_target_width = Some(200.0); + container.layout_dimensions.layout_target_height = Some(100.0); + let container_id = + graph.append_child(Node::Container(container), Parent::NodeId(bool_id)); + + // Grandchild: Rectangle inside the container + let mut rect = nf.create_rectangle_node(); + rect.size = Size { + width: 50.0, + height: 50.0, + }; + let rect_id = graph.append_child(Node::Rectangle(rect), Parent::NodeId(container_id)); + + // Child 2: Another Container nested deeper + let mut container2 = nf.create_container_node(); + container2.layout_dimensions.layout_target_width = Some(80.0); + container2.layout_dimensions.layout_target_height = Some(60.0); + let container2_id = + graph.append_child(Node::Container(container2), Parent::NodeId(container_id)); + + // Great-grandchild: Vector inside container2 + let vector_node = VectorNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::default(), + mask: None, + effects: LayerEffects::default(), + transform: AffineTransform::new(0.0, 0.0, 0.0), + network: VectorNetwork { + vertices: vec![(0.0, 0.0), (30.0, 0.0), (30.0, 30.0)], + segments: vec![ + VectorNetworkSegment::ab(0, 1), + VectorNetworkSegment::ab(1, 2), + VectorNetworkSegment::ab(2, 0), + ], + regions: vec![], + }, + corner_radius: 0.0, + fills: Paints::default(), + strokes: Paints::default(), + stroke_width: 0.0, + stroke_width_profile: None, + stroke_align: StrokeAlign::Inside, + stroke_cap: StrokeCap::default(), + stroke_join: StrokeJoin::default(), + stroke_miter_limit: StrokeMiterLimit::default(), + stroke_dash_array: None, + marker_start_shape: StrokeMarkerPreset::default(), + marker_end_shape: StrokeMarkerPreset::default(), + layout_child: None, + }; + let vector_id = + graph.append_child(Node::Vector(vector_node), Parent::NodeId(container2_id)); + + let scene = Scene { + name: "Root boolean with container descendants".to_string(), + graph, + background_color: None, + }; + + // This used to panic: containers under a non-taffy root had no layout results + let mut engine = LayoutEngine::new(); + let result = engine.compute( + &scene, + Size { + width: 800.0, + height: 600.0, + }, + None, + ); + + // All nodes must have layout results + assert!( + result.get(&bool_id).is_some(), + "Root BooleanOperation must have layout result" + ); + assert!( + result.get(&container_id).is_some(), + "Container under BooleanOperation must have layout result" + ); + assert!( + result.get(&rect_id).is_some(), + "Rectangle under Container must have layout result" + ); + assert!( + result.get(&container2_id).is_some(), + "Nested Container must have layout result" + ); + assert!( + result.get(&vector_id).is_some(), + "Vector under nested Container must have layout result" + ); + + // Verify the BooleanOperation gets schema position + let bool_layout = result.get(&bool_id).unwrap(); + assert_eq!(bool_layout.x, 10.0, "BooleanOperation x from transform"); + assert_eq!(bool_layout.y, 20.0, "BooleanOperation y from transform"); + + // Verify container dimensions from schema + let container_layout = result.get(&container_id).unwrap(); + assert_eq!(container_layout.width, 200.0); + assert_eq!(container_layout.height, 100.0); + + let container2_layout = result.get(&container2_id).unwrap(); + assert_eq!(container2_layout.width, 80.0); + assert_eq!(container2_layout.height, 60.0); + } + + /// Test: Root-level Group with Container descendants + /// + /// Same scenario as BooleanOperation — Group also doesn't participate in Taffy. + #[test] + fn test_root_group_with_container_descendants() { + let nf = NodeFactory::new(); + let mut graph = SceneGraph::new(); + + // Root: Group (does NOT participate in Taffy) + let group = nf.create_group_node(); + let group_id = graph.append_child(Node::Group(group), Parent::Root); + + // Child: Container + let mut container = nf.create_container_node(); + container.layout_dimensions.layout_target_width = Some(150.0); + container.layout_dimensions.layout_target_height = Some(75.0); + let container_id = + graph.append_child(Node::Container(container), Parent::NodeId(group_id)); + + // Grandchild: Rectangle + let mut rect = nf.create_rectangle_node(); + rect.size = Size { + width: 40.0, + height: 40.0, + }; + let rect_id = graph.append_child(Node::Rectangle(rect), Parent::NodeId(container_id)); + + let scene = Scene { + name: "Root group with container descendants".to_string(), + graph, + background_color: None, + }; + + let mut engine = LayoutEngine::new(); + let result = engine.compute( + &scene, + Size { + width: 800.0, + height: 600.0, + }, + None, + ); + + // All nodes must have layout results + assert!( + result.get(&group_id).is_some(), + "Root Group must have layout result" + ); + assert!( + result.get(&container_id).is_some(), + "Container under Group must have layout result" + ); + assert!( + result.get(&rect_id).is_some(), + "Rectangle under Container must have layout result" + ); + + let container_layout = result.get(&container_id).unwrap(); + assert_eq!(container_layout.width, 150.0); + assert_eq!(container_layout.height, 75.0); + } } diff --git a/crates/grida-dev/src/grida_file.rs b/crates/grida-dev/src/grida_file.rs index 001178839b..261e3b1eba 100644 --- a/crates/grida-dev/src/grida_file.rs +++ b/crates/grida-dev/src/grida_file.rs @@ -1,100 +1,32 @@ -//! `.grida` file format detection and decoding. -//! -//! A `.grida` file is one of two variants: -//! - **Raw FlatBuffers** – identified by the `"GRID"` file identifier at bytes 4–7. -//! - **ZIP archive** – identified by PK magic bytes; contains `manifest.json` and -//! `document.grida` (a raw FlatBuffers binary) plus optional `images/` and `bitmaps/`. +//! `.grida` file format detection and decoding — thin wrapper around +//! [`cg::io::io_grida_file`] with `anyhow::Result` ergonomics. -use anyhow::{anyhow, Context, Result}; -use cg::io::{id_converter::IdConverter, io_grida, io_grida_fbs}; +use anyhow::{anyhow, Result}; +use cg::io::{io_grida_fbs, io_grida_file}; use cg::node::schema::Scene; -/// Detected variant of a `.grida` file. -enum Format { - RawFbs, - Zip, - Json, - Unknown, -} - -fn detect(bytes: &[u8]) -> Format { - // ZIP magic: PK\x03\x04 or PK\x05\x06 - if bytes.len() >= 4 - && bytes[0] == 0x50 - && bytes[1] == 0x4b - && ((bytes[2] == 0x03 && bytes[3] == 0x04) || (bytes[2] == 0x05 && bytes[3] == 0x06)) - { - return Format::Zip; - } - // Raw FlatBuffers: file identifier "GRID" at bytes 4–7 - if bytes.len() >= 8 && &bytes[4..8] == b"GRID" { - return Format::RawFbs; - } - // JSON: starts with '{' or '[' (after optional UTF-8 BOM / whitespace) - let trimmed = bytes - .iter() - .position(|&b| !b.is_ascii_whitespace() && b != 0xEF && b != 0xBB && b != 0xBF) - .map(|i| bytes[i]) - .unwrap_or(0); - if trimmed == b'{' || trimmed == b'[' { - return Format::Json; - } - Format::Unknown -} +/// Re-export the format enum for callers that need it. +#[allow(unused_imports)] +pub use io_grida_file::Format; -/// Extracts the raw FlatBuffers bytes from a `.grida` ZIP archive. -fn extract_fbs_from_zip(bytes: &[u8]) -> Result> { - use std::io::{Cursor, Read}; - let cursor = Cursor::new(bytes); - let mut archive = zip::ZipArchive::new(cursor).context("failed to open .grida ZIP")?; - - anyhow::ensure!( - archive.by_name("manifest.json").is_ok(), - ".grida ZIP is missing manifest.json" - ); - - let mut doc_file = archive - .by_name("document.grida") - .context(".grida ZIP is missing document.grida")?; - let mut fbs_bytes = Vec::with_capacity(doc_file.size() as usize); - doc_file - .read_to_end(&mut fbs_bytes) - .context("failed to read document.grida from ZIP")?; - Ok(fbs_bytes) -} - -/// Decodes a `.grida` file (raw FlatBuffers or ZIP) into a [`Scene`]. +/// Decodes a `.grida` file (any format) into a [`Scene`]. /// /// If the file contains multiple scenes, only the first is returned. /// Use [`decode_all`] to get all scenes. #[allow(dead_code)] pub fn decode(bytes: &[u8]) -> Result { - decode_all(bytes)? - .into_iter() - .next() - .ok_or_else(|| anyhow!("no scenes found in .grida file")) + io_grida_file::decode(bytes).map_err(|e| anyhow!("{e}")) } -/// Decodes a `.grida` file into all [`Scene`]s it contains. +/// Decodes a `.grida` file (any format) into all [`Scene`]s it contains. pub fn decode_all(bytes: &[u8]) -> Result> { - match detect(bytes) { - Format::RawFbs => io_grida_fbs::decode_all(bytes).map_err(|err| anyhow!("{err}")), - Format::Zip => { - let fbs_bytes = extract_fbs_from_zip(bytes)?; - io_grida_fbs::decode_all(&fbs_bytes).map_err(|err| anyhow!("{err}")) - } - Format::Json => { - let json_str = std::str::from_utf8(bytes).context("invalid UTF-8 in JSON scene")?; - let file = io_grida::parse(json_str) - .map_err(|err| anyhow!("failed to parse JSON scene: {err}"))?; - let mut converter = IdConverter::new(); - let scene = converter - .convert_json_canvas_file(file) - .map_err(|err| anyhow!("failed to convert JSON scene: {err}"))?; - Ok(vec![scene]) - } - Format::Unknown => Err(anyhow!( - "unrecognized file format: expected a .grida FlatBuffers binary, ZIP archive, or JSON" - )), - } + io_grida_file::decode_all(bytes).map_err(|e| anyhow!("{e}")) +} + +/// Decodes a `.grida` file (FBS or ZIP) with internal→string ID mapping. +/// +/// Returns an error for JSON input. +#[allow(dead_code)] +pub fn decode_with_id_map(bytes: &[u8]) -> Result { + io_grida_file::decode_with_id_map(bytes).map_err(|e| anyhow!("{e}")) } diff --git a/fixtures/test-grida/L0.grida b/fixtures/test-grida/L0.grida index e9dd4edf40..eb8a6f08dd 100644 Binary files a/fixtures/test-grida/L0.grida and b/fixtures/test-grida/L0.grida differ diff --git a/fixtures/test-grida/bench.grida b/fixtures/test-grida/bench.grida index e11f4f9663..3c4cbaff57 100644 Binary files a/fixtures/test-grida/bench.grida and b/fixtures/test-grida/bench.grida differ diff --git a/fixtures/test-grida/cover.grida b/fixtures/test-grida/cover.grida new file mode 100644 index 0000000000..66e720c71f Binary files /dev/null and b/fixtures/test-grida/cover.grida differ diff --git a/format/README.md b/format/README.md index 6855b3f6ff..e1f27ce8a8 100644 --- a/format/README.md +++ b/format/README.md @@ -61,6 +61,11 @@ python3 bin/activate-flatc -- --rust -o /tmp/grida-fbs-gen/rust format/grida.fbs > > **Contributor workflow**: after editing `grida.fbs`, run `pnpm build` (or the individual `prebuild` scripts in each package) to regenerate bindings, then commit the updated generated files alongside your schema change. +## References + +- [Adobe Photoshop File Format Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/) — PSD/PSB structure, image resources, layer and mask info; useful when comparing or aligning design-tool format concepts. +- [Figma .fig (Kiwi) format](../.ref/figma/README.md) — In-repo: extracted Kiwi schema (`fig.kiwi`) and tooling for Figma’s binary `.fig` format; see `/.ref/figma/`. + ## Changelog See [CHANGELOG.md](./CHANGELOG.md).