diff --git a/crates/openpencil-desktop/src/main.rs b/crates/openpencil-desktop/src/main.rs index bdcc7c4fe..af659427d 100644 --- a/crates/openpencil-desktop/src/main.rs +++ b/crates/openpencil-desktop/src/main.rs @@ -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; diff --git a/crates/openpencil-desktop/src/pen_doc_adapter.rs b/crates/openpencil-desktop/src/pen_doc_adapter.rs index 9565c7327..71fd16459 100644 --- a/crates/openpencil-desktop/src/pen_doc_adapter.rs +++ b/crates/openpencil-desktop/src/pen_doc_adapter.rs @@ -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(), } } diff --git a/crates/openpencil-desktop/src/persistence.rs b/crates/openpencil-desktop/src/persistence.rs index c96eeb55f..1121f549a 100644 --- a/crates/openpencil-desktop/src/persistence.rs +++ b/crates/openpencil-desktop/src/persistence.rs @@ -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, #[serde(default)] pub children: Vec, } @@ -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 } diff --git a/crates/openpencil-desktop/src/persistence_effects.rs b/crates/openpencil-desktop/src/persistence_effects.rs new file mode 100644 index 000000000..e6529dc05 --- /dev/null +++ b/crates/openpencil-desktop/src/persistence_effects.rs @@ -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 { + 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) -> Vec { + 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 = + 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()); + } +} diff --git a/crates/openpencil-shell-core/src/document.rs b/crates/openpencil-shell-core/src/document.rs index 774083887..e1f8ca15f 100644 --- a/crates/openpencil-shell-core/src/document.rs +++ b/crates/openpencil-shell-core/src/document.rs @@ -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, pub children: Vec, } @@ -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, } } diff --git a/crates/openpencil-shell-core/src/document/tests_mutators.rs b/crates/openpencil-shell-core/src/document/tests_mutators.rs index fe6341d66..42464135a 100644 --- a/crates/openpencil-shell-core/src/document/tests_mutators.rs +++ b/crates/openpencil-shell-core/src/document/tests_mutators.rs @@ -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). diff --git a/crates/openpencil-shell-core/src/document/walkers.rs b/crates/openpencil-shell-core/src/document/walkers.rs index 2f854921c..4f320eaf5 100644 --- a/crates/openpencil-shell-core/src/document/walkers.rs +++ b/crates/openpencil-shell-core/src/document/walkers.rs @@ -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, } }