refactor(op-pen-loader): build LayoutScene directly from PenDocument

editor_state_to_layout_scene now constructs SceneNodes straight from
the layout-resolved DocPayload (pen_document_to_payload) instead of
routing through the intermediate shell-core Document model. The
DocPayload already carries the resolved geometry + paint fields;
apply_payload's only transforms on them are lossless format
conversions, so the scene output is byte-identical (verified by the
existing layout_scene tests). Adds effects_from_payload_ref so the
builder can rebuild effects from a borrowed payload slice.
This commit is contained in:
Kayshen-X 2026-05-16 22:37:46 +08:00
parent 43cbf2131d
commit 16a31251b5
3 changed files with 145 additions and 82 deletions

View file

@ -34,22 +34,29 @@ pub fn effects_to_payload(effects: &[Effect]) -> Vec<ShadowPayload> {
/// Rebuild a node's `effects` from payload form.
pub fn effects_from_payload(payload: Vec<ShadowPayload>) -> Vec<Effect> {
payload
.into_iter()
.map(|s| {
Effect::DropShadow(DropShadow {
offset_x: s.offset_x,
offset_y: s.offset_y,
blur: s.blur,
color: openpencil_shell_core::Color {
r: s.color[0],
g: s.color[1],
b: s.color[2],
a: s.color[3],
},
})
})
.collect()
payload.iter().map(shadow_payload_to_effect).collect()
}
/// Borrowing variant of [`effects_from_payload`] — rebuilds `effects`
/// from a payload slice without consuming it. Used by the
/// `LayoutScene` builder, which reads `NodePayload.effects` by
/// reference.
pub fn effects_from_payload_ref(payload: &[ShadowPayload]) -> Vec<Effect> {
payload.iter().map(shadow_payload_to_effect).collect()
}
fn shadow_payload_to_effect(s: &ShadowPayload) -> Effect {
Effect::DropShadow(DropShadow {
offset_x: s.offset_x,
offset_y: s.offset_y,
blur: s.blur,
color: openpencil_shell_core::Color {
r: s.color[0],
g: s.color[1],
b: s.color[2],
a: s.color[3],
},
})
}
/// Per-variant accessor for a canonical node's `effects` list.

View file

@ -1,122 +1,176 @@
//! `EditorState` → [`LayoutScene`] builder.
//!
//! Produces the paint-only, layout-resolved render scene that a
//! future `CanvasViewport` painter walks instead of the editor's
//! `Document`.
//! Produces the paint-only, layout-resolved render scene that the
//! `CanvasViewport` painter walks.
//!
//! 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.
//! is a `PenDocument`; [`pen_document_to_payload`] runs each page-root
//! through jian-core's taffy `LayoutEngine` + `jian_skia::SkiaMeasure`
//! (see `adapter.rs`) and bakes the resolved absolute AABBs — plus
//! every paint field — into a layout-resolved [`DocPayload`]. This
//! builder reuses that resolved payload and re-shapes its `NodePayload`
//! 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.
//! to what `pen_document_to_payload` bakes — there is one layout pass
//! and one set of resolved rects. The builder no longer routes through
//! the shell-core `Document` model: `DocPayload` already carries the
//! resolved geometry + paint fields, and `apply_payload`'s only
//! transforms on them are lossless format conversions (colour array →
//! struct, kind / fill-type string → enum).
use openpencil_shell_core::document::{Document, Node, VariableTable};
use openpencil_shell_core::document::{NodeKind, VariableTable};
use openpencil_shell_core::layout_scene::{
LayoutScene, SceneFillType, SceneNode, SceneStroke, ScenePage,
};
use openpencil_shell_core::Color;
use crate::editor_state_var_table;
use crate::payload::pen_document_to_document;
use crate::payload::{DocPayload, NodePayload, StrokePayload};
/// 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.
/// the canonical `.op` loader uses (via [`pen_document_to_payload`]),
/// 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);
// The layout-resolved payload — flex layout already baked into
// every `NodePayload`'s AABB by jian-core's `LayoutEngine`. This
// is the reusable layout-resolution core; it never touches the
// shell-core `Document` model.
let payload: DocPayload = crate::adapter::pen_document_to_payload(&state.doc).payload;
// 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.
// caches the editor holds. `editor_state_var_table` folds the
// persisted definitions and the transient `EditorState.ui`
// selection / caches together.
let var_table: VariableTable = editor_state_var_table(state);
LayoutScene {
pages: doc
pages: payload
.pages
.iter()
.map(|page| ScenePage {
id: page.id.as_str().to_string(),
id: page.id.clone(),
name: page.name.clone(),
children: page
.children
.iter()
.map(|n| node_to_scene(n, &var_table))
.map(|n| node_payload_to_scene(n, &var_table))
.collect(),
})
.collect(),
active_page_index: doc.active_page_index,
// The loader always opens on page 0 (`pen_document_to_payload`
// hardcodes `active_page_index: 0`, then `apply_payload` clamps
// it into range — same result for a fresh load).
active_page_index: payload
.active_page_index
.min(payload.pages.len().saturating_sub(1)),
}
}
/// Convert one resolved shell-core [`Node`] into a [`SceneNode`].
/// Convert one resolved [`NodePayload`] into a [`SceneNode`].
///
/// Geometry is copied straight through — `pen_document_to_document`
/// Geometry is copied straight through — `pen_document_to_payload`
/// 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 {
fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> SceneNode {
use openpencil_shell_core::{Point2D, Rect};
let node_id = openpencil_shell_core::document::NodeId::new(node.id.clone());
SceneNode {
id: node.id.as_str().to_string(),
kind: node.kind.clone(),
bounds: node.bounds,
id: node.id.clone(),
kind: str_to_kind(&node.kind),
bounds: Rect {
origin: Point2D::new(node.x, node.y),
size: Point2D::new(node.w, node.h),
},
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,
}),
fill: var_table
.fill_for(&node_id)
.or_else(|| node.fill.map(array_to_color)),
fill_type: str_to_scene_fill_type(&node.fill_type),
stroke: node.stroke.as_ref().map(|s| scene_stroke(s, &node_id, var_table)),
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(),
points: node
.points
.iter()
.map(|p| Point2D::new(p[0], p[1]))
.collect(),
effects: crate::effects::effects_from_payload_ref(&node.effects),
hidden: node.hidden,
locked: node.locked,
children: node
.children
.iter()
.map(|c| node_to_scene(c, var_table))
.map(|c| node_payload_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,
/// Resolve a payload stroke into a scene stroke. The `$ref` stroke
/// resolution parallels the fill path.
fn scene_stroke(
s: &StrokePayload,
node_id: &openpencil_shell_core::document::NodeId,
var_table: &VariableTable,
) -> SceneStroke {
SceneStroke {
color: var_table
.stroke_color_for(node_id)
.unwrap_or_else(|| array_to_color(s.color)),
width: s.width,
}
}
/// `[r, g, b, a]` payload colour → shell-core `Color`. Lossless;
/// the same conversion `apply_payload` runs on the `Document` path.
fn array_to_color(a: [f32; 4]) -> Color {
Color {
r: a[0],
g: a[1],
b: a[2],
a: a[3],
}
}
/// `NodePayload.kind` string → shell-core `NodeKind`. Mirrors
/// `payload::str_to_kind` so the scene's per-kind paint dispatch
/// matches the `Document` path exactly.
fn str_to_kind(s: &str) -> NodeKind {
match s {
"frame" => NodeKind::Frame,
"group" => NodeKind::Group,
"rect" => NodeKind::Rect,
"ellipse" => NodeKind::Ellipse,
"polygon" => NodeKind::Polygon,
"line" => NodeKind::Line,
"text" => NodeKind::Text,
"path" => NodeKind::Path,
other => NodeKind::Other(other.to_string()),
}
}
/// `NodePayload.fill_type` string → scene `SceneFillType`. Mirrors
/// `payload::str_to_fill_type` followed by `fill_type_to_scene`.
fn str_to_scene_fill_type(s: &str) -> SceneFillType {
match s {
"linear" => SceneFillType::LinearGradient,
"radial" => SceneFillType::RadialGradient,
"image" => SceneFillType::Image,
_ => SceneFillType::Solid,
}
}

View file

@ -33,11 +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.
/// Build a paint-only, layout-resolved `LayoutScene` from an
/// `EditorState`. Reuses the same jian `LayoutEngine` + `SkiaMeasure`
/// flex pass as the canonical `.op` loader (via
/// [`pen_document_to_payload`]) and resolves variable `$ref` fills
/// against the editor's variables + active theme. Builds the scene
/// directly from the layout-resolved `DocPayload` — no intermediate
/// shell-core `Document`.
pub use layout_scene::editor_state_to_layout_scene;
// The `EditorState` → paint-`Document` type bridge. `op-editor-core`