feat(layout-scene): add layout-resolved render scene type + builder
This commit is contained in:
parent
e9861a0e21
commit
9fb8a42036
124
crates/op-pen-loader/src/layout_scene.rs
Normal file
124
crates/op-pen-loader/src/layout_scene.rs
Normal 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;
|
||||
164
crates/op-pen-loader/src/layout_scene_tests.rs
Normal file
164
crates/op-pen-loader/src/layout_scene_tests.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
207
crates/openpencil-shell-core/src/layout_scene.rs
Normal file
207
crates/openpencil-shell-core/src/layout_scene.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue