feat(layout-scene): add layout-resolved render scene type + builder

This commit is contained in:
Kayshen-X 2026-05-16 21:35:03 +08:00
parent e9861a0e21
commit 9fb8a42036
5 changed files with 507 additions and 0 deletions

View file

@ -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;

View file

@ -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());
}

View file

@ -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

View file

@ -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<ScenePage>,
/// 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<SceneNode>,
}
/// 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<Color>,
/// 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<SceneStroke>,
/// Text content — `Some` for Text nodes (and the lucide glyph
/// name for `icon_font`). `None` for non-text kinds.
pub text: Option<String>,
/// 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<Point2D>,
/// Drop-shadow / effects painted behind the node's fill.
pub effects: Vec<Effect>,
/// 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<SceneNode>,
}
impl SceneNode {
/// Construct a leaf render node with all paint fields cleared.
/// Builders set `bounds` / `fill` / `text` / … after.
pub fn leaf(id: impl Into<String>, 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);
}
}

View file

@ -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