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:
Kayshen-X 2026-05-15 22:33:13 +08:00
parent d9ada6cc11
commit 1bed264d1f
7 changed files with 124 additions and 0 deletions

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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