Skip to content

Commit 2e345fb

Browse files
feat(toolbox): add Filter Editor toolbox plugin
Port PlotJuggler 3's "Apply filter to data" / transform editor to a PJ4 toolbox panel (toolbox_filter_editor), presented like the colormap / quaternion toolboxes (embedded panel, not a modal dialog). Pick one or more source curves (multi-selection lets the same transform apply to several timeseries at once, matching PJ3), choose a transform, tune its parameters with a live preview, and Save to create the derived series; Cancel reverts. Layout mirrors PJ3: source list | transform list | parameter panel, with the output name auto-derived as "<source>[<Transform>]". Built-in transforms ported from plotjuggler_app/transforms/* (pure C++, transforms.hpp): Absolute, Scale/Offset, Derivative, Integral, Moving Average / RMS / Variance, Outlier Removal, Samples Counter, Binary Filter, Time-since-previous. The catalogue is closed by design — arbitrary Lua / Python scripting is the separate concern of the scripting engine. Each transform declares its incremental look-back so streaming append can recompute only the bounded suffix needed for the new tail (incrementalStartIndex + hasMonotonicOutput in transforms.hpp). The plugin keeps the filter volatile from the datastore point of view: derived points are pushed into the host through createDataSource + appendRecord; on Reset the new remove_data_source SDK slot (release 0.7.0) drops the data source so the catalogue returns to its pre-filter state without orphan topics. Clipboard (process-global JSON) supports copy/paste between curves. Plugin id: toolbox-filter-editor; manifest tagged plot_action so the right-click menu of the plot widget can launch it directly. Tests: transforms_test (per-transform deterministic-causal + look-back correctness against full recompute) — gtest, no Qt. SDK_VERSION bumps to 0.7.0 to require the remove_data_source slot. Also touches the MCAP load dialog only to remove a now-redundant array-size control that is now handled by the embedded parser dialog.
1 parent 25f45ce commit 2e345fb

11 files changed

Lines changed: 2343 additions & 1 deletion

CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ if(TARGET plotjuggler_sdk::plugin_sdk)
3939
add_subdirectory(toolbox_colormap)
4040
add_subdirectory(toolbox_quaternion)
4141
add_subdirectory(toolbox_reactive_scripts_editor)
42+
add_subdirectory(toolbox_filter_editor)
4243
add_subdirectory(toolbox_mosaico)
4344
return()
4445
endif()
@@ -137,5 +138,6 @@ else()
137138
add_subdirectory(toolbox_fft)
138139
add_subdirectory(toolbox_colormap)
139140
add_subdirectory(toolbox_quaternion)
141+
add_subdirectory(toolbox_filter_editor)
140142
add_subdirectory(toolbox_mosaico)
141143
endif()

SDK_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.6.0
1+
0.7.0
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
find_package(nlohmann_json REQUIRED)
2+
3+
include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedManifest.cmake)
4+
include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedUi.cmake)
5+
6+
add_library(toolbox_filter_editor_plugin SHARED filter_editor_plugin.cpp)
7+
target_compile_features(toolbox_filter_editor_plugin PRIVATE cxx_std_20)
8+
target_compile_options(toolbox_filter_editor_plugin PRIVATE ${PJ_WARNING_FLAGS})
9+
10+
target_link_libraries(toolbox_filter_editor_plugin PRIVATE
11+
plotjuggler_sdk::plugin_sdk
12+
nlohmann_json::nlohmann_json
13+
${CMAKE_DL_LIBS}
14+
)
15+
16+
pj_embed_manifest(toolbox_filter_editor_plugin
17+
HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/filter_editor_manifest.hpp
18+
VAR_NAME kFilterEditorManifest
19+
)
20+
21+
pj_embed_ui(toolbox_filter_editor_plugin
22+
UI_FILE ${CMAKE_CURRENT_SOURCE_DIR}/filter_editor_dialog.ui
23+
HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/filter_editor_dialog_ui.hpp
24+
VAR_NAME kFilterEditorDialogUi
25+
)
26+
27+
# v4 plugin discovery: emit a sidecar .pjmanifest.json the host can scan
28+
# pre-dlopen. Content derived from manifest.json + {abi_major, family}.
29+
pj_emit_plugin_manifest(toolbox_filter_editor_plugin
30+
FAMILY toolbox
31+
MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json
32+
)
33+
34+
if(TARGET GTest::gtest_main)
35+
# Built-in predefined transforms engine (pure C++, no Lua).
36+
add_executable(transforms_test transforms_test.cpp)
37+
target_compile_features(transforms_test PRIVATE cxx_std_20)
38+
target_compile_options(transforms_test PRIVATE ${PJ_WARNING_FLAGS})
39+
target_link_libraries(transforms_test PRIVATE GTest::gtest_main)
40+
add_test(NAME transforms_test COMMAND transforms_test)
41+
endif()

toolbox_filter_editor/conanfile.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os
2+
from conan import ConanFile
3+
4+
5+
_SDK_VERSION = (
6+
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "SDK_VERSION"))
7+
.read()
8+
.strip()
9+
)
10+
11+
12+
class ToolboxFilterEditorConan(ConanFile):
13+
name = "toolbox_filter_editor"
14+
version = "0"
15+
settings = "os", "compiler", "build_type", "arch"
16+
generators = "CMakeDeps", "CMakeToolchain"
17+
requires = (
18+
f"plotjuggler_sdk/{_SDK_VERSION}",
19+
"gtest/1.17.0",
20+
"nlohmann_json/3.12.0",
21+
)
22+
default_options = {
23+
"*:shared": False,
24+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#pragma once
2+
// Copyright 2026 Davide Faconti
3+
// SPDX-License-Identifier: MPL-2.0
4+
5+
// CustomFunctionEngine — Lua evaluation core for the Custom Function toolbox.
6+
//
7+
// Faithful port of PlotJuggler 3's transforms/lua_custom_function.cpp. A custom
8+
// function derives ONE output series from a "main" input series plus N optional
9+
// "additional" sources. The user-supplied body becomes the body of a Lua
10+
// function with the exact PJ3 signature:
11+
//
12+
// function calc(time, value, v1, v2, ... vN)
13+
// <user body>
14+
// end
15+
//
16+
// where, for each sample i of the main series:
17+
// - `time` = main timestamp[i]
18+
// - `value` = main value[i]
19+
// - `vk` = additional[k] value sampled at `time` (nearest sample; NaN if
20+
// the additional series is empty)
21+
//
22+
// The body returns one of (matching PJ3 LuaCustomFunction::calculatePoints):
23+
// - a single number -> point (time, number)
24+
// - two numbers (t, v) -> point (t, v)
25+
// - a table of {t, v} pairs -> several points
26+
//
27+
// Header-only (sol2 in the header) so the engine is unit-testable without the
28+
// plugin host. No Qt, no datastore — input/output are plain doubles.
29+
30+
#include <array>
31+
#include <cmath>
32+
#include <optional>
33+
#include <sol/sol.hpp>
34+
#include <string>
35+
#include <vector>
36+
37+
namespace pj_custom_function {
38+
39+
/// Read-only timestamp/value view over one input series (timestamps ascending).
40+
struct SeriesAccessor {
41+
std::vector<double> timestamps;
42+
std::vector<double> values;
43+
44+
[[nodiscard]] size_t size() const {
45+
return timestamps.size();
46+
}
47+
[[nodiscard]] bool empty() const {
48+
return timestamps.empty();
49+
}
50+
51+
/// Value at the sample whose timestamp is closest to `t` (PJ3 getIndexFromX
52+
/// semantics). Returns NaN when the series is empty.
53+
[[nodiscard]] double valueAtNearest(double t) const {
54+
if (timestamps.empty()) {
55+
return std::numeric_limits<double>::quiet_NaN();
56+
}
57+
auto it = std::lower_bound(timestamps.begin(), timestamps.end(), t);
58+
if (it == timestamps.end()) {
59+
return values.back();
60+
}
61+
if (it == timestamps.begin()) {
62+
return values.front();
63+
}
64+
size_t idx = static_cast<size_t>(it - timestamps.begin());
65+
// Pick whichever of idx-1 / idx is closer in time.
66+
double d_hi = timestamps[idx] - t;
67+
double d_lo = t - timestamps[idx - 1];
68+
return (d_lo <= d_hi) ? values[idx - 1] : values[idx];
69+
}
70+
};
71+
72+
/// One derived output sample.
73+
struct OutputPoint {
74+
double t;
75+
double v;
76+
};
77+
78+
/// Compiles + runs a PJ3-style custom function. Construct, compile() once, then
79+
/// evaluate() as the main series grows (incremental via `after_timestamp`).
80+
class CustomFunctionEngine {
81+
public:
82+
/// Compile `global_code` (run once, may define helpers/vars) and wrap
83+
/// `function_body` into `calc(time, value, v1..vN)` for `num_additional`
84+
/// extra sources. Returns "" on success or a human-readable error.
85+
std::string compile(const std::string& global_code, const std::string& function_body, size_t num_additional) {
86+
num_additional_ = num_additional;
87+
lua_ = sol::state{};
88+
lua_.open_libraries(sol::lib::base, sol::lib::string, sol::lib::math, sol::lib::table);
89+
calc_ = sol::protected_function{};
90+
91+
if (!global_code.empty()) {
92+
auto result = lua_.safe_script(global_code, sol::script_pass_on_error);
93+
if (!result.valid()) {
94+
sol::error err = result;
95+
return std::string("Global: ") + err.what();
96+
}
97+
}
98+
99+
std::string signature = "function calc(time, value";
100+
for (size_t i = 1; i <= num_additional; ++i) {
101+
signature += ", v" + std::to_string(i);
102+
}
103+
signature += ")\n" + function_body + "\nend";
104+
105+
auto result = lua_.safe_script(signature, sol::script_pass_on_error);
106+
if (!result.valid()) {
107+
sol::error err = result;
108+
return err.what();
109+
}
110+
calc_ = lua_.get<sol::protected_function>("calc");
111+
if (!calc_.valid()) {
112+
return "internal error: calc() not defined";
113+
}
114+
return "";
115+
}
116+
117+
/// Evaluate over every main sample with `timestamp > after_timestamp`,
118+
/// looking up each additional source at the main timestamp. Appends derived
119+
/// points to `out`. Returns "" on success or a human-readable error.
120+
std::string evaluate(
121+
const SeriesAccessor& main, const std::vector<const SeriesAccessor*>& additional, double after_timestamp,
122+
std::vector<OutputPoint>& out) {
123+
if (!calc_.valid()) {
124+
return "internal error: function not compiled";
125+
}
126+
if (additional.size() != num_additional_) {
127+
return "internal error: additional source count mismatch";
128+
}
129+
130+
std::vector<double> args;
131+
args.reserve(num_additional_ + 2);
132+
133+
for (size_t i = 0; i < main.size(); ++i) {
134+
const double t = main.timestamps[i];
135+
if (t <= after_timestamp) {
136+
continue;
137+
}
138+
args.clear();
139+
args.push_back(t);
140+
args.push_back(main.values[i]);
141+
for (const SeriesAccessor* src : additional) {
142+
args.push_back(src != nullptr ? src->valueAtNearest(t) : std::numeric_limits<double>::quiet_NaN());
143+
}
144+
145+
sol::protected_function_result result = calc_(sol::as_args(args));
146+
if (!result.valid()) {
147+
sol::error err = result;
148+
return err.what();
149+
}
150+
if (std::string e = appendResult(result, t, out); !e.empty()) {
151+
return e;
152+
}
153+
}
154+
return "";
155+
}
156+
157+
private:
158+
// Interpret the Lua return value(s) exactly like PJ3 LuaCustomFunction.
159+
static std::string appendResult(sol::protected_function_result& result, double time, std::vector<OutputPoint>& out) {
160+
const int count = result.return_count();
161+
if (count >= 2 && result.get_type(0) == sol::type::number && result.get_type(1) == sol::type::number) {
162+
out.push_back({result.get<double>(0), result.get<double>(1)});
163+
return "";
164+
}
165+
if (count == 1 && result.get_type(0) == sol::type::number) {
166+
out.push_back({time, result.get<double>(0)});
167+
return "";
168+
}
169+
if (count == 1 && result.get_type(0) == sol::type::table) {
170+
sol::table table = result.get<sol::table>(0);
171+
for (size_t i = 1; i <= table.size(); ++i) {
172+
sol::object element = table.get<sol::object>(i);
173+
if (!element.is<sol::table>()) {
174+
return "Wrong return object: expected an array of {time, value} pairs";
175+
}
176+
sol::table pair = element.as<sol::table>();
177+
out.push_back({pair[1].get<double>(), pair[2].get<double>()});
178+
}
179+
return "";
180+
}
181+
return "Wrong return object: expecting either a single value, two values (time, value) "
182+
"or an array of two-sized arrays (time, value)";
183+
}
184+
185+
sol::state lua_;
186+
sol::protected_function calc_;
187+
size_t num_additional_ = 0;
188+
};
189+
190+
} // namespace pj_custom_function

0 commit comments

Comments
 (0)