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
This commit is contained in:
2026-08-21 04:20:14 +08:00
parent 980c41acec
commit a43302d9b7
7 changed files with 676 additions and 35 deletions
+41 -1
View File
@@ -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<T>(m: &Mutex<T>) -> 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 → combineXML 桥由
/// 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);
}
}
+15 -5
View File
@@ -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,
}
}
+8 -5
View File
@@ -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())),
};
+252 -20
View File
@@ -281,7 +281,18 @@ fn default_value_for_param(def: &ParamDef) -> Option<NodeValue> {
}
}
ofx::TYPE_BYTES => Some(NodeValue::Binary(Vec::new())),
// PushButton/Group/Page/Parametric/未知 → invalidC++ 末尾
// parametric:值 = 默认曲线的确定性 JSON(节点输入无曲线模型,
// 经 Text 承载;撤销/工程序列化随标准值免费获得)。默认曲线
// 来自 def.defaultdescribe 期插件经 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/未知 → invalidC++ 末尾
// return QVariant())。
_ => None,
}
@@ -450,6 +461,9 @@ fn input_type_for(ofx_type: &str) -> Option<ValueType> {
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<String> {
// 渲染执行器 + 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 → StrChoiceParametric →
/// 解析 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 曲线集的 JSONtype_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::<Vec<_>>()
};
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);
}
}
+16 -2
View File
@@ -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:无节点对应
},
}
+341
View File
@@ -212,6 +212,256 @@ impl Curve {
}
}
// ---- 曲线集 ↔ JSON(节点输入 / 工程序列化的值载荷)--------------------
//
// 格式(确定性、紧凑,无空白):
// {"curves":[[{"key":0.0,"value":0.0,"slope":1.0},...],...]}
// 外层按 dimension 顺序一维一条曲线;每维一个控制点对象,字段序固定
// key/value/slope。数值用 Rust 最短往返格式化(`{}`)。非有限值 JSON
// 无标准写法,取对称 tokenNaN → `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::<usize>());
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<Vec<Curve>> {
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<String> {
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<f64> {
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<Curve> {
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<ControlPoint> {
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::<Curve>::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);
}
}
+3 -2
View File
@@ -163,8 +163,9 @@ fn curves_of(r: &ParamRef) -> Result<Vec<Curve>, 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<Curve>) {
match r {
ParamRef::Def(d) => d.default = ParamValue::Parametric(curves),