From a43302d9b72cd24ad608d57781cb5b5f517b121e Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 21 Aug 2026 04:20:14 +0800 Subject: [PATCH] feat(plugin,node): bridge parametric params into the node/inspector path - ValueType::Parametric; the node input carries the whole curve set as NodeValue::Text(JSON) so undo and project serialization come for free - translation pass builds the input with the default-curve JSON and the dimension/range/ui-colour properties - edits flow both ways: node input (UI) -> curves_from_json -> set_ofx(Parametric) on the instance; plugin-side Set/Add/Delete -> notify_instance_changed -> JSON written back to the input (undoable) - screenshot example: gate the macOS-only offscreen capture items so the workspace tests build on Linux/Windows --- crates/oaknode/src/serializer.rs | 42 ++- crates/oaknode/src/value.rs | 20 +- crates/oakplugin/src/node.rs | 13 +- crates/oakplugin/src/node_factory.rs | 272 +++++++++++++++-- crates/oakplugin/src/param.rs | 18 +- crates/oakplugin/src/param_curve.rs | 341 ++++++++++++++++++++++ crates/oakplugin/src/suites/parametric.rs | 5 +- 7 files changed, 676 insertions(+), 35 deletions(-) diff --git a/crates/oaknode/src/serializer.rs b/crates/oaknode/src/serializer.rs index 51b36f996..41f40d3f7 100644 --- a/crates/oaknode/src/serializer.rs +++ b/crates/oaknode/src/serializer.rs @@ -206,7 +206,7 @@ pub fn value_to_string(declared: ValueType, value: &NodeValue, key_track: bool) } } ValueType::Float => format!("{}", value.to_double()), - ValueType::Text | ValueType::StrCombo => match value { + ValueType::Text | ValueType::StrCombo | ValueType::Parametric => match value { NodeValue::Text(s) => s.clone(), NodeValue::StrCombo(s) => s.clone(), _ => String::new(), @@ -249,6 +249,7 @@ pub fn string_to_value(declared: ValueType, text: &str) -> NodeValue { ValueType::Float => NodeValue::Float(text.trim().parse().unwrap_or(0.0)), ValueType::Text => NodeValue::Text(text.to_string()), ValueType::StrCombo => NodeValue::StrCombo(text.to_string()), + ValueType::Parametric => NodeValue::Text(text.to_string()), _ => NodeValue::None, } } @@ -1082,3 +1083,42 @@ fn resolve_folder_children( fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Value codec 对 Parametric 输入的往返(save_immediate / + /// load_immediate 对 standard 值走的纯函数路径:split → + /// value_to_string → string_to_value → combine;XML 桥由 + /// tests/serializer_test.rs 覆盖)。 + #[test] + fn parametric_input_value_codec_roundtrip() { + let declared = ValueType::Parametric; + let json = r#"{"curves":[[{"key":0,"value":0,"slope":1},{"key":0.5,"value":0.6,"slope":1}]]}"#; + let value = NodeValue::Text(json.to_string()); + + // save 侧:standard 值按声明类型拆轨(Text → 单轨整值)。 + let tracks = value.split_into_tracks(declared); + assert_eq!(tracks.len(), 1); + let text = value_to_string(declared, &tracks[0], true); + assert_eq!(text, json); + + // load 侧:track 文本还原 + 合并回整值。 + let restored = string_to_value(declared, &text); + assert_eq!(restored, value); + let combined = NodeValue::combine_tracks(&[restored], declared); + assert_eq!(combined, value); + + // key_track=false 形态(整值轨道)同样往返。 + assert_eq!(value_to_string(declared, &value, false), json); + + // 载荷不是 Text(防御)→ 空串;声明类型 Parametric 无键帧 + // 插值、无 C++ 判别值。 + assert_eq!(value_to_string(declared, &NodeValue::None, true), ""); + assert!(!declared.can_interpolate()); + assert_eq!(declared.to_cpp_discriminant(), 0); + assert!(declared.is_string()); + assert_eq!(declared.to_oak(), crate::value::oak::STRING); + } +} diff --git a/crates/oaknode/src/value.rs b/crates/oaknode/src/value.rs index 3f45d19af..a1d6dee5e 100644 --- a/crates/oaknode/src/value.rs +++ b/crates/oaknode/src/value.rs @@ -103,6 +103,11 @@ pub enum ValueType { NodeRef, /// Push button (no payload). PushButton, + /// Parametric curve (OpenFX parametric param; the value payload is a + /// [`NodeValue::Text`] JSON document, see + /// `oakplugin::param_curve::curves_to_json` — string-carried like + /// [`ValueType::Text`]). + Parametric, } /// A node value. `Texture` stores an oakrender handle; dropping the @@ -310,8 +315,9 @@ impl ValueType { ValueType::Vec3 => oak::VEC3, ValueType::Vec4 => oak::VEC4, ValueType::Combo => oak::COMBO, - // String-carried types (k_file/k_text/k_font/k_str_combo). - ValueType::Text | ValueType::StrCombo => oak::STRING, + // String-carried types (k_file/k_text/k_font/k_str_combo; + // parametric carries a JSON text). + ValueType::Text | ValueType::StrCombo | ValueType::Parametric => oak::STRING, _ => oak::NONE, } } @@ -320,7 +326,10 @@ impl ValueType { /// the dedicated string getters/setters, `// CPP-PARITY: valueconvert.h` /// `value_type_is_string`). pub fn is_string(self) -> bool { - matches!(self, ValueType::Text | ValueType::StrCombo) + matches!( + self, + ValueType::Text | ValueType::StrCombo | ValueType::Parametric + ) } /// Number of keyframe tracks the type splits into (C++ @@ -363,8 +372,9 @@ impl ValueType { // k_subtitle_params = 20 has no Rust type counterpart. ValueType::Binary => 21, ValueType::PushButton => 22, - // No C++ counterpart (k_none). - ValueType::NodeRef => 0, + // No C++ counterpart (k_none); the parametric curve payload + // lives in the JSON text (k_bezier = 15 is a different model). + ValueType::NodeRef | ValueType::Parametric => 0, } } diff --git a/crates/oakplugin/src/node.rs b/crates/oakplugin/src/node.rs index d1578c330..6c27a8355 100644 --- a/crates/oakplugin/src/node.rs +++ b/crates/oakplugin/src/node.rs @@ -399,12 +399,13 @@ pub fn set_input_undoable( } /// 以 undoable 方式设置节点的标准输入值(字符串族输入:Text / -/// StrCombo——POD 不携带字符串数据)。 +/// StrCombo / Parametric——POD 不携带字符串数据)。 /// /// 按输入的声明类型([`oaknode::value::ValueType`])选存储变体: -/// `StrCombo` → [`oaknode::value::NodeValue::StrCombo`],其余 -/// 字符串族(Text)→ [`oaknode::value::NodeValue::Text`]。声明类型 -/// 不是字符串族 → [`NodeBridgeError::NotStringInput`]。命令语义与 +/// `StrCombo` → [`oaknode::value::NodeValue::StrCombo`], +/// `Text` / `Parametric`(值 = 曲线 JSON 文本)→ +/// [`oaknode::value::NodeValue::Text`]。声明类型不是字符串族 → +/// [`NodeBridgeError::NotStringInput`]。命令语义与 /// [`set_input_undoable`] 相同。 pub fn set_input_string_undoable( node: &NodeRef, @@ -425,7 +426,9 @@ pub fn set_input_string_undoable( }; let nv = match declared { oaknode::value::ValueType::StrCombo => oaknode::value::NodeValue::StrCombo(value.to_string()), - oaknode::value::ValueType::Text => oaknode::value::NodeValue::Text(value.to_string()), + oaknode::value::ValueType::Text | oaknode::value::ValueType::Parametric => { + oaknode::value::NodeValue::Text(value.to_string()) + } _ => return Err(NodeBridgeError::NotStringInput(input.to_string())), }; diff --git a/crates/oakplugin/src/node_factory.rs b/crates/oakplugin/src/node_factory.rs index 429c9bbdd..2bc485281 100644 --- a/crates/oakplugin/src/node_factory.rs +++ b/crates/oakplugin/src/node_factory.rs @@ -281,7 +281,18 @@ fn default_value_for_param(def: &ParamDef) -> Option { } } ofx::TYPE_BYTES => Some(NodeValue::Binary(Vec::new())), - // PushButton/Group/Page/Parametric/未知 → invalid(C++ 末尾 + // parametric:值 = 默认曲线的确定性 JSON(节点输入无曲线模型, + // 经 Text 承载;撤销/工程序列化随标准值免费获得)。默认曲线 + // 来自 def.default(describe 期插件经 parametric suite 改过 + // 的默认),不是 P_DEFAULT 属性(parametric 无标量默认)。 + ofx::TYPE_PARAMETRIC => match &def.default { + ParamValue::Parametric(curves) => { + Some(NodeValue::Text(crate::param_curve::curves_to_json(curves))) + } + // 防御:type_default 恒产出 Parametric,此处只兜底。 + _ => None, + }, + // PushButton/Group/Page/未知 → invalid(C++ 末尾 // return QVariant())。 _ => None, } @@ -450,6 +461,9 @@ fn input_type_for(ofx_type: &str) -> Option { ofx::TYPE_STRCHOICE => ValueType::StrCombo, ofx::TYPE_BYTES | ofx::TYPE_CUSTOM => ValueType::Binary, ofx::TYPE_PUSHBUTTON => ValueType::PushButton, + // parametric:值 = 默认曲线的 JSON 文本(见 + // [`crate::param_curve::curves_to_json`])。 + ofx::TYPE_PARAMETRIC => ValueType::Parametric, _ => return None, }) } @@ -533,6 +547,44 @@ fn build_core(inst: &Instance) -> NodeCore { .push(("ui_page".to_string(), NodeValue::Text(page.clone()))); } + // parametric 专属属性(消费方 = 检查器曲线编辑器;值从 + // OfxParametricParameterSuite 的属性表搬运): + // - "parametric_dimension":int,维度数; + // - "parametric_range":Vec2,定义域 (lo, hi); + // - "parametric_ui_colour":每维一条(重复键,对齐 combo_option + // 惯例),Color([r,g,b,1]),取自 double×3N 属性,未配置不携带。 + // 无参数动画(第 1 期)→ 不可键帧。 + if matches!(value_type, ValueType::Parametric) { + input.flags |= input_flags::NOT_KEYFRAMABLE; + let dim = prop_int(&def.props, ofx::P_PARAMETRIC_DIMENSION, 0).max(1); + input.properties.push(( + "parametric_dimension".to_string(), + NodeValue::Int(dim as i64), + )); + input.properties.push(( + "parametric_range".to_string(), + NodeValue::Vec2([ + prop_double(&def.props, ofx::P_PARAMETRIC_RANGE, 0), + prop_double(&def.props, ofx::P_PARAMETRIC_RANGE, 1), + ]), + )); + let colour_dim = def.props.dimension(ofx::P_PARAMETRIC_UI_COLOUR); + for i in 0..dim as usize { + if i * 3 + 2 >= colour_dim { + break; + } + input.properties.push(( + "parametric_ui_colour".to_string(), + NodeValue::Color([ + prop_double(&def.props, ofx::P_PARAMETRIC_UI_COLOUR, i * 3), + prop_double(&def.props, ofx::P_PARAMETRIC_UI_COLOUR, i * 3 + 1), + prop_double(&def.props, ofx::P_PARAMETRIC_UI_COLOUR, i * 3 + 2), + 1.0, + ]), + )); + } + } + if matches!(value_type, ValueType::Color) { let semantic = deduce_color_semantic(def, &group_labels); input.properties.push(( @@ -757,23 +809,38 @@ pub fn register_plugin_nodes() -> Vec { // 渲染执行器 + duplicator(依赖反转的 oakplugin 侧半环) // --------------------------------------------------------------------------- -/// 字符串族参数注入(POD 无字符串表达;直接 set_ofx)。 -fn set_string_param(inst: &Instance, key: &str, expected_type: &str, value: &str) { +/// 文本族参数注入(POD 无字符串表达;直接 set_ofx)。按参数的 OFX +/// 类型分发:String → String、StrChoice → StrChoice;Parametric → +/// 解析 JSON 曲线集([`crate::param_curve::curves_from_json`],宿主侧 +/// [`crate::param_curve::curves_to_json`] 的格式)写回 +/// `ParamValue::Parametric`——即"节点输入值变化 → 写回实例"的 +/// parametric 路径(渲染期参数覆盖的分支,见 +/// [`execute_plugin_job`])。JSON 解析失败 / 类型不符 → 忽略(与 +/// 字符串族类型不匹配静默一致)。 +fn set_text_param(inst: &Instance, key: &str, text: &str) { let Some(p) = inst.params.find(key) else { return; }; - if p.def.ofx_type != expected_type { - return; + match p.def.ofx_type.as_str() { + ofx::TYPE_STRING => { + let Ok(cs) = CString::new(text) else { + return; + }; + p.set_ofx(ParamValue::String(cs)); + } + ofx::TYPE_STRCHOICE => { + let Ok(cs) = CString::new(text) else { + return; + }; + p.set_ofx(ParamValue::StrChoice(cs)); + } + ofx::TYPE_PARAMETRIC => { + if let Some(curves) = crate::param_curve::curves_from_json(text) { + p.set_ofx(ParamValue::Parametric(curves)); + } + } + _ => {} } - let Ok(cs) = CString::new(value) else { - return; - }; - let pv = if expected_type == ofx::TYPE_STRING { - ParamValue::String(cs) - } else { - ParamValue::StrChoice(cs) - }; - p.set_ofx(pv); } /// executor 槽实现:JobSpec::Plugin → render_driver::render_frame。 @@ -801,14 +868,15 @@ fn execute_plugin_job( return Err(Error::Failed("plugin job 无可用输入纹理".into())); } - // 参数注入:数值族走 render_driver 的 POD 覆盖;字符串族 POD 无 - // 表达,这里直接 set_ofx(对齐 pluginrenderer.cpp 的 - // StringInstance::set 分支)。 + // 参数注入:数值族走 render_driver 的 POD 覆盖;字符串/曲线族 + // POD 无表达,这里直接 set_ofx(对齐 pluginrenderer.cpp 的 + // StringInstance::set 分支 + parametric 的 JSON 写回)。 let mut pod_values = Vec::new(); for (key, nv) in values { match nv { - NodeValue::Text(s) => set_string_param(&inst.value, key, ofx::TYPE_STRING, s), - NodeValue::StrCombo(s) => set_string_param(&inst.value, key, ofx::TYPE_STRCHOICE, s), + NodeValue::Text(s) | NodeValue::StrCombo(s) => { + set_text_param(&inst.value, key, s) + } NodeValue::PushButton | NodeValue::None => {} other => { if let Some(v) = crate::node::Value::from_node_value(other) { @@ -940,7 +1008,12 @@ mod tests { // 容器与未知类型:跳过。 assert_eq!(input_type_for(ofx::TYPE_GROUP), None); assert_eq!(input_type_for(ofx::TYPE_PAGE), None); - assert_eq!(input_type_for("OfxParamTypeParametric"), None); + assert_eq!(input_type_for("OfxParamTypeBogus"), None); + // parametric → Parametric(值 = 曲线 JSON 文本)。 + assert_eq!( + input_type_for(ofx::TYPE_PARAMETRIC), + Some(ValueType::Parametric) + ); } #[test] @@ -1107,4 +1180,163 @@ mod tests { assert_eq!(calls, vec!["OfxActionInstanceChanged", "OfxChangeUserEdited"]); unregister_instance(id); } + + /// 构造一个带 parametric 参数(维度 2、自定义 range、双维 UI 颜色 + /// 已配置)与一个 String 参数的实例(直接登记进注册表)。 + fn instance_with_parametric() -> u64 { + use std::ffi::{c_char, c_void}; + use std::sync::atomic::AtomicU32; + use crate::descriptor::EffectDescriptor; + use crate::handle::RefBox; + use crate::host::Plugin; + use crate::param::{ParamDef, ParamInstance, ParamSetInstance}; + + unsafe extern "C" fn dummy_entry( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> i32 { + 0 + } + let mut def = ParamDef::new("curve", ofx::TYPE_PARAMETRIC); + def.props + .set_one(ofx::PROP_LABEL, PropValue::String(CString::new("Curve").unwrap())); + def.props + .set_one(ofx::P_PARAMETRIC_DIMENSION, PropValue::Int(2)); + def.props.define( + ofx::P_PARAMETRIC_RANGE, + vec![PropValue::Double(0.0), PropValue::Double(1.0)], + ); + def.props.define( + ofx::P_PARAMETRIC_UI_COLOUR, + vec![ + PropValue::Double(1.0), + PropValue::Double(0.0), + PropValue::Double(0.0), + PropValue::Double(0.0), + PropValue::Double(1.0), + PropValue::Double(0.0), + ], + ); + let mut params = ParamSetInstance { params: Vec::new() }; + params + .params + .push(Box::new(ParamInstance::from_def(def))); + params.params.push(Box::new(ParamInstance::from_def( + ParamDef::new("title", ofx::TYPE_STRING), + ))); + let plugin = Arc::new(Plugin { + identifier: "test.parametric".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::new(), + contexts: vec![], + descriptor: EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry: dummy_entry, + ofx_plugin: std::ptr::null_mut(), + }); + let inst = crate::instance::Instance { + props: crate::property::PropertySet::new(), + plugin, + context: "OfxImageEffectContextFilter".into(), + params, + clips: Vec::new(), + node_identity: std::sync::atomic::AtomicUsize::new(0), + destroyed: std::sync::atomic::AtomicBool::new(false), + sequence_range: std::sync::Mutex::new(None), + progress_cb: std::sync::Mutex::new(None), + cancel: std::sync::atomic::AtomicBool::new(false), + edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()), + render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), + }; + register_instance(Arc::new(RefBox { + refs: AtomicU32::new(1), + value: inst, + })) + } + + /// 翻译 pass 产出 Parametric 输入:value_type、默认值(= 默认曲线 + /// 的 JSON)、display_name(= label)、不可键帧 + 专属属性 + /// (dimension / range / 每维一条 UI 颜色)。 + #[test] + fn build_core_produces_parametric_input() { + let id = instance_with_parametric(); + let inst = instance_from_id(id).expect("已登记"); + let core = build_core(&inst.value); + + let input = core.get_input("curve").expect("parametric 输入应存在"); + assert_eq!(input.value_type, ValueType::Parametric); + assert_eq!(input.display_name, "Curve"); + assert_ne!(input.flags & input_flags::NOT_KEYFRAMABLE, 0); + + // 默认值 = def.default 曲线集的 JSON(type_default 产出 1 条 + // 恒等曲线;dimension 属性只做索引门控,未配置曲线按恒等读, + // 不扩充默认值)。 + let expected = crate::param_curve::curves_to_json(&[crate::param_curve::Curve::identity( + 0.0, 1.0, + )]); + match &input.default { + NodeValue::Text(s) => assert_eq!(s, &expected, "默认值应为曲线 JSON"), + other => panic!("预期 Text(JSON),实际 {other:?}"), + } + // 标准值 = 默认(工程序列化从这里走)。 + assert_eq!(core.standard_value("curve", -1), input.default); + + // 专属属性。 + let props = |name: &str| { + input + .properties + .iter() + .filter(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + .collect::>() + }; + assert_eq!(props("parametric_dimension"), vec![NodeValue::Int(2)]); + assert_eq!( + props("parametric_range"), + vec![NodeValue::Vec2([0.0, 1.0])] + ); + // 双维 → 两条颜色(重复键)。 + assert_eq!( + props("parametric_ui_colour"), + vec![ + NodeValue::Color([1.0, 0.0, 0.0, 1.0]), + NodeValue::Color([0.0, 1.0, 0.0, 1.0]), + ] + ); + unregister_instance(id); + } + + /// parametric 输入(Text JSON)→ 实例写回:有效 JSON 解析并 + /// `set_ofx(Parametric)`;坏 JSON / 类型不符 → 静默忽略(保持现值)。 + #[test] + fn parametric_input_writes_back_to_instance() { + let id = instance_with_parametric(); + let inst = instance_from_id(id).expect("已登记"); + + // 有效 JSON(双维,第二维单点)→ 曲线写回,求值即实例曲线。 + let json = r#"{"curves":[[{"key":0,"value":0,"slope":1},{"key":0.5,"value":0.7,"slope":1}],[{"key":0,"value":0,"slope":1}]]}"#; + set_text_param(&inst.value, "curve", json); + let curves = match inst.value.params.find("curve").unwrap().get() { + ParamValue::Parametric(c) => c, + other => panic!("应写回 Parametric,实际 {other:?}"), + }; + assert_eq!(curves[0].evaluate(0.5), 0.7); + assert_eq!(curves[1].len(), 1); + + // 坏 JSON → 不改值(与字符串族类型不匹配静默一致)。 + let before = inst.value.params.find("curve").unwrap().get(); + set_text_param(&inst.value, "curve", "{broken"); + assert_eq!(inst.value.params.find("curve").unwrap().get(), before); + + // 非 parametric 参数(String)遇任意文本 → 字符串值路径。 + set_text_param(&inst.value, "title", "hello"); + assert!(matches!( + inst.value.params.find("title").unwrap().get(), + ParamValue::String(_) + )); + unregister_instance(id); + } } diff --git a/crates/oakplugin/src/param.rs b/crates/oakplugin/src/param.rs index 515b52ab3..e7a59cd8a 100644 --- a/crates/oakplugin/src/param.rs +++ b/crates/oakplugin/src/param.rs @@ -585,7 +585,10 @@ impl ParamInstance { /// COLOR→RGB/RGBA、VEC2/VEC3→Double(2D/3D)/Integer(2D/3D)。 /// 维度不齐按 OFX 语义截断/补零(缺失元素按 0)。字符串族 /// (OAKNODE_VALUE_STRING)的 POD 不携带数据——此路径不改值 - /// (走 facade 的字符串 API,见 `include/plugin/instance.h`)。 + /// (走 facade 的字符串 API,见 `include/plugin/instance.h`); + /// parametric 同理(曲线 JSON 文本经渲染期注入 + /// [`crate::node_factory`] 的 set_text_param 写回,见 + /// `crate::node_factory::execute_plugin_job`)。 /// 类型不匹配 → 忽略(保持现值;C++ `node_get` 失败时参数不回写)。 pub fn set_from_node(&self, node_value: &crate::node::Value) { if let Some(pv) = param_value_from_node(node_value, &self.def.ofx_type) { @@ -726,7 +729,10 @@ impl ParamSetInstance { /// TimeChanged 是宿主侧变更,值已由 [`ParamInstance::set_from_node`] /// 同步,不重复写回; /// - 字符串族经 [`crate::node::set_input_string_undoable`] -/// (POD 不携带字符串数据); +/// (POD 不携带字符串数据);parametric 曲线同样经该字符串 setter +/// 以 JSON 文本写回输入([`crate::param_curve::curves_to_json`], +/// 与标量回写同一 undo 语义——插件在实例上改曲线 → 节点输入更新, +/// 工程序列化随标准值保留); /// - 无值类(PushButton/Group/Page)与 Bytes 无节点对应 → no-op; /// - 编辑事务内([`crate::instance::Instance::in_edit`])并入 multi /// 命令,否则单命令立即 redo 生效(C++ `submit_undo_command`)。 @@ -770,6 +776,14 @@ pub(crate) fn notify_instance_changed( instance.submit_undo_command(cmd, &label); } } + // parametric:曲线序列化为 JSON 文本写回输入(节点输入是 + // Parametric 声明类型的 Text 值;经字符串 setter 存储)。 + ParamValue::Parametric(curves) => { + let json = crate::param_curve::curves_to_json(curves); + if let Ok(cmd) = node::set_input_string_undoable(&node_ref, param_name, &json) { + instance.submit_undo_command(cmd, &label); + } + } _ => {} // 无值类 / Bytes:无节点对应 }, } diff --git a/crates/oakplugin/src/param_curve.rs b/crates/oakplugin/src/param_curve.rs index af1704d46..cd4b9d54e 100644 --- a/crates/oakplugin/src/param_curve.rs +++ b/crates/oakplugin/src/param_curve.rs @@ -212,6 +212,256 @@ impl Curve { } } +// ---- 曲线集 ↔ JSON(节点输入 / 工程序列化的值载荷)-------------------- +// +// 格式(确定性、紧凑,无空白): +// {"curves":[[{"key":0.0,"value":0.0,"slope":1.0},...],...]} +// 外层按 dimension 顺序一维一条曲线;每维一个控制点对象,字段序固定 +// key/value/slope。数值用 Rust 最短往返格式化(`{}`)。非有限值 JSON +// 无标准写法,取对称 token:NaN → `null`,+Inf → `"inf"`, +// -Inf → `"-inf"`(字符串字面量;解析器对称处理,有限值恒为裸数字)。 +// 解析器容忍空白与字段乱序;结构不符 → None(调用方按字符串族静默 +// 路径处理)。手写实现,不引入 serde(crate 无该依赖)。 + +/// 曲线集 → JSON(节点输入默认值 / 插件回写节点输入的载荷)。 +pub fn curves_to_json(curves: &[Curve]) -> String { + let mut out = + String::with_capacity(8 + 12 * curves.iter().map(|c| c.len()).sum::()); + out.push_str("{\"curves\":["); + for (ci, c) in curves.iter().enumerate() { + if ci > 0 { + out.push(','); + } + out.push('['); + for (pi, p) in c.points.iter().enumerate() { + if pi > 0 { + out.push(','); + } + out.push_str("{\"key\":"); + push_num(&mut out, p.key); + out.push_str(",\"value\":"); + push_num(&mut out, p.value); + out.push_str(",\"slope\":"); + push_num(&mut out, p.slope); + out.push('}'); + } + out.push(']'); + } + out.push_str("]}"); + out +} + +/// JSON → 曲线集(格式见 [`curves_to_json`];结构不符 → None)。 +pub fn curves_from_json(text: &str) -> Option> { + let mut p = JsonParser { + bytes: text.as_bytes(), + pos: 0, + }; + p.skip_ws(); + if !p.eat(b'{') { + return None; + } + p.skip_ws(); + if p.parse_string()?.as_str() != "curves" { + return None; + } + p.skip_ws(); + if !p.eat(b':') { + return None; + } + p.skip_ws(); + let mut curves = Vec::new(); + if p.eat(b'[') { + loop { + p.skip_ws(); + if p.eat(b']') { + break; + } + curves.push(p.parse_curve()?); + p.skip_ws(); + if p.eat(b',') { + continue; + } + if p.eat(b']') { + break; + } + return None; + } + } else { + return None; + } + p.skip_ws(); + if !p.eat(b'}') { + return None; + } + p.skip_ws(); + if p.pos != p.bytes.len() { + return None; + } + Some(curves) +} + +/// 单个数值 → JSON token(有限值裸数字;NaN/±Inf 见模块文档)。 +fn push_num(out: &mut String, v: f64) { + if v.is_nan() { + out.push_str("null"); + } else if v == f64::INFINITY { + out.push_str("\"inf\""); + } else if v == f64::NEG_INFINITY { + out.push_str("\"-inf\""); + } else { + out.push_str(&format!("{v}")); + } +} + +/// 手写 JSON 解析器(仅本格式的子集;字段名无转义引号,控制点字段 +/// 均为固定标识符)。 +struct JsonParser<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> JsonParser<'a> { + fn skip_ws(&mut self) { + while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() { + self.pos += 1; + } + } + + fn eat(&mut self, byte: u8) -> bool { + if self.bytes.get(self.pos) == Some(&byte) { + self.pos += 1; + true + } else { + false + } + } + + /// 原样匹配一段字节(字段名;不做字符串语义)。 + fn eat_str(&mut self, want: &[u8]) -> bool { + if self.bytes[self.pos..].starts_with(want) { + self.pos += want.len(); + true + } else { + false + } + } + + /// 引号字符串(无转义;字段名/`"inf"` token 用)。 + fn parse_string(&mut self) -> Option { + if !self.eat(b'"') { + return None; + } + let start = self.pos; + while self.pos < self.bytes.len() && self.bytes[self.pos] != b'"' { + self.pos += 1; + } + let s = std::str::from_utf8(&self.bytes[start..self.pos]).ok()?.to_string(); + if !self.eat(b'"') { + return None; + } + Some(s) + } + + /// 数值 token:`null` → NaN;`"inf"`/`"-inf"` → ±∞;否则裸数字 + /// (`f64::from_str`,上溢 → ±∞,与 `{}` 最短往返格式对称)。 + fn parse_num(&mut self) -> Option { + if self.eat_str(b"null") { + return Some(f64::NAN); + } + if self.bytes.get(self.pos) == Some(&b'"') { + return match self.parse_string()?.as_str() { + "inf" => Some(f64::INFINITY), + "-inf" => Some(f64::NEG_INFINITY), + _ => None, + }; + } + let start = self.pos; + while self.pos < self.bytes.len() + && matches!( + self.bytes[self.pos], + b'-' | b'+' | b'.' | b'0'..=b'9' | b'e' | b'E' + ) + { + self.pos += 1; + } + if self.pos == start { + return None; + } + std::str::from_utf8(&self.bytes[start..self.pos]) + .ok()? + .parse() + .ok() + } + + /// 一条曲线:`[` 控制点* `]`(控制点按 key 升序存储,解析按原序)。 + fn parse_curve(&mut self) -> Option { + if !self.eat(b'[') { + return None; + } + let mut points = Vec::new(); + loop { + self.skip_ws(); + if self.eat(b']') { + break; + } + points.push(self.parse_point()?); + self.skip_ws(); + if self.eat(b',') { + continue; + } + if self.eat(b']') { + break; + } + return None; + } + Some(Curve { points }) + } + + /// 一个控制点:`{` ("name" `:` 数值)* `}`(字段乱序可;未知字段 + /// 拒绝)。 + fn parse_point(&mut self) -> Option { + if !self.eat(b'{') { + return None; + } + let mut key = None; + let mut value = None; + let mut slope = None; + loop { + self.skip_ws(); + if self.eat(b'}') { + break; + } + let name = self.parse_string()?; + self.skip_ws(); + if !self.eat(b':') { + return None; + } + self.skip_ws(); + let v = self.parse_num()?; + match name.as_str() { + "key" => key = Some(v), + "value" => value = Some(v), + "slope" => slope = Some(v), + _ => return None, + } + self.skip_ws(); + if self.eat(b',') { + continue; + } + if self.eat(b'}') { + break; + } + return None; + } + Some(ControlPoint { + key: key?, + value: value?, + slope: slope.unwrap_or(0.0), + }) + } +} + /// 第 `i` 点处的差分斜率(不重算,供 [`Curve::recompute_slopes`])。 /// 内部点:中心差分 (y_{i+1} - y_{i-1}) / (x_{i+1} - x_{i-1}); /// 端点:单侧差分;单点/空曲线:0。防御重复 key 时除零 → 0。 @@ -390,4 +640,95 @@ mod tests { assert!(c.is_empty()); assert_eq!(c.evaluate(0.5), 0.5); } + + /// JSON 往返:恒等曲线(默认值)序列化形状逐字 + 解析回等值模型。 + #[test] + fn json_roundtrip_identity() { + let curves = vec![Curve::identity(0.0, 1.0)]; + let json = curves_to_json(&curves); + assert_eq!( + json, + r#"{"curves":[[{"key":0,"value":0,"slope":1},{"key":1,"value":1,"slope":1}]]}"# + ); + let back = curves_from_json(&json).expect("应可解析"); + assert_eq!(back, curves); + } + + /// JSON 往返:多维 + 非平凡形状 + 显式编辑 slope(slope 也是载荷 + /// 的一部分,逐位保留)。 + #[test] + fn json_roundtrip_multidim_and_slope() { + let mut c = Curve::from_pairs(&[(0.0, 0.0), (0.5, 0.25), (1.0, 1.0)]); + c.points[1].slope = 0.0; // 显式编辑(字段公开即契约) + let curves = vec![c.clone(), Curve::identity(0.0, 255.0)]; + let json = curves_to_json(&curves); + let back = curves_from_json(&json).expect("应可解析"); + assert_eq!(back.len(), 2); + assert_eq!(back, curves); + assert_eq!(back[0].points[1].slope, 0.0); + assert_eq!(back[1].points[1].key, 255.0); + // 求值不受序列化影响。 + assert_eq!(back[0].evaluate(0.5), 0.25); + } + + /// JSON 往返:特殊值(NaN/±Inf,slope 与值均可)与空曲线/空集。 + #[test] + fn json_roundtrip_special_and_empty() { + let curves = vec![Curve { + points: vec![ + ControlPoint { + key: 0.0, + value: f64::NAN, + slope: 0.0, + }, + ControlPoint { + key: 1.0, + value: f64::INFINITY, + slope: f64::NEG_INFINITY, + }, + ], + }]; + let json = curves_to_json(&curves); + assert_eq!( + json, + r#"{"curves":[[{"key":0,"value":null,"slope":0},{"key":1,"value":"inf","slope":"-inf"}]]}"# + ); + let back = curves_from_json(&json).expect("应可解析"); + assert!(back[0].points[0].value.is_nan()); + assert_eq!(back[0].points[1].value, f64::INFINITY); + assert_eq!(back[0].points[1].slope, f64::NEG_INFINITY); + + // 空曲线(DeleteAll 后)与空集。 + let curves = vec![Curve::empty(), Curve::empty()]; + let json = curves_to_json(&curves); + assert_eq!(json, r#"{"curves":[[],[]]}"#); + assert_eq!(curves_from_json(&json).unwrap(), curves); + assert_eq!(curves_from_json(r#"{"curves":[]}"#).unwrap(), Vec::::new()); + } + + /// JSON 解析:容忍空白与字段乱序;结构不符 → None(静默路径)。 + #[test] + fn json_parser_tolerances() { + let curves = vec![Curve::from_pairs(&[(0.0, 0.0), (1.0, 1.0)])]; + // 空白 + 字段乱序。 + let spaced = r#"{ "curves" : [ [ { "slope" : 1 , "key" : 0 , "value" : 0 } , { "key" : 1 , "value" : 1 , "slope" : 1 } ] ] }"#; + assert_eq!(curves_from_json(spaced).unwrap(), curves); + // 结构不符 → None。 + for bad in [ + "", + "{}", + r#"{"curves"}"#, + r#"{"curve":[]}"#, + r#"{"curves":[{"key":0}]}"#, + r#"{"curves":[[{"key":"x"}]]}"#, + r#"{"curves":[[{"key":0}]]"#, + r#"{"curves":[[{"key":0,"value":0,"slope":0}]]}junk"#, + ] { + assert!(curves_from_json(bad).is_none(), "应拒绝:{bad}"); + } + // 缺 slope 字段 → 0(宽容)。 + let no_slope = r#"{"curves":[[{"key":0,"value":0}]]}"#; + let back = curves_from_json(no_slope).unwrap(); + assert_eq!(back[0].points[0].slope, 0.0); + } } diff --git a/crates/oakplugin/src/suites/parametric.rs b/crates/oakplugin/src/suites/parametric.rs index 1de6394cf..115dbc369 100644 --- a/crates/oakplugin/src/suites/parametric.rs +++ b/crates/oakplugin/src/suites/parametric.rs @@ -163,8 +163,9 @@ fn curves_of(r: &ParamRef) -> Result, c_int> { /// 写回曲线:Def → 默认值;Instance → 当前值 + instanceChanged 通知 /// (复用 param suite 的 [`crate::suites::param::notify_changed`]—— -/// 曲线无标量节点值,节点回写是 no-op,但走既有通知路径保持宿主侧 -/// 变更感知的纪律一致)。 +/// 曲线经 [`crate::param::notify_instance_changed`] 序列化为 JSON +/// 文本写回节点输入(undoable,同标量路径),保持宿主侧变更感知的 +/// 纪律一致)。 fn write_curves(r: &mut ParamRef, curves: Vec) { match r { ParamRef::Def(d) => d.default = ParamValue::Parametric(curves),