feat(document): add Effect (drop shadow) data model + persistence
First slice of the Effects gap — the property panel's 效果 section was a header-only stub with no data behind it. - `Effect` / `DropShadow` types in `document.rs`; `Node.effects: Vec<Effect>` (offset_x/y + blur + color, doc-px). Both `Node` builders + `deep_clone_with_new_ids` carry it. - `persistence_effects.rs` — `ShadowPayload` + `effects_to_payload` / `effects_from_payload`; `NodePayload.effects` is `#[serde(default)]` so legacy `.op` files still load. Carved into its own module so `persistence.rs` stays under the 800-line cap. - Round-trip tested through the real serde JSON path (2 tests). 858 workspace tests pass. Still pending (own slices): canvas drop-shadow paint (needs a blur primitive on `RenderBackend`), the property-panel 效果 editing rows, and a `set_node_shadow` MCP tool. Shadows currently round-trip through save/load but do not yet render.
This commit is contained in:
parent
d9ada6cc11
commit
1bed264d1f
|
|
@ -16,6 +16,7 @@ mod mcp_serve;
|
|||
mod pen_doc_adapter;
|
||||
mod pen_doc_path_bounds;
|
||||
mod persistence;
|
||||
mod persistence_effects;
|
||||
mod persistence_variables;
|
||||
mod settings_io;
|
||||
|
||||
|
|
|
|||
|
|
@ -621,6 +621,7 @@ fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
|
|||
font_size: 0.0,
|
||||
font_weight: 0,
|
||||
text_wrap: false,
|
||||
effects: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ pub struct NodePayload {
|
|||
pub font_weight: u16,
|
||||
#[serde(default)]
|
||||
pub text_wrap: bool,
|
||||
/// Drop-shadow effects. `#[serde(default)]` so a legacy `.op`
|
||||
/// saved before effects round-tripped still loads (empty vec).
|
||||
#[serde(default)]
|
||||
pub effects: Vec<crate::persistence_effects::ShadowPayload>,
|
||||
#[serde(default)]
|
||||
pub children: Vec<NodePayload>,
|
||||
}
|
||||
|
|
@ -137,6 +141,7 @@ fn node_to_payload(n: &Node) -> NodePayload {
|
|||
font_size: n.font_size,
|
||||
font_weight: n.font_weight,
|
||||
text_wrap: n.text_wrap,
|
||||
effects: crate::persistence_effects::effects_to_payload(&n.effects),
|
||||
children: n.children.iter().map(node_to_payload).collect(),
|
||||
}
|
||||
}
|
||||
|
|
@ -306,6 +311,7 @@ fn payload_to_node(n: NodePayload) -> Node {
|
|||
node.font_size = n.font_size;
|
||||
node.font_weight = n.font_weight;
|
||||
node.text_wrap = n.text_wrap;
|
||||
node.effects = crate::persistence_effects::effects_from_payload(n.effects);
|
||||
node
|
||||
}
|
||||
|
||||
|
|
|
|||
89
crates/openpencil-desktop/src/persistence_effects.rs
Normal file
89
crates/openpencil-desktop/src/persistence_effects.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! Node-effects (drop shadow) round-trip for the desktop's private
|
||||
//! `DocPayload` save format. Carved off `persistence.rs` to keep
|
||||
//! that file under the 800-line cap.
|
||||
|
||||
use openpencil_shell_core::document::{DropShadow, Effect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Serializable mirror of `document::DropShadow`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ShadowPayload {
|
||||
pub offset_x: f32,
|
||||
pub offset_y: f32,
|
||||
pub blur: f32,
|
||||
pub color: [f32; 4],
|
||||
}
|
||||
|
||||
/// Serialize a node's `effects` into payload form.
|
||||
pub fn effects_to_payload(effects: &[Effect]) -> Vec<ShadowPayload> {
|
||||
effects
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let Effect::DropShadow(s) = e;
|
||||
ShadowPayload {
|
||||
offset_x: s.offset_x,
|
||||
offset_y: s.offset_y,
|
||||
blur: s.blur,
|
||||
color: [s.color.r, s.color.g, s.color.b, s.color.a],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openpencil_shell_core::Color;
|
||||
|
||||
#[test]
|
||||
fn effects_survive_payload_round_trip() {
|
||||
let original = vec![
|
||||
Effect::DropShadow(DropShadow {
|
||||
offset_x: 4.0,
|
||||
offset_y: 6.0,
|
||||
blur: 12.0,
|
||||
color: Color { r: 0.0, g: 0.0, b: 0.0, a: 0.5 },
|
||||
}),
|
||||
Effect::DropShadow(DropShadow {
|
||||
offset_x: -2.0,
|
||||
offset_y: 0.0,
|
||||
blur: 3.0,
|
||||
color: Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 },
|
||||
}),
|
||||
];
|
||||
// Through the actual serde JSON path the `.op` file uses.
|
||||
let payload = effects_to_payload(&original);
|
||||
let json = serde_json::to_string(&payload).expect("serialize");
|
||||
let back: Vec<ShadowPayload> =
|
||||
serde_json::from_str(&json).expect("deserialize");
|
||||
let restored = effects_from_payload(back);
|
||||
assert_eq!(restored, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_effects_round_trip_to_empty() {
|
||||
let payload = effects_to_payload(&[]);
|
||||
assert!(payload.is_empty());
|
||||
assert!(effects_from_payload(payload).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,26 @@ pub struct Stroke {
|
|||
pub width: f32,
|
||||
}
|
||||
|
||||
/// Drop-shadow effect — offset + blur + colour, doc-px units.
|
||||
/// Painted behind the node's fill. Mirrors the TS `PenEffect`
|
||||
/// shadow variant (`offsetX` / `offsetY` / `blur` / `color`);
|
||||
/// `spread` is omitted until the renderer honors it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DropShadow {
|
||||
pub offset_x: f32,
|
||||
pub offset_y: f32,
|
||||
pub blur: f32,
|
||||
pub color: crate::Color,
|
||||
}
|
||||
|
||||
/// A visual effect on a node. v1 ships drop shadow (the common
|
||||
/// case + what the property panel's 效果 section needs); layer
|
||||
/// blur lands once its render path does.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Effect {
|
||||
DropShadow(DropShadow),
|
||||
}
|
||||
|
||||
/// Document tree node. `bounds = Rect::ZERO` for containers that
|
||||
/// derive size from children. `text` is populated for Text kind.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -109,6 +129,9 @@ pub struct Node {
|
|||
pub font_size: f32,
|
||||
pub font_weight: u16,
|
||||
pub text_wrap: bool,
|
||||
/// Visual effects (drop shadows) painted with the node. Empty
|
||||
/// for most nodes; the property panel's 效果 section edits it.
|
||||
pub effects: Vec<Effect>,
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +155,7 @@ impl Node {
|
|||
font_size: 0.0,
|
||||
font_weight: 0,
|
||||
text_wrap: false,
|
||||
effects: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -160,6 +184,7 @@ impl Node {
|
|||
font_size: 0.0,
|
||||
font_weight: 0,
|
||||
text_wrap: false,
|
||||
effects: Vec::new(),
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -538,6 +538,7 @@ fn hit_test_rotated_negative_size_line_uses_segment_midpoint_pivot() {
|
|||
font_size: 0.0,
|
||||
font_weight: 0,
|
||||
text_wrap: false,
|
||||
effects: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}];
|
||||
// Click at (50, 50) — midpoint (invariant under rotation).
|
||||
|
|
|
|||
|
|
@ -549,6 +549,7 @@ pub(in crate::document) fn deep_clone_with_new_ids(node: &Node, next_id: &mut u6
|
|||
font_size: node.font_size,
|
||||
font_weight: node.font_weight,
|
||||
text_wrap: node.text_wrap,
|
||||
effects: node.effects.clone(),
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue