diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs new file mode 100644 index 000000000..e9fd6b3ee --- /dev/null +++ b/crates/op-pen-loader/src/layout_scene.rs @@ -0,0 +1,124 @@ +//! `EditorState` → [`LayoutScene`] builder. +//! +//! Produces the paint-only, layout-resolved render scene that a +//! future `CanvasViewport` painter walks instead of the editor's +//! `Document`. +//! +//! The flex layout pass is NOT re-implemented here. `EditorState.doc` +//! is a `PenDocument`; [`pen_document_to_document`] already runs each +//! page-root through jian-core's taffy `LayoutEngine` + +//! `jian_skia::SkiaMeasure` (see `adapter.rs`) and bakes the resolved +//! absolute AABBs into a shell-core `Document`. This builder reuses +//! that exact resolved `Document` and re-shapes its `Node` tree into +//! [`SceneNode`]s, dropping all editor state (selection / chat / +//! history / ui) and resolving variable `$ref` fills against the +//! editor's variables + active theme. +//! +//! So the resolved geometry a `LayoutScene` carries is bit-identical +//! to what `pen_document_to_document` bakes today — there is one +//! layout pass and one set of resolved rects. + +use openpencil_shell_core::document::{Document, Node, VariableTable}; +use openpencil_shell_core::layout_scene::{ + LayoutScene, SceneFillType, SceneNode, SceneStroke, ScenePage, +}; + +use crate::editor_state_var_table; +use crate::payload::pen_document_to_document; + +/// Build a paint-only [`LayoutScene`] from an editor state. +/// +/// Runs the same jian `LayoutEngine` + `SkiaMeasure` flex pass that +/// [`pen_document_to_document`] uses (by delegating to it), resolves +/// variable `$ref` fills / strokes against the editor's variables + +/// active theme (via [`editor_state_var_table`]), and re-shapes the +/// resolved node tree into a render scene that carries NO editor +/// state. +pub fn editor_state_to_layout_scene(state: &op_editor_core::EditorState) -> LayoutScene { + // The resolved `Document` — flex layout already baked into AABBs. + let doc: Document = pen_document_to_document(&state.doc); + // Variables + active theme + the `fill_refs` / `stroke_refs` + // caches the editor holds. `pen_document_to_document` returns a + // `Document` whose `var_table` only carries the persisted + // definitions; the transient `fill_refs` / `stroke_refs` live on + // `EditorState.ui` — so resolve `$ref`s against the editor-state + // table, which folds both halves together. + let var_table: VariableTable = editor_state_var_table(state); + + LayoutScene { + pages: doc + .pages + .iter() + .map(|page| ScenePage { + id: page.id.as_str().to_string(), + name: page.name.clone(), + children: page + .children + .iter() + .map(|n| node_to_scene(n, &var_table)) + .collect(), + }) + .collect(), + active_page_index: doc.active_page_index, + } +} + +/// Convert one resolved shell-core [`Node`] into a [`SceneNode`]. +/// +/// Geometry is copied straight through — `pen_document_to_document` +/// already resolved it. Variable `$ref` fills / strokes are resolved +/// here so the scene carries only concrete colours; a registered ref +/// wins over the node's authored colour, mirroring the canvas +/// painter's `var_table.fill_for(id).or(node.fill)`. +fn node_to_scene(node: &Node, var_table: &VariableTable) -> SceneNode { + SceneNode { + id: node.id.as_str().to_string(), + kind: node.kind.clone(), + bounds: node.bounds, + rotation: node.rotation, + corner_radius: node.corner_radius, + // Paint-time `$ref` resolution: a registered fill ref wins, + // else the node's own fill. Same precedence as the canvas + // painter's `node_fill` helper. + fill: var_table.fill_for(&node.id).or(node.fill), + fill_type: fill_type_to_scene(node.fill_type), + stroke: node.stroke.map(|s| SceneStroke { + // Stroke `$ref` resolution parallels the fill path. + color: var_table + .stroke_color_for(&node.id) + .unwrap_or(s.color), + width: s.width, + }), + text: node.text.clone(), + font_size: node.font_size, + font_weight: node.font_weight, + text_wrap: node.text_wrap, + points: node.points.clone(), + effects: node.effects.clone(), + hidden: node.hidden, + children: node + .children + .iter() + .map(|c| node_to_scene(c, var_table)) + .collect(), + } +} + +/// Map shell-core's editor-model `FillType` onto the scene's own +/// `SceneFillType`. A dedicated enum keeps `LayoutScene` from +/// re-exporting an editor-model type that may diverge. +fn fill_type_to_scene( + ft: openpencil_shell_core::document::FillType, +) -> SceneFillType { + use openpencil_shell_core::document::FillType; + match ft { + FillType::Solid => SceneFillType::Solid, + FillType::LinearGradient => SceneFillType::LinearGradient, + FillType::RadialGradient => SceneFillType::RadialGradient, + FillType::Image => SceneFillType::Image, + } +} + +#[cfg(test)] +#[path = "layout_scene_tests.rs"] +mod tests; diff --git a/crates/op-pen-loader/src/layout_scene_tests.rs b/crates/op-pen-loader/src/layout_scene_tests.rs new file mode 100644 index 000000000..c0d089a82 --- /dev/null +++ b/crates/op-pen-loader/src/layout_scene_tests.rs @@ -0,0 +1,164 @@ +//! Unit tests for [`editor_state_to_layout_scene`]. + +use super::*; +use op_editor_core::EditorState; + +/// Build an `EditorState` from a `.op` JSON source — mirrors how the +/// `adapter_tests` fixtures parse a `PenDocument`. +fn state_from(src: &str) -> EditorState { + let parsed = jian_ops_schema::load_str(src).expect("parse .op fixture"); + EditorState::from_document(parsed.value) +} + +#[test] +fn flex_layout_resolves_child_bounds_not_authored_coords() { + // A vertical flex frame: a `fill_container`-width child must come + // out stretched to the root's 375 px width — NOT the authored + // `0` width the schema collapses flex tokens to. This proves the + // scene carries layout-RESOLVED geometry. + let src = r##"{ + "version":"1.0.0", + "pages":[{ + "id":"p1","name":"Page 1", + "children":[{ + "type":"frame","id":"root","width":375,"height":812, + "layout":"vertical","gap":16, + "children":[ + {"type":"rectangle","id":"r1","width":"fill_container","height":40, + "fill":[{"type":"solid","color":"#000000"}]} + ] + }] + }], + "children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + assert_eq!(scene.pages.len(), 1); + let root = &scene.pages[0].children[0]; + assert_eq!(root.id, "root"); + let child = &root.children[0]; + assert_eq!(child.id, "r1"); + // Flex stretched the child to the root width. + assert_eq!(child.bounds.size.x, 375.0, "fill_container stretched via taffy"); + assert_eq!(child.bounds.size.y, 40.0); +} + +#[test] +fn multi_root_designs_keep_authored_canvas_offset() { + // Two side-by-side designs at distinct canvas coords — each + // root's resolved bounds must reflect its authored `(x, y)`, + // not collapse to origin. + let src = r##"{ + "version":"1.0.0","pages":[{"id":"p","name":"P","children":[ + {"type":"frame","id":"a","x":100,"y":50,"width":200,"height":100, + "fill":[{"type":"solid","color":"#FF0000"}]}, + {"type":"frame","id":"b","x":-500,"y":2000,"width":200,"height":100, + "fill":[{"type":"solid","color":"#00FF00"}]} + ]}],"children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let kids = &scene.pages[0].children; + assert_eq!((kids[0].bounds.origin.x, kids[0].bounds.origin.y), (100.0, 50.0)); + assert_eq!((kids[1].bounds.origin.x, kids[1].bounds.origin.y), (-500.0, 2000.0)); +} + +#[test] +fn multi_page_document_produces_expected_page_structure() { + let src = r##"{ + "version":"1.0.0","pages":[ + {"id":"home","name":"Home","children":[ + {"type":"rectangle","id":"h1","width":80,"height":40} + ]}, + {"id":"about","name":"About","children":[ + {"type":"rectangle","id":"a1","width":60,"height":30}, + {"type":"rectangle","id":"a2","width":60,"height":30} + ]} + ],"children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + assert_eq!(scene.pages.len(), 2); + assert_eq!(scene.pages[0].id, "home"); + assert_eq!(scene.pages[0].name, "Home"); + assert_eq!(scene.pages[0].children.len(), 1); + assert_eq!(scene.pages[1].id, "about"); + assert_eq!(scene.pages[1].name, "About"); + assert_eq!(scene.pages[1].children.len(), 2); + // The builder always opens on page 0 (the loader resets the + // active page index). + assert_eq!(scene.active_page_index, 0); + assert_eq!(scene.active_page().map(|p| p.id.as_str()), Some("home")); +} + +#[test] +fn variable_ref_fill_resolves_to_concrete_color() { + use jian_ops_schema::variable::{VariableKind, VariableScalar}; + use op_editor_core::NodeId; + + // A rectangle whose fill should follow a `$ref` Color variable. + let src = r##"{ + "version":"1.0.0","pages":[{"id":"p","name":"P","children":[ + {"type":"rectangle","id":"r1","width":100,"height":50, + "fill":[{"type":"solid","color":"#000000"}]} + ]}],"children":[] + }"##; + let mut state = state_from(src); + // Persisted Color variable. + state.create_variable( + "brand", + VariableKind::Color, + VariableScalar::Str("#ff8800".into()), + ); + // Transient fill-ref cache: node `r1`'s fill follows `brand`. + state + .ui + .variables + .fill_refs + .insert(NodeId::new("r1"), "brand".into()); + + let scene = editor_state_to_layout_scene(&state); + let r1 = &scene.pages[0].children[0]; + let fill = r1.fill.expect("r1 must carry a resolved fill"); + // `$ref` won over the authored black — resolved to #ff8800. + assert!((fill.r - 1.0).abs() < 0.01, "red channel: {}", fill.r); + assert!((fill.g - 0.533).abs() < 0.02, "green channel: {}", fill.g); + assert!(fill.b.abs() < 0.01, "blue channel: {}", fill.b); +} + +#[test] +fn no_variable_ref_keeps_authored_fill() { + // Without a registered `$ref`, the node keeps its authored fill. + let src = r##"{ + "version":"1.0.0","pages":[{"id":"p","name":"P","children":[ + {"type":"rectangle","id":"r1","width":100,"height":50, + "fill":[{"type":"solid","color":"#112233"}]} + ]}],"children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let fill = scene.pages[0].children[0].fill.expect("authored fill"); + assert!((fill.r - 0.0667).abs() < 0.02); + assert!((fill.g - 0.1333).abs() < 0.02); + assert!((fill.b - 0.2).abs() < 0.02); +} + +#[test] +fn text_node_carries_content_and_text_style() { + let src = r##"{ + "version":"1.0.0","pages":[{"id":"p","name":"P","children":[ + {"type":"text","id":"hd","content":"Welcome Back", + "fontSize":28,"fontWeight":700, + "fill":[{"type":"solid","color":"#0F172A"}]} + ]}],"children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let n = &scene.pages[0].children[0]; + assert_eq!(n.text.as_deref(), Some("Welcome Back")); + assert_eq!(n.font_size, 28.0); + assert_eq!(n.font_weight, 700); +} + +#[test] +fn empty_editor_state_yields_empty_single_page_scene() { + let scene = editor_state_to_layout_scene(&EditorState::new()); + // The loader's single-page fallback yields one empty page. + assert_eq!(scene.pages.len(), 1); + assert!(scene.pages[0].children.is_empty()); +} diff --git a/crates/op-pen-loader/src/lib.rs b/crates/op-pen-loader/src/lib.rs index 3873eea35..c2e197612 100644 --- a/crates/op-pen-loader/src/lib.rs +++ b/crates/op-pen-loader/src/lib.rs @@ -23,6 +23,7 @@ mod bridge_enums_rev; mod bridge_ui; mod editor_state_bridge; mod effects; +mod layout_scene; mod path_bounds; pub mod payload; @@ -32,6 +33,13 @@ pub mod variables; /// into a shell-core `Document`. pub use payload::pen_document_to_document; +/// Rust-reorg step 1: build a paint-only, layout-resolved +/// `LayoutScene` from an `EditorState`. Reuses the same jian +/// `LayoutEngine` + `SkiaMeasure` flex pass as `pen_document_to_document` +/// and resolves variable `$ref` fills against the editor's +/// variables + active theme. +pub use layout_scene::editor_state_to_layout_scene; + // The `EditorState` → paint-`Document` type bridge. `op-editor-core` // stores chrome / chat / components state as a different set of types // than a shell-core `Document` carries; these functions translate diff --git a/crates/openpencil-shell-core/src/layout_scene.rs b/crates/openpencil-shell-core/src/layout_scene.rs new file mode 100644 index 000000000..c2ec5d6f7 --- /dev/null +++ b/crates/openpencil-shell-core/src/layout_scene.rs @@ -0,0 +1,207 @@ +//! `LayoutScene` — a paint-only, layout-resolved render scene. +//! +//! This is the migration target for `CanvasViewport`: a tree of +//! resolved render nodes that a painter can walk and reproduce the +//! current canvas pixel-for-pixel, WITHOUT depending on the editor's +//! `Document` (which mixes in selection / chat / history / UI state). +//! +//! Distinctions from [`crate::document::Document`]: +//! +//! - **No editor state.** `LayoutScene` carries no `selected`, +//! `tool`, `viewport`, `chat`, `history`, `components`, `ui`. The +//! selection overlay + grid + viewport transform are the host +//! painter's concern, layered on top of the scene. +//! - **Layout-resolved geometry.** Every [`SceneNode::bounds`] is the +//! absolute doc-space AABB produced by jian's taffy `LayoutEngine` — +//! NOT the authored `(x, y, w, h)`. A future painter draws straight +//! from `bounds` with no second layout pass. +//! - **Fills are concrete.** Variable `$ref` fills / strokes are +//! resolved against the editor's variables + active theme at build +//! time, so [`SceneNode::fill`] is always a final paintable colour. +//! +//! The builder lives in `op-pen-loader` +//! (`editor_state_to_layout_scene`); nothing consumes `LayoutScene` +//! yet — `CanvasViewport` is flipped onto it in a later step. +//! +//! wasm32-clean: only `crate::{Color, Point2D, Rect}` + `document` +//! enum re-exports. The web host will build scenes too. + +use crate::document::{Effect, NodeKind}; +use crate::{Color, Point2D, Rect}; + +/// A paint-only, layout-resolved render scene. +/// +/// Built from an `op_editor_core::EditorState` by running jian's flex +/// layout pass and resolving variable `$ref` colours. Carries the +/// multi-page structure the canvas lays out plus, per page, the +/// resolved render-node tree. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LayoutScene { + /// Pages, in document order. The canvas paints one page at a time + /// (`active_page_index`), but every page is resolved so page + /// switches don't need a rebuild. + pub pages: Vec, + /// Index into `pages` of the page the editor currently shows. + /// Clamped into range by the builder. + pub active_page_index: usize, +} + +impl LayoutScene { + /// The page the editor currently shows, or `None` when the scene + /// has no pages. + pub fn active_page(&self) -> Option<&ScenePage> { + self.pages.get(self.active_page_index) + } +} + +/// One resolved page — an artboard / page id + name + the top-level +/// resolved render nodes. +#[derive(Debug, Clone, PartialEq)] +pub struct ScenePage { + /// Page id (the `.op` page id, or `"page-1"` for the single-page + /// fallback). Identity only — the painter does not key off it. + pub id: String, + /// Page name — surfaced by the layer panel, not painted on canvas. + pub name: String, + /// Top-level resolved render nodes for this page. + pub children: Vec, +} + +/// A resolved render node — everything the canvas painter reads to +/// draw one node, with geometry already baked by the layout pass and +/// fills already resolved to concrete colours. +/// +/// Mirrors the fields `CanvasViewport`'s painter reads off +/// `document::Node` today (`kind`, `bounds`, `fill`, `stroke`, +/// `rotation`, `corner_radius`, `text`, `font_size`, `font_weight`, +/// `text_wrap`, `points`, `effects`, `children`, `hidden`) so a +/// painter over `LayoutScene` can reproduce the current canvas +/// pixel-for-pixel. +#[derive(Debug, Clone, PartialEq)] +pub struct SceneNode { + /// Stable node id (the `.op` schema id). Identity for hit-test / + /// selection mapping done by the host on top of the scene. + pub id: String, + /// Node kind — drives per-kind paint (Frame fill, Ellipse oval, + /// Polygon triangle, Line diagonal, Path polyline, Text run, …). + /// `Other("icon_font")` carries a lucide glyph name in `text`. + pub kind: NodeKind, + /// Layout-resolved absolute doc-space rect. Already offset by the + /// page-root's authored `(x, y)` — paint applies only the + /// viewport transform. + pub bounds: Rect, + /// Rotation in radians, clockwise about the node's bounds centre. + pub rotation: f32, + /// Corner radius in doc-px — honoured by Rect / Frame paint. + pub corner_radius: f32, + /// Resolved fill colour. `$ref` variable fills are already + /// resolved against the editor's variables + active theme; a + /// gradient keeps its first stop here (parity with the current + /// canvas, which paints the first solid colour). `None` = no fill. + pub fill: Option, + /// Fill paint mode — `Solid` / `LinearGradient` / `RadialGradient` + /// / `Image`. The current canvas paints all of them as the solid + /// `fill` colour; carried so a richer painter can branch later. + pub fill_type: SceneFillType, + /// Resolved stroke (colour + width). `$ref` stroke colours are + /// resolved at build time. `None` = no stroke. + pub stroke: Option, + /// Text content — `Some` for Text nodes (and the lucide glyph + /// name for `icon_font`). `None` for non-text kinds. + pub text: Option, + /// Text size in doc-px. `0.0` = the painter's default (13 px). + pub font_size: f32, + /// CSS-style font weight (100-900). `0` = default (400). + pub font_weight: u16, + /// Whether the painter wraps the text to `bounds.size.x`. + pub text_wrap: bool, + /// Polyline / path geometry in absolute doc-space coords — + /// populated for `Path` (and any kind the painter walks as + /// points). Empty otherwise. + pub points: Vec, + /// Drop-shadow / effects painted behind the node's fill. + pub effects: Vec, + /// Whether the node (and its subtree) is hidden — the painter + /// skips hidden nodes entirely. + pub hidden: bool, + /// Child render nodes, in paint order. + pub children: Vec, +} + +impl SceneNode { + /// Construct a leaf render node with all paint fields cleared. + /// Builders set `bounds` / `fill` / `text` / … after. + pub fn leaf(id: impl Into, kind: NodeKind) -> Self { + Self { + id: id.into(), + kind, + bounds: Rect::ZERO, + rotation: 0.0, + corner_radius: 0.0, + fill: None, + fill_type: SceneFillType::Solid, + stroke: None, + text: None, + font_size: 0.0, + font_weight: 0, + text_wrap: false, + points: Vec::new(), + effects: Vec::new(), + hidden: false, + children: Vec::new(), + } + } +} + +/// Resolved stroke descriptor — colour already `$ref`-resolved. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SceneStroke { + pub color: Color, + pub width: f32, +} + +/// Fill paint mode for a [`SceneNode`]. Mirrors +/// [`crate::document::FillType`]; kept as its own enum so the scene +/// type does not re-export an editor-model enum that may diverge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SceneFillType { + #[default] + Solid, + LinearGradient, + RadialGradient, + Image, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_scene_has_no_active_page() { + let scene = LayoutScene::default(); + assert!(scene.pages.is_empty()); + assert!(scene.active_page().is_none()); + } + + #[test] + fn active_page_indexes_into_pages() { + let scene = LayoutScene { + pages: vec![ + ScenePage { id: "a".into(), name: "A".into(), children: Vec::new() }, + ScenePage { id: "b".into(), name: "B".into(), children: Vec::new() }, + ], + active_page_index: 1, + }; + assert_eq!(scene.active_page().map(|p| p.id.as_str()), Some("b")); + } + + #[test] + fn leaf_node_clears_paint_fields() { + let n = SceneNode::leaf("n1", NodeKind::Rect); + assert_eq!(n.bounds, Rect::ZERO); + assert!(n.fill.is_none()); + assert!(n.stroke.is_none()); + assert!(n.children.is_empty()); + assert_eq!(n.fill_type, SceneFillType::Solid); + } +} diff --git a/crates/openpencil-shell-core/src/lib.rs b/crates/openpencil-shell-core/src/lib.rs index 082671613..65ca662d9 100644 --- a/crates/openpencil-shell-core/src/lib.rs +++ b/crates/openpencil-shell-core/src/lib.rs @@ -27,6 +27,10 @@ pub mod figma; // as `i18n` so `crate::i18n::translate` / `crate::i18n::Locale` paths still resolve. pub use op_i18n as i18n; pub mod jian; +// Rust-reorg step 1: a paint-only, layout-resolved render scene type. +// `CanvasViewport` is flipped onto it in a later step so the canvas +// widget can stop depending on the editor-state `Document`. +pub mod layout_scene; pub mod mcp; #[cfg(test)] mod mcp_tests; // Phase 4 strangler reorg: the wasm-clean RenderBackend trait moved into the