From cd028e8d4fe9d0e58e77810cc5c5964a2689e910 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Sat, 22 Aug 2026 20:08:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E7=BB=B4=E9=A2=91=E8=B0=B1=E5=81=9A?= =?UTF-8?q?=E5=A5=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kernel/src/kernel/plot/Axis.cpp | 145 ++++++++ kernel/src/kernel/plot/Axis.hpp | 64 ++++ kernel/src/kernel/plot/Camera.hpp | 51 +++ kernel/src/kernel/plot/Time_Domain.hpp | 18 + render_2D/render_2D/axis/Abs_Axis.cpp | 9 +- render_2D/render_2D/axis/Axis_Types.cpp | 23 -- render_2D/render_2D/axis/Axis_Types.hpp | 34 +- render_3D/render_3D/Render_3D.hpp | 2 + render_3D/render_3D/axis/Axes_3D.hpp | 24 ++ render_3D/render_3D/camera/Camera_3D.hpp | 24 ++ .../render_3D/detail/Async_Render_Backend.cpp | 5 +- render_3D/render_3D/detail/Backend_Types.hpp | 6 + .../detail/Datoviz_Visual_Backend.cpp | 335 ++++++++++++++++-- .../detail/Datoviz_Visual_Backend.hpp | 11 +- render_3D/render_3D/scene/Render_Scene_3D.hpp | 20 +- render_3D/render_3D/scene/Render_Scene_3D.ipp | 106 +++++- render_3D/todolist.md | 237 +++++++++++++ web_server/src/Gallery_Plots_3D.cpp | 295 ++++++++++++++- web_server/src/Gallery_Plots_3D.hpp | 1 + web_server/src/Renderable_Adapter.ipp | 4 + web_server/src/Web_Server.cpp | 1 + webapp_gallery/src/app.tsx | 81 +++-- 22 files changed, 1352 insertions(+), 144 deletions(-) create mode 100644 kernel/src/kernel/plot/Axis.cpp create mode 100644 kernel/src/kernel/plot/Axis.hpp create mode 100644 kernel/src/kernel/plot/Camera.hpp create mode 100644 kernel/src/kernel/plot/Time_Domain.hpp create mode 100644 render_3D/render_3D/axis/Axes_3D.hpp create mode 100644 render_3D/render_3D/camera/Camera_3D.hpp create mode 100644 render_3D/todolist.md diff --git a/kernel/src/kernel/plot/Axis.cpp b/kernel/src/kernel/plot/Axis.cpp new file mode 100644 index 0000000..ace06c7 --- /dev/null +++ b/kernel/src/kernel/plot/Axis.cpp @@ -0,0 +1,145 @@ +#include "Axis.hpp" + +#include +#include +#include +#include +#include +#include + +namespace aethera::plot { +namespace { + +std::string trim_number(std::string value) { + const auto exponent = value.find_first_of("eE"); + const auto fraction_end = exponent == std::string::npos ? value.size() : exponent; + const auto decimal = value.find('.'); + if (decimal == std::string::npos || decimal >= fraction_end) return value; + auto last = fraction_end; + while (last > decimal + 1 && value[last - 1] == '0') --last; + if (last == decimal + 1) --last; + value.erase(last, fraction_end - last); + return value; +} + +std::string numeric_label(double value, int precision) { + std::ostringstream stream; + const auto magnitude = std::abs(value); + std::string suffix; + if (magnitude >= 1.0e3 && magnitude < 1.0e6) { + value /= 1.0e3; + suffix = "k"; + stream << std::fixed; + } else if ((magnitude >= 1.0e6 || (magnitude > 0.0 && magnitude < 1.0e-4))) + stream << std::scientific; + else + stream << std::fixed; + stream << std::setprecision(std::clamp(precision, 0, 12)) << value; + auto result = trim_number(stream.str()) + suffix; + return result; +} + +std::vector linear_ticks(const Axis_Descriptor& axis) { + const auto step = nice_tick_step(axis.range, axis.target_tick_count); + if (!(step > 0.0) || !std::isfinite(step)) return {}; + const auto [minimum, maximum] = std::minmax(axis.range.origin, axis.range.target); + const auto first = std::ceil(minimum / step) * step; + std::vector ticks; + const auto maximum_count = std::max(2, axis.target_tick_count * 4 + 4); + for (std::size_t index = 0; index < maximum_count; ++index) { + const auto coordinate = first + static_cast(index) * step; + if (coordinate > maximum + step * 1.0e-9) break; + ticks.push_back({coordinate, numeric_label(coordinate, axis.precision)}); + } + if (axis.range.length() < 0.0) std::ranges::reverse(ticks); + return ticks; +} + +std::vector logarithmic_ticks(const Axis_Descriptor& axis) { + const auto [minimum, maximum] = std::minmax(axis.range.origin, axis.range.target); + if (!(minimum > 0.0) || !std::isfinite(maximum)) return {}; + const auto first_power = static_cast(std::floor(std::log10(minimum))); + const auto last_power = static_cast(std::ceil(std::log10(maximum))); + const auto decade_count = static_cast(last_power - first_power + 1); + const auto multiples = axis.target_tick_count <= decade_count + ? std::vector{1.0} + : axis.target_tick_count <= decade_count * 2 + ? std::vector{1.0, 5.0} + : std::vector{1.0, 2.0, 5.0}; + std::vector ticks; + for (int power = first_power; power <= last_power; ++power) { + const auto decade = std::pow(10.0, static_cast(power)); + for (const double multiple : multiples) { + const auto coordinate = multiple * decade; + if (coordinate >= minimum * (1.0 - 1.0e-12) && + coordinate <= maximum * (1.0 + 1.0e-12)) + ticks.push_back({coordinate, numeric_label(coordinate, axis.precision)}); + } + } + const auto add_boundary = [&](double coordinate) { + const auto tolerance = coordinate * 1.0e-9; + if (std::ranges::none_of(ticks, [&](const Axis_Tick& tick) { + return std::abs(tick.coordinate - coordinate) <= tolerance; + })) { + const auto logarithmic_span = std::log(maximum / minimum); + const auto minimum_spacing = 0.5 / + static_cast(std::max(2, axis.target_tick_count) - 1); + if (logarithmic_span > 0.0) { + const auto nearest = std::ranges::min_element( + ticks, {}, [&](const Axis_Tick& tick) { + return std::abs(std::log(tick.coordinate / coordinate)) / + logarithmic_span; + }); + if (nearest != ticks.end() && + std::abs(std::log(nearest->coordinate / coordinate)) / + logarithmic_span < minimum_spacing) + ticks.erase(nearest); + } + ticks.push_back({coordinate, numeric_label(coordinate, axis.precision)}); + } + }; + add_boundary(minimum); + add_boundary(maximum); + std::ranges::sort(ticks, {}, &Axis_Tick::coordinate); + if (axis.range.length() < 0.0) std::ranges::reverse(ticks); + return ticks; +} + +} // namespace + +Axis_Coordinate Axis_Range::size() const noexcept { + return std::abs(length()); +} + +Axis_Coordinate Axis_Range::length() const noexcept { + return target - origin; +} + +Axis_Coordinate Axis_Range::center() const noexcept { + return origin + length() * 0.5; +} + +bool Axis_Range::contains(Axis_Coordinate coordinate) const noexcept { + const auto [minimum, maximum] = std::minmax(origin, target); + return coordinate >= minimum - 1.0e-9 && coordinate <= maximum + 1.0e-9; +} + +Axis_Coordinate nice_tick_step(Axis_Range range, std::size_t target_tick_count) { + if (!std::isfinite(range.origin) || !std::isfinite(range.target) || range.size() <= 0.0) + throw std::invalid_argument("axis range must be finite and non-empty"); + const auto count = std::max(2, target_tick_count); + const auto raw = range.size() / static_cast(count - 1); + const auto magnitude = std::pow(10.0, std::floor(std::log10(raw))); + const auto normalized = raw / magnitude; + const auto nice = normalized <= 1.0 ? 1.0 : normalized <= 2.0 ? 2.0 : + normalized <= 5.0 ? 5.0 : 10.0; + return nice * magnitude; +} + +std::vector axis_ticks(const Axis_Descriptor& axis) { + if (!axis.visible) return {}; + if (axis.scale == Axis_Scale::logarithmic) return logarithmic_ticks(axis); + return linear_ticks(axis); +} + +} // namespace aethera::plot diff --git a/kernel/src/kernel/plot/Axis.hpp b/kernel/src/kernel/plot/Axis.hpp new file mode 100644 index 0000000..a769e14 --- /dev/null +++ b/kernel/src/kernel/plot/Axis.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +namespace aethera::plot { + +using Axis_Coordinate = double; + +enum class Axis_Scale : std::uint8_t { + linear, + logarithmic, + time +}; + +struct Axis_Range { + Axis_Coordinate origin{}; + Axis_Coordinate target{}; + + [[nodiscard]] Axis_Coordinate size() const noexcept; + [[nodiscard]] Axis_Coordinate length() const noexcept; + [[nodiscard]] Axis_Coordinate center() const noexcept; + [[nodiscard]] bool contains(Axis_Coordinate coordinate) const noexcept; + bool operator==(const Axis_Range&) const = default; +}; + +struct Axis_Point { + Axis_Coordinate horizontal{}; + Axis_Coordinate vertical{}; + bool operator==(const Axis_Point&) const = default; +}; + +struct Axis_Rectangle { + Axis_Range horizontal{}; + Axis_Range vertical{}; + bool operator==(const Axis_Rectangle&) const = default; +}; + +struct Axis_Descriptor { + Axis_Range range{}; + Axis_Scale scale{Axis_Scale::linear}; + std::string label{}; + std::string unit{}; + std::size_t target_tick_count{6}; + int precision{2}; + bool visible{true}; + bool grid_visible{true}; + bool labels_visible{true}; + bool operator==(const Axis_Descriptor&) const = default; +}; + +struct Axis_Tick { + Axis_Coordinate coordinate{}; + std::string label{}; + bool operator==(const Axis_Tick&) const = default; +}; + +[[nodiscard]] Axis_Coordinate nice_tick_step(Axis_Range range, + std::size_t target_tick_count = 6); +[[nodiscard]] std::vector axis_ticks(const Axis_Descriptor& axis); + +} // namespace aethera::plot diff --git a/kernel/src/kernel/plot/Camera.hpp b/kernel/src/kernel/plot/Camera.hpp new file mode 100644 index 0000000..7698169 --- /dev/null +++ b/kernel/src/kernel/plot/Camera.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include + +namespace aethera::plot { + +struct Spatial_Point { + double x{}; + double y{}; + double z{}; + bool operator==(const Spatial_Point&) const = default; +}; + +enum class Camera_Projection : std::uint8_t { + perspective, + orthographic +}; + +struct Camera_View { + Spatial_Point eye{2.8, -3.2, 2.4}; + Spatial_Point target{}; + Spatial_Point up{0.0, 0.0, 1.0}; + bool operator==(const Camera_View&) const = default; +}; + +struct Camera_Control { + double yaw_speed{0.20}; + double pitch_speed{0.20}; + double zoom_speed{0.10}; + double pan_speed{0.002}; + double minimum_pitch{-1.45}; + double maximum_pitch{1.45}; + double minimum_distance{0.25}; + double maximum_distance{50.0}; + bool rotate_enabled{true}; + bool zoom_enabled{true}; + bool pan_enabled{true}; + bool operator==(const Camera_Control&) const = default; +}; + +struct Camera_Descriptor { + Camera_View initial_view{}; + Camera_Projection projection{Camera_Projection::perspective}; + Camera_Control control{}; + double vertical_field_of_view_degrees{45.0}; + double near_plane{0.01}; + double far_plane{100.0}; + bool operator==(const Camera_Descriptor&) const = default; +}; + +} // namespace aethera::plot diff --git a/kernel/src/kernel/plot/Time_Domain.hpp b/kernel/src/kernel/plot/Time_Domain.hpp new file mode 100644 index 0000000..2f2eb8c --- /dev/null +++ b/kernel/src/kernel/plot/Time_Domain.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "Axis.hpp" + +#include +#include + +namespace aethera::plot { + +struct Time_Domain { + Axis_Range seconds{}; + std::size_t visible_sample_count{}; + bool realtime{}; + bool newest_at_start{}; + bool operator==(const Time_Domain&) const = default; +}; + +} // namespace aethera::plot diff --git a/render_2D/render_2D/axis/Abs_Axis.cpp b/render_2D/render_2D/axis/Abs_Axis.cpp index c9f549c..138db61 100644 --- a/render_2D/render_2D/axis/Abs_Axis.cpp +++ b/render_2D/render_2D/axis/Abs_Axis.cpp @@ -32,12 +32,9 @@ int Abs_Axis::Private::sub_tick_count(const Root* object, double major_step) con return dispatch->sub_tick_count(object, major_step); } double Abs_Axis::Private::nice_tick_step(Axis_Range coordinate_range) { - const double raw_step = coordinate_range.size() / 5.0; - if (!(raw_step > 0.0) || !std::isfinite(raw_step)) return 1.0; - const double scale = std::pow(10.0, std::floor(std::log10(raw_step))); - const double normalized = raw_step / scale; - const double nice = normalized <= 1.0 ? 1.0 : normalized <= 2.0 ? 2.0 : normalized <= 5.0 ? 5.0 : 10.0; - return nice * scale; + if (!(coordinate_range.size() > 0.0) || !std::isfinite(coordinate_range.origin) || + !std::isfinite(coordinate_range.target)) return 1.0; + return plot::nice_tick_step(coordinate_range, 6); } std::string Abs_Axis::Private::localized_number(double value, int precision, Number_Locale locale) { std::ostringstream stream; diff --git a/render_2D/render_2D/axis/Axis_Types.cpp b/render_2D/render_2D/axis/Axis_Types.cpp index 30048fd..8693acd 100644 --- a/render_2D/render_2D/axis/Axis_Types.cpp +++ b/render_2D/render_2D/axis/Axis_Types.cpp @@ -1,24 +1 @@ #include "Axis_Types.hpp" - -namespace aethera::render_2d { -Axis_Coordinate Axis_Range::size() const noexcept { - return std::abs(length()); -} - -Axis_Coordinate Axis_Range::length() const noexcept { - return target - origin; -} - -Axis_Coordinate Axis_Range::center() const noexcept { - return origin + length() * 0.5; -} - -bool Axis_Range::contains(Axis_Coordinate coordinate) const noexcept { - const auto [low, high] = std::minmax(origin, target); - return coordinate >= low - 1e-9 && coordinate <= high + 1e-9; -} - -bool Axis_Range::operator==(const Axis_Range&) const = default; -bool Axis_Point::operator==(const Axis_Point&) const = default; -bool Axis_Rectangle::operator==(const Axis_Rectangle&) const = default; -} diff --git a/render_2D/render_2D/axis/Axis_Types.hpp b/render_2D/render_2D/axis/Axis_Types.hpp index e69dfbd..c2954d7 100644 --- a/render_2D/render_2D/axis/Axis_Types.hpp +++ b/render_2D/render_2D/axis/Axis_Types.hpp @@ -1,10 +1,12 @@ #pragma once #include "../base/Types.hpp" -#include -#include +#include #include namespace aethera::render_2d { -using Axis_Coordinate = double; +using plot::Axis_Coordinate; +using plot::Axis_Point; +using plot::Axis_Range; +using plot::Axis_Rectangle; using Axis_Pixel_Position = double; using Axis_Pixel_Length = double; using Axis_Tick_Length = double; @@ -19,30 +21,4 @@ enum class Axis_Orientation : std::uint8_t { horizontal, vertical }; -/* 坐标轴上的有向数值区间;origin 到 target 的顺序决定坐标增长方向。 */ -struct Axis_Range { - Axis_Coordinate origin{}; /* 区间起点坐标。 */ - Axis_Coordinate target{}; /* 区间终点坐标;可小于 origin 以表达反向坐标轴。 */ - /* 返回不带方向的区间跨度。 */ - [[nodiscard]] Axis_Coordinate size() const noexcept; - /* 返回保留方向的区间长度。 */ - [[nodiscard]] Axis_Coordinate length() const noexcept; - /* 返回区间中点。 */ - [[nodiscard]] Axis_Coordinate center() const noexcept; - /* 判断坐标是否落在区间内;正向和反向区间使用相同边界语义。 */ - [[nodiscard]] bool contains(Axis_Coordinate coordinate) const noexcept; - bool operator==(const Axis_Range&) const; -}; -/* 两根正交轴上的一个数据坐标点;不表示画布像素。 */ -struct Axis_Point { - Axis_Coordinate horizontal{}; /* 水平轴上的业务坐标。 */ - Axis_Coordinate vertical{}; /* 垂直轴上的业务坐标。 */ - bool operator==(const Axis_Point&) const; -}; -/* 两根正交轴定义的数据选择区域;绘制时才映射为像素矩形。 */ -struct Axis_Rectangle { - Axis_Range horizontal{}; /* 水平轴上的已选坐标范围。 */ - Axis_Range vertical{}; /* 垂直轴上的已选坐标范围。 */ - bool operator==(const Axis_Rectangle&) const; -}; } diff --git a/render_3D/render_3D/Render_3D.hpp b/render_3D/render_3D/Render_3D.hpp index 1e414cf..ca8e74d 100644 --- a/render_3D/render_3D/Render_3D.hpp +++ b/render_3D/render_3D/Render_3D.hpp @@ -1,3 +1,5 @@ #pragma once +#include "axis/Axes_3D.hpp" +#include "camera/Camera_3D.hpp" #include "scene/Render_Scene_3D.hpp" #include "visual/Visuals.hpp" diff --git a/render_3D/render_3D/axis/Axes_3D.hpp b/render_3D/render_3D/axis/Axes_3D.hpp new file mode 100644 index 0000000..3aafd0e --- /dev/null +++ b/render_3D/render_3D/axis/Axes_3D.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace aethera::render_3d { + +struct Axes_3D : Def { + struct Prop : Prev_Prop { + plot::Axis_Descriptor x_axis{{0.0, 4.0}, plot::Axis_Scale::time, + "Time", "s", 6, 1, true, true, true}; + plot::Axis_Descriptor y_axis{{10.0, 20'000.0}, plot::Axis_Scale::logarithmic, + "Frequency", "Hz", 6, 1, true, true, true}; + plot::Axis_Descriptor z_axis{{18.0, 78.0}, plot::Axis_Scale::linear, + "SPL", "dB", 7, 0, true, true, true}; + bool operator==(const Prop&) const = default; + }; + struct State : Prev_State { + bool operator==(const State&) const = default; + }; + struct Private : Prev_Private {}; +}; + +} // namespace aethera::render_3d diff --git a/render_3D/render_3D/camera/Camera_3D.hpp b/render_3D/render_3D/camera/Camera_3D.hpp new file mode 100644 index 0000000..5faa088 --- /dev/null +++ b/render_3D/render_3D/camera/Camera_3D.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace aethera::render_3d { + +struct Camera_3D : Def { + struct Prop : Prev_Prop { + plot::Camera_View initial_view{}; + plot::Camera_Projection projection{plot::Camera_Projection::perspective}; + plot::Camera_Control control{}; + double vertical_field_of_view_degrees{45.0}; + double near_plane{0.01}; + double far_plane{100.0}; + bool operator==(const Prop&) const = default; + }; + struct State : Prev_State { + bool operator==(const State&) const = default; + }; + struct Private : Prev_Private {}; +}; + +} // namespace aethera::render_3d diff --git a/render_3D/render_3D/detail/Async_Render_Backend.cpp b/render_3D/render_3D/detail/Async_Render_Backend.cpp index eabcaa3..b847e99 100644 --- a/render_3D/render_3D/detail/Async_Render_Backend.cpp +++ b/render_3D/render_3D/detail/Async_Render_Backend.cpp @@ -10,7 +10,10 @@ #include namespace aethera::render_3d::detail { namespace { -float wheel_step(double pixel, double angle) { return static_cast(pixel != 0.0 ? pixel : angle / 120.0); } +float wheel_step(double pixel, double angle) { + if (angle != 0.0) return static_cast(angle / 120.0); + return static_cast(pixel / 100.0); +} Mouse_Button held_button(Mouse_Button_Mask buttons) { if ((buttons & 1U) != 0) return Mouse_Button::left; if ((buttons & 2U) != 0) return Mouse_Button::right; if ((buttons & 4U) != 0) return Mouse_Button::middle; return Mouse_Button::none; } void record_datoviz_trace(Frame_3D* frame, const Datoviz_Frame_Trace& trace) { if (trace.apply_ns) frame->record(Frame_Trace_Measurement::backend_apply_ns, trace.apply_ns); diff --git a/render_3D/render_3D/detail/Backend_Types.hpp b/render_3D/render_3D/detail/Backend_Types.hpp index 03c3cce..4c23625 100644 --- a/render_3D/render_3D/detail/Backend_Types.hpp +++ b/render_3D/render_3D/detail/Backend_Types.hpp @@ -1,10 +1,16 @@ #pragma once #include "../visual/Prepared_Visual.hpp" #include +#include +#include namespace aethera::render_3d::detail { struct Scene_3D_Parameters { Extent viewport{560, 320}; /* 离屏渲染目标尺寸,单位为像素。 */ Linear_Color clear_color{}; /* 每帧开始时写入的线性背景色。 */ + plot::Camera_Descriptor camera{}; /* Camera 组件发布的本帧配置快照。 */ + plot::Axis_Descriptor x_axis{}; /* 三维数据 X 轴的业务范围与标签策略。 */ + plot::Axis_Descriptor y_axis{}; /* 三维数据 Y 轴的业务范围与标签策略。 */ + plot::Axis_Descriptor z_axis{}; /* 三维数据 Z 轴的业务范围与标签策略。 */ bool operator==(const Scene_3D_Parameters&) const = default; }; } diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp index 6869d5e..54977b2 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp @@ -12,12 +12,18 @@ #include #include #include +#include #include #include #include namespace aethera::render_3d::detail { namespace { constexpr std::uint64_t color_target_id = 0x5256504f494e54ULL; +DvzCapabilitySnapshot offscreen_capabilities() { + auto capabilities = dvz_capability_snapshot(); + capabilities.supports_color_blending = true; + return capabilities; +} std::uint64_t trace_now_ns() noexcept { return static_cast( std::chrono::duration_cast( @@ -73,6 +79,30 @@ DvzShapeAspect aspect(Point_Aspect value) { } return DVZ_SHAPE_ASPECT_FILLED; } + +float axis_position(const plot::Axis_Descriptor& axis, double coordinate) { + const auto origin = axis.range.origin; + const auto target = axis.range.target; + if (!std::isfinite(origin) || !std::isfinite(target) || origin == target) + throw std::invalid_argument("3D axis range must be finite and non-empty"); + double ratio{}; + if (axis.scale == plot::Axis_Scale::logarithmic) { + if (!(origin > 0.0) || !(target > 0.0) || !(coordinate > 0.0)) + throw std::invalid_argument("logarithmic 3D axis coordinates must be positive"); + ratio = (std::log10(coordinate) - std::log10(origin)) / + (std::log10(target) - std::log10(origin)); + } else { + ratio = (coordinate - origin) / (target - origin); + } + return static_cast(-1.0 + 2.0 * ratio); +} + +std::string axis_title(const plot::Axis_Descriptor& axis) { + if (axis.label.empty()) return axis.unit; + if (axis.unit.empty()) return axis.label; + return axis.label + " (" + axis.unit + ")"; +} + void configure_glyph_text(DvzScene* scene, DvzVisual* visual, const char* text) { auto font_descriptor = dvz_font_desc(); font_descriptor.family = "Roboto"; @@ -139,6 +169,11 @@ void configure_glyph_text(DvzScene* scene, DvzVisual* visual, const char* text) dvz_visual_set_depth_test(visual, false) != DVZ_OK) throw std::runtime_error("failed to upload Datoviz text geometry"); } + +bool supports_item_interaction(Visual_Family family) noexcept { + return family == Visual_Family::point || family == Visual_Family::pixel || + family == Visual_Family::marker; +} } // namespace class Datoviz_Render_Context final { public: @@ -593,7 +628,7 @@ void Datoviz_Visual_Backend::require_domain() const { void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_Parameters& initial_scene) { scene_ = dvz_scene(); if (scene_ == nullptr) throw std::runtime_error("failed to create Datoviz scene"); - const auto capabilities = dvz_capability_snapshot(); + const auto capabilities = offscreen_capabilities(); if (dvz_scene_set_capabilities(scene_, &capabilities) != DVZ_OK) throw std::runtime_error("failed to configure Datoviz scene capabilities"); figure_ = dvz_figure(scene_, initial_scene.viewport.width, initial_scene.viewport.height, 0); @@ -745,33 +780,33 @@ void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_P if (figure_ == nullptr || panel_ == nullptr || visual_ == nullptr || dvz_panel_add_visual(panel_, visual_, nullptr) != DVZ_OK) throw std::runtime_error("failed to create Datoviz visual family"); - DvzVisual* axes = dvz_segment(scene_, 0); - const std::array axis_starts{{{0.0F, 0.0F, 0.0F}, {0.0F, 0.0F, 0.0F}, {0.0F, 0.0F, 0.0F}}}; - const std::array axis_ends{{{1.15F, 0.0F, 0.0F}, {0.0F, 1.15F, 0.0F}, {0.0F, 0.0F, 1.15F}}}; - const std::array axis_colors{{{238, 72, 89, 230}, {64, 210, 143, 230}, {70, 132, 245, 230}}}; - const std::array axis_widths{{3.0F, 3.0F, 3.0F}}; - const std::array axis_updates{{ - {"position_start", axis_starts.data(), 3}, {"position_end", axis_ends.data(), 3}, - {"color", axis_colors.data(), 3}, {"stroke_width_px", axis_widths.data(), 3}}}; - if (axes == nullptr || dvz_visual_set_data_many(axes, axis_updates.data(), 4) != DVZ_OK || - dvz_segment_set_caps(axes, DVZ_SEGMENT_CAP_BUTT, DVZ_SEGMENT_CAP_BUTT) != DVZ_OK || - dvz_panel_add_visual(panel_, axes, nullptr) != DVZ_OK) - throw std::runtime_error("failed to create Datoviz coordinate axes"); - DvzCameraDesc camera = dvz_camera_desc(); - camera.view.eye[0] = 0.0F; - camera.view.eye[1] = 0.0F; - camera.view.eye[2] = 4.0F; - camera.view.target[0] = 0.0F; - camera.view.target[1] = 0.0F; - camera.view.target[2] = 0.0F; - camera.projection.near_clip = 0.01F; - camera.projection.far_clip = 100.0F; - if (dvz_panel_set_camera_desc(panel_, &camera) != DVZ_OK) throw std::runtime_error("failed to create Datoviz point camera"); - DvzController* controller = dvz_arcball(scene_, nullptr); - arcball_ = controller != nullptr ? dvz_controller_arcball(controller) : nullptr; - if (controller == nullptr || arcball_ == nullptr || - dvz_panel_bind_controller(panel_, controller, DVZ_DIM_MASK_XYZ) != DVZ_OK) - throw std::runtime_error("failed to bind Datoviz arcball controller"); + if (supports_item_interaction(family)) { + if (dvz_visual_set_query_capabilities( + visual_, DVZ_QUERY_CAPABILITY_ITEM) != DVZ_OK) + throw std::runtime_error("failed to enable Datoviz item queries"); + item_interaction_ = dvz_item_interaction(panel_, nullptr); + if (item_interaction_ == nullptr) + throw std::runtime_error("failed to create Datoviz item interaction"); + } + axes_visual_ = dvz_segment(scene_, 0); + axes_text_ = dvz_text(panel_, 0); + if (axes_visual_ == nullptr || axes_text_ == nullptr || + dvz_segment_set_caps(axes_visual_, DVZ_SEGMENT_CAP_BUTT, + DVZ_SEGMENT_CAP_BUTT) != DVZ_OK || + dvz_visual_set_alpha_mode(axes_visual_, DVZ_ALPHA_OPAQUE) != DVZ_OK || + dvz_panel_add_visual(panel_, axes_visual_, nullptr) != DVZ_OK) + throw std::runtime_error("failed to create Datoviz 3D axes visuals"); + DvzTextPlacement axes_placement = dvz_text_placement(); + axes_placement.mode = DVZ_TEXT_PLACEMENT_DATA; + axes_placement.anchor = DVZ_SCENE_ANCHOR_DATA; + axes_placement.depth_test = false; + DvzTextStyle axes_style = dvz_text_style(); + axes_style.renderer = DVZ_TEXT_RENDERER_MSDF_ATLAS; + axes_style.size_px = 12.0F; + if (dvz_text_set_placement(axes_text_, &axes_placement) != DVZ_OK || + dvz_text_set_style(axes_text_, &axes_style) != DVZ_OK) + throw std::runtime_error("failed to configure Datoviz 3D axes text"); + apply_axes(initial_scene); input_router_ = dvz_input_router(); gesture_handler_ = input_router_ != nullptr ? dvz_pointer_gesture_handler(input_router_) @@ -779,12 +814,188 @@ void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_P if (input_router_ == nullptr || gesture_handler_ == nullptr || dvz_panel_connect_input(panel_, input_router_) != DVZ_OK) throw std::runtime_error("failed to connect Datoviz point input"); + apply_camera(initial_scene.camera); DvzInputResizeEvent resize{ initial_scene.viewport.width, initial_scene.viewport.height, initial_scene.viewport.width, initial_scene.viewport.height, 1.0F, 1.0F }; dvz_input_emit_resize(input_router_, &resize); } + +void Datoviz_Visual_Backend::apply_axes(const Scene_3D_Parameters& scene) { + const std::array descriptors{scene.x_axis, scene.y_axis, scene.z_axis}; + if (applied_axes_ && *applied_axes_ == descriptors) return; + + using Position = std::array; + std::vector starts; + std::vector ends; + std::vector colors; + std::vector widths; + const DvzColor axis_color{190, 207, 226, 255}; + const DvzColor grid_color{58, 70, 86, 255}; + const DvzColor tick_color{145, 164, 188, 255}; + const auto segment = [&](Position start, Position end, DvzColor color, + float width) { + starts.push_back(start); + ends.push_back(end); + colors.push_back(color); + widths.push_back(width); + }; + + std::vector strings; + std::vector text_items; + const auto text = [&](std::string value, Position position, + std::array offset, + std::array anchor, float size, + DvzColor color) { + strings.push_back(std::move(value)); + DvzTextItem item{}; + item.struct_size = sizeof(DvzTextItem); + item.position[0] = position[0]; + item.position[1] = position[1]; + item.position[2] = position[2]; + item.offset[0] = offset[0]; + item.offset[1] = offset[1]; + item.anchor[0] = anchor[0]; + item.anchor[1] = anchor[1]; + item.size_px = size; + item.color = color; + text_items.push_back(item); + }; + + const auto append_axis = [&](const plot::Axis_Descriptor& axis, + std::size_t dimension) { + if (!axis.visible) return; + const auto ticks = plot::axis_ticks(axis); + if (dimension == 0) + segment({-1, -1, -1}, {1, -1, -1}, axis_color, 2.2F); + else if (dimension == 1) + segment({-1, -1, -1}, {-1, 1, -1}, axis_color, 2.2F); + else + segment({-1, -1, -1}, {-1, -1, 1}, axis_color, 2.2F); + + for (const auto& tick : ticks) { + const auto position = axis_position(axis, tick.coordinate); + if (dimension == 0) { + segment({position, -1, -1}, {position, -1.055F, -1}, + tick_color, 1.4F); + if (axis.grid_visible) + segment({position, -1, -1}, {position, 1, -1}, + grid_color, 1.0F); + if (axis.labels_visible) + text(tick.label, {position, -1, -1}, {0, 12}, {.5F, 0}, + 11, tick_color); + } else if (dimension == 1) { + segment({-1, position, -1}, {-1.055F, position, -1}, + tick_color, 1.4F); + if (axis.grid_visible) + segment({-1, position, -1}, {1, position, -1}, + grid_color, 1.0F); + if (axis.labels_visible) + text(tick.label, {-1, position, -1}, {-10, 0}, {1, .5F}, + 11, tick_color); + } else { + segment({-1, -1, position}, {-1.055F, -1, position}, + tick_color, 1.4F); + if (axis.grid_visible) { + segment({-1, -1, position}, {1, -1, position}, + grid_color, 1.0F); + segment({-1, -1, position}, {-1, 1, position}, + grid_color, 1.0F); + } + if (axis.labels_visible) + text(tick.label, {-1, -1, position}, {-10, 0}, {1, .5F}, + 11, tick_color); + } + } + + const auto title = axis_title(axis); + if (title.empty()) return; + if (dimension == 0) + text(title, {0, -1, -1}, {0, 34}, {.5F, 0}, 14, axis_color); + else if (dimension == 1) + text(title, {-1, 0, -1}, {-78, 0}, {1, .5F}, 14, axis_color); + else + text(title, {-1, -1, 0}, {-78, 0}, {1, .5F}, 14, axis_color); + }; + append_axis(descriptors[0], 0); + append_axis(descriptors[1], 1); + append_axis(descriptors[2], 2); + + const auto segment_count = static_cast(starts.size()); + const std::array updates{{ + {"position_start", starts.data(), segment_count}, + {"position_end", ends.data(), segment_count}, + {"color", colors.data(), segment_count}, + {"stroke_width_px", widths.data(), segment_count}}}; + if (dvz_visual_set_data_many(axes_visual_, updates.data(), + static_cast(updates.size())) != DVZ_OK || + dvz_visual_set_visible(axes_visual_, !starts.empty()) != DVZ_OK) + throw std::runtime_error("failed to upload Datoviz 3D axes geometry"); + + for (std::size_t index = 0; index < text_items.size(); ++index) + text_items[index].string = strings[index].c_str(); + if (dvz_text_set_items( + axes_text_, text_items.empty() ? nullptr : text_items.data(), + static_cast(text_items.size())) != DVZ_OK) + throw std::runtime_error("failed to upload Datoviz 3D axes labels"); + applied_axes_ = descriptors; +} + +void Datoviz_Visual_Backend::apply_camera(const plot::Camera_Descriptor& source) { + if (applied_camera_ && *applied_camera_ == source) return; + if (turntable_ != nullptr) { + if (input_router_ != nullptr) + (void)dvz_turntable_disconnect(turntable_, input_router_); + dvz_turntable_destroy(turntable_); + turntable_ = nullptr; + } + DvzCameraDesc camera = dvz_camera_desc(); + const auto assign = [](vec3 target, plot::Spatial_Point value) { + target[0] = static_cast(value.x); + target[1] = static_cast(value.y); + target[2] = static_cast(value.z); + }; + assign(camera.view.eye, source.initial_view.eye); + assign(camera.view.target, source.initial_view.target); + assign(camera.view.up, source.initial_view.up); + camera.projection.type = source.projection == plot::Camera_Projection::orthographic + ? DVZ_CAMERA_ORTHOGRAPHIC + : DVZ_CAMERA_PERSPECTIVE; + camera.projection.fov_y = static_cast( + source.vertical_field_of_view_degrees * std::numbers::pi / 180.0); + camera.projection.near_clip = static_cast(source.near_plane); + camera.projection.far_clip = static_cast(source.far_plane); + const auto dx = source.initial_view.eye.x - source.initial_view.target.x; + const auto dy = source.initial_view.eye.y - source.initial_view.target.y; + const auto dz = source.initial_view.eye.z - source.initial_view.target.z; + const auto distance = std::sqrt(dx * dx + dy * dy + dz * dz); + camera.projection.ortho_height = static_cast( + 2.0 * distance * + std::tan(source.vertical_field_of_view_degrees * std::numbers::pi / 360.0)); + if (dvz_panel_set_camera_desc(panel_, &camera) != DVZ_OK) + throw std::runtime_error("failed to apply Datoviz Camera component"); + DvzTurntableDesc turntable = dvz_turntable_desc(); + turntable.initial_view = camera.view; + turntable.yaw_speed = static_cast(source.control.yaw_speed); + turntable.pitch_speed = static_cast(source.control.pitch_speed); + turntable.zoom_speed = static_cast(source.control.zoom_speed); + turntable.pan_speed = static_cast(source.control.pan_speed); + turntable.min_pitch = static_cast(source.control.minimum_pitch); + turntable.max_pitch = static_cast(source.control.maximum_pitch); + turntable.min_distance = static_cast(source.control.minimum_distance); + turntable.max_distance = static_cast(source.control.maximum_distance); + turntable.controller_flags = DVZ_TURNTABLE_FLAGS_WRAP_YAW | + DVZ_TURNTABLE_FLAGS_CLAMP_DISTANCE; + if (source.control.pan_enabled) + turntable.controller_flags |= DVZ_TURNTABLE_FLAGS_ALLOW_PAN; + turntable_ = dvz_turntable_create(&turntable); + if (turntable_ == nullptr || + dvz_turntable_set_camera(turntable_, dvz_panel_camera(panel_)) != DVZ_OK || + dvz_turntable_connect(turntable_, input_router_) != DVZ_OK) + throw std::runtime_error("failed to connect Datoviz Turntable to Panel Camera"); + applied_camera_ = source; +} void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepared_Visual& point) { require_domain(); if (!point.data) throw std::logic_error("3D prepared visual has no immutable payload"); @@ -799,6 +1010,8 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa }; dvz_input_emit_resize(input_router_, &resize); } + apply_camera(scene.camera); + apply_axes(scene); if (point.revision == applied_visual_revision_) return; { mat4 transform{}; @@ -958,6 +1171,13 @@ void Datoviz_Visual_Backend::dispatch_pointer( ::aethera::Mouse_Button mouse_button, ::aethera::Keyboard_Modifier keyboard_modifiers, Extent viewport) { require_domain(); + if (applied_camera_) { + if (mouse_button == ::aethera::Mouse_Button::left && + !applied_camera_->control.rotate_enabled) return; + if ((mouse_button == ::aethera::Mouse_Button::middle || + mouse_button == ::aethera::Mouse_Button::right) && + !applied_camera_->control.pan_enabled) return; + } const float width = static_cast(viewport.width); const float height = static_cast(viewport.height); const DvzPointerEventType type = @@ -975,6 +1195,7 @@ void Datoviz_Visual_Backend::dispatch_wheel( float x, float y, float delta_x, float delta_y, ::aethera::Keyboard_Modifier keyboard_modifiers, Extent viewport) { require_domain(); + if (applied_camera_ && !applied_camera_->control.zoom_enabled) return; dvz_pointer_emit_wheel( input_router_, x, y, static_cast(viewport.width), static_cast(viewport.height), delta_x, delta_y, @@ -984,10 +1205,8 @@ void Datoviz_Visual_Backend::dispatch_key( const ::aethera::Key_Event& event) { require_domain(); if (event.key == ::aethera::Key::home && event.type == ::aethera::Event_Type::key_press) { - if (arcball_ == nullptr || target_extent_.empty()) throw std::runtime_error("failed to reset Datoviz arcball camera"); - const float width = static_cast(target_extent_.width); const float height = static_cast(target_extent_.height); - const auto emit = [&](DvzPointerEventType type) { dvz_pointer_emit_position(input_router_, type, width * 0.5F, height * 0.5F, width, height, DVZ_POINTER_BUTTON_LEFT, DVZ_KEY_MODIFIER_NONE, 1.0F, dvz_input_timestamp_ns(), nullptr); }; - emit(DVZ_POINTER_EVENT_PRESS); emit(DVZ_POINTER_EVENT_RELEASE); emit(DVZ_POINTER_EVENT_PRESS); emit(DVZ_POINTER_EVENT_RELEASE); + if (turntable_ == nullptr || dvz_turntable_reset(turntable_) != DVZ_OK) + throw std::runtime_error("failed to reset Datoviz Turntable Camera"); return; } const DvzKeyboardEventType type = @@ -1013,7 +1232,7 @@ DvzSceneFrameArtifact* Datoviz_Visual_Backend::emit( configuration.clear_color[1] = scene.clear_color.green; configuration.clear_color[2] = scene.clear_color.blue; configuration.clear_color[3] = scene.clear_color.alpha; - const auto capabilities = dvz_capability_snapshot(); + const auto capabilities = offscreen_capabilities(); DvzDiagnosticReport report{}; dvz_diagnostic_report_init(&report); auto* artifact = @@ -1038,6 +1257,37 @@ std::optional Datoviz_Visual_Backend::sub trace.observed = observe; std::uint64_t phase_started = observe ? trace_now_ns() : 0; apply(scene, point); + if (item_interaction_ != nullptr) { + static_cast(dvz_figure_process_queries(figure_, runtime_, nullptr)); + DvzQueryResult query{}; + bool resolved{}; + while (dvz_scene_poll_query(scene_, &query)) resolved = true; + if (resolved) { + if (hover_readout_ != nullptr) { + dvz_pinned_readout_destroy(hover_readout_); + hover_readout_ = nullptr; + } + if (query.hit) { + if (query.value_kind == DVZ_QUERY_VALUE_NONE) { + if (query.has_data_position || query.has_visual_position) { + query.value_kind = DVZ_QUERY_VALUE_VEC3; + const auto& position = query.has_data_position + ? query.data_position + : query.visual_position; + std::ranges::copy(position, query.vector); + constexpr char position_label[] = "Position"; + std::ranges::copy(position_label, query.label); + } else { + query.value_kind = DVZ_QUERY_VALUE_SCALAR; + query.scalar = static_cast(query.item_id); + constexpr char item_label[] = "Item"; + std::ranges::copy(item_label, query.label); + } + } + hover_readout_ = dvz_pinned_readout_query(panel_, &query); + } + } + } if (observe) trace.apply_ns = trace_now_ns() - phase_started; if (target_ == nullptr || target_extent_ != scene.viewport) { target_.reset(); @@ -1142,6 +1392,20 @@ void Datoviz_Visual_Backend::destroy() { runtime_ = nullptr; } target_.reset(); + if (turntable_ != nullptr) { + if (input_router_ != nullptr) + (void)dvz_turntable_disconnect(turntable_, input_router_); + dvz_turntable_destroy(turntable_); + turntable_ = nullptr; + } + if (item_interaction_ != nullptr) { + dvz_item_interaction_destroy(item_interaction_); + item_interaction_ = nullptr; + } + if (hover_readout_ != nullptr) { + dvz_pinned_readout_destroy(hover_readout_); + hover_readout_ = nullptr; + } if (panel_ != nullptr && input_router_ != nullptr) (void)dvz_panel_connect_input(panel_, nullptr); if (gesture_handler_ != nullptr) { dvz_pointer_gesture_handler_destroy(gesture_handler_); @@ -1152,7 +1416,10 @@ void Datoviz_Visual_Backend::destroy() { input_router_ = nullptr; } visual_ = nullptr; - arcball_ = nullptr; + axes_visual_ = nullptr; + axes_text_ = nullptr; + applied_camera_.reset(); + applied_axes_.reset(); panel_ = nullptr; figure_ = nullptr; if (scene_ != nullptr) { diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp index a1e07a7..38e307e 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,8 @@ private: class Frame_Target; void require_domain() const; void create_scene(Visual_Family visual_family, const Scene_3D_Parameters& initial_scene); + void apply_camera(const plot::Camera_Descriptor& camera); + void apply_axes(const Scene_3D_Parameters& scene); void apply(const Scene_3D_Parameters& scene, const Prepared_Visual& visual); [[nodiscard]] DvzSceneFrameArtifact* emit(const Scene_3D_Parameters& scene); void destroy(); @@ -62,13 +65,19 @@ private: DvzFigure* figure_{}; /* 当前离屏 Figure。 */ DvzPanel* panel_{}; /* 承载唯一 Visual 的全屏 Panel。 */ DvzVisual* visual_{}; /* Builder 指定 family 的唯一 Visual。 */ - DvzArcball* arcball_{}; /* 当前 Panel 相机的交互状态;由 Scene Controller 拥有。 */ + DvzVisual* axes_visual_{}; /* 三维主轴、刻度和网格 Segment Visual。 */ + DvzText* axes_text_{}; /* 随相机变换的三维刻度与轴标题。 */ + DvzItemInteraction* item_interaction_{}; /* Datoviz 原生图元悬停与选择控制器。 */ + DvzPinnedReadout* hover_readout_{}; /* 最近一次命中的 Datoviz 数据提示卡。 */ + DvzTurntable* turntable_{}; /* 直接驱动 Panel Camera 的独立 Turntable。 */ DvzInputRouter* input_router_{}; /* Scene 输入事件路由器。 */ DvzPointerGestureHandler* gesture_handler_{}; /* 指针手势解析器。 */ std::unique_ptr target_; /* 当前尺寸对应的离屏提交和读回目标。 */ Extent target_extent_{}; /* target_ 当前适配的像素尺寸。 */ std::uint64_t target_generation_{}; /* 每次重建 target_ 时递增的资源代次。 */ std::uint64_t applied_visual_revision_{}; /* 已上传到 Datoviz 的 Prepared 版本。 */ + std::optional applied_camera_{}; /* 已应用到 Panel 的 Camera 配置。 */ + std::optional> applied_axes_{}; /* 已生成 Visual 的轴描述快照。 */ }; } // namespace aethera::render_3d::detail diff --git a/render_3D/render_3D/scene/Render_Scene_3D.hpp b/render_3D/render_3D/scene/Render_Scene_3D.hpp index ba830f4..3b6a16e 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.hpp +++ b/render_3D/render_3D/scene/Render_Scene_3D.hpp @@ -1,8 +1,11 @@ #pragma once +#include "../axis/Axes_3D.hpp" #include "../base/Frame_3D.hpp" +#include "../camera/Camera_3D.hpp" #include "../detail/Backend_Types.hpp" #include "../visual/Visuals.hpp" #include +#include #include #include #include @@ -24,13 +27,28 @@ struct Render_Scene_3D : Def struct Builder : Prev_Builder { using Base = Prev_Builder; + using Final_Builder = typename Object::Builder; + Builder(); template - explicit Builder(Visual_Object* visual, std::uint32_t gpu_index = 0, bool validation_enabled = false); + Final_Builder& add_renderable(Visual_Object* visual); + template + requires std::derived_from + Final_Builder& add_camera(Camera_Object* camera); + template + requires std::derived_from + Final_Builder& add_axes(Axes_Object* axes); + Final_Builder& use_gpu(std::uint32_t gpu_index, bool validation_enabled = false); [[nodiscard]] std::expected, Dependency_Graph_Error> build(); private: Root* visual{}; /* 不拥有的唯一 Visual;生命周期必须覆盖 Scene。 */ Visual_Family visual_family{Visual_Family::point}; /* Visual 编译期 Spec 对应的后端 family。 */ void (*bind_visual)(Root*, std::shared_ptr){}; /* 把 Scene 拥有的弱提交上下文绑定到最终 Visual Private。 */ + Root* camera{}; /* 不拥有的 Camera 组件;生命周期必须覆盖 Scene。 */ + Root* axes{}; /* 不拥有的三轴组件;生命周期必须覆盖 Scene。 */ + using Camera_Read = plot::Camera_Descriptor (*)(const Root*); + using Axes_Read = std::array (*)(const Root*); + Camera_Read read_camera{}; + Axes_Read read_axes{}; std::uint32_t gpu_index{}; /* Datoviz 使用的物理 GPU 下标。 */ bool validation_enabled{}; /* 是否启用 Vulkan 验证。 */ }; diff --git a/render_3D/render_3D/scene/Render_Scene_3D.ipp b/render_3D/render_3D/scene/Render_Scene_3D.ipp index fce33a7..01eaf3a 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.ipp +++ b/render_3D/render_3D/scene/Render_Scene_3D.ipp @@ -8,6 +8,7 @@ struct Scene_Paint_Context { std::shared_ptr backend{}; /* Scene 拥有、异步命令延长生命周期的后端。 */ Object* scene{}; /* 仅在 Scene 拥有本上下文期间读取当前 Prop。 */ Frame_3D* frame{}; /* 当前 Submit 图对应的外部帧;process 返回后清空。 */ + Scene_3D_Parameters parameters{}; /* 本帧从 Scene 组件读取的一致快照。 */ }; } struct Render_Scene_3D::Private : Prev_Private { @@ -23,10 +24,19 @@ struct Render_Scene_3D::Private : Prev_Private { }; std::shared_ptr backend{}; /* Scene 拥有的异步后端;已入队命令自行延长实现寿命。 */ std::shared_ptr paint_context{}; /* Paint 节点读取 Scene 当前 Prop 的生命周期门闩。 */ + Root* camera_component{}; /* Builder 绑定的 Camera 组件。 */ + Root* axes_component{}; /* Builder 绑定的三轴组件。 */ + plot::Camera_Descriptor (*read_camera)(const Root*){}; /* 读取 Camera 当前配置。 */ + std::array (*read_axes)(const Root*){}; /* 读取三轴当前配置。 */ Frame_3D* active_frame{}; /* 当前同步 process 借用的外部帧;提交完成后清空。 */ const Dispatch* dispatch{}; /* 最终 Scene 类型对应的静态公开分派表。 */ /* Builder 内部初始化后端;必须在绑定 Visual Paint 目标之前调用一次。 */ - template void initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family); + template + void initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, + Visual_Family visual_family, Root* camera, Root* axes, + plot::Camera_Descriptor (*camera_reader)(const Root*), + std::array (*axes_reader)(const Root*)); + template [[nodiscard]] detail::Scene_3D_Parameters parameters(Object* object) const; /* CRTP 覆盖:绑定 Scene 机制和 Render_Scene_3D 公开薄壳。 */ template void bind_private_crtp(Object* object); /* CRTP 覆盖:先执行 Kernel Prepare Taskflow,再运行只负责异步入队的三维 Submit 阶段。 */ @@ -36,20 +46,105 @@ struct Render_Scene_3D::Private : Prev_Private { template [[nodiscard]] Render_Result render(Object* object, Frame_3D* frame); template [[nodiscard]] static const Dispatch& dispatch_for(); }; +template +Render_Scene_3D::Builder::Builder() : Base() {} template template -Render_Scene_3D::Builder::Builder(Visual_Object* visual_value, std::uint32_t gpu_index_value, bool validation_enabled_value) : Base(), visual(visual_value), visual_family(Visual_Object::Attached_Object::Specification::family), gpu_index(gpu_index_value), validation_enabled(validation_enabled_value) { - bind_visual = [](Root* root, std::shared_ptr context) { auto* object = static_cast(root); Base::private_access(object).template get().bind_paint_target(std::move(context), [](void* raw_context, const detail::Prepared_Visual& prepared) { auto& submission = *static_cast*>(raw_context); const auto& prop = submission.scene->template read_prop(); submission.backend->render(prepared, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}, submission.frame); }); }; +typename Render_Scene_3D::Builder::Final_Builder& +Render_Scene_3D::Builder::add_renderable(Visual_Object* visual_value) { + if (!visual_value) throw std::invalid_argument("Render_Scene_3D cannot attach a null Visual"); + if (visual) throw std::logic_error("Render_Scene_3D currently accepts one primary Visual"); + visual = visual_value; + visual_family = Visual_Object::Attached_Object::Specification::family; + bind_visual = [](Root* root, std::shared_ptr context) { + auto* object = static_cast(root); + Base::private_access(object).template get() + .bind_paint_target(std::move(context), [](void* raw_context, const detail::Prepared_Visual& prepared) { + auto& submission = *static_cast*>(raw_context); + submission.backend->render(prepared, submission.parameters, submission.frame); + }); + }; this->template add_dependency_node(visual_value); this->template add_dependency_node(visual_value); + return static_cast(*this); +} +template template + requires std::derived_from +typename Render_Scene_3D::Builder::Final_Builder& +Render_Scene_3D::Builder::add_camera(Camera_Object* camera_value) { + if (!camera_value) throw std::invalid_argument("Render_Scene_3D cannot attach a null Camera"); + if (camera) throw std::logic_error("Render_Scene_3D accepts one Camera component"); + camera = camera_value; + read_camera = [](const Root* root) { + const auto& prop = static_cast(root)->template read_prop(); + return plot::Camera_Descriptor{prop.initial_view, prop.projection, prop.control, + prop.vertical_field_of_view_degrees, + prop.near_plane, prop.far_plane}; + }; + return static_cast(*this); +} +template template + requires std::derived_from +typename Render_Scene_3D::Builder::Final_Builder& +Render_Scene_3D::Builder::add_axes(Axes_Object* axes_value) { + if (!axes_value) throw std::invalid_argument("Render_Scene_3D cannot attach null Axes"); + if (axes) throw std::logic_error("Render_Scene_3D accepts one Axes component"); + axes = axes_value; + read_axes = [](const Root* root) { + const auto& prop = static_cast(root)->template read_prop(); + return std::array{prop.x_axis, prop.y_axis, prop.z_axis}; + }; + return static_cast(*this); } template -std::expected, Dependency_Graph_Error> Render_Scene_3D::Builder::build() { if (!visual || !bind_visual) throw std::invalid_argument("Render_Scene_3D requires one Visual"); auto result = Base::build(); if (!result) return std::unexpected(result.error()); auto scene = std::move(result).value(); auto& private_data = Base::private_access(scene.get()).template get(); private_data.initialize_backend(scene.get(), gpu_index, validation_enabled, visual_family); bind_visual(visual, private_data.paint_context); return std::move(scene); } -template void Render_Scene_3D::Private::initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family) { const auto& prop = object->template read_prop(); backend = std::make_shared(gpu_index, validation_enabled, visual_family, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}); paint_context = std::make_shared>(detail::Scene_Paint_Context{backend, object, nullptr}); } +typename Render_Scene_3D::Builder::Final_Builder& +Render_Scene_3D::Builder::use_gpu(std::uint32_t gpu_index_value, + bool validation_enabled_value) { + gpu_index = gpu_index_value; + validation_enabled = validation_enabled_value; + return static_cast(*this); +} +template +std::expected, Dependency_Graph_Error> Render_Scene_3D::Builder::build() { + if (!visual || !bind_visual) throw std::invalid_argument("Render_Scene_3D requires one Visual"); + if (!camera || !read_camera) throw std::invalid_argument("Render_Scene_3D requires one Camera component"); + if (!axes || !read_axes) throw std::invalid_argument("Render_Scene_3D requires one Axes component"); + auto result = Base::build(); + if (!result) return std::unexpected(result.error()); + auto scene = std::move(result).value(); + auto& private_data = Base::private_access(scene.get()).template get(); + private_data.initialize_backend(scene.get(), gpu_index, validation_enabled, visual_family, + camera, axes, read_camera, read_axes); + bind_visual(visual, private_data.paint_context); + return scene; +} +template +detail::Scene_3D_Parameters Render_Scene_3D::Private::parameters(Object* object) const { + const auto& prop = object->template read_prop(); + const auto axis = read_axes(axes_component); + return {prop.viewport, prop.clear_color, read_camera(camera_component), axis[0], axis[1], axis[2]}; +} +template +void Render_Scene_3D::Private::initialize_backend( + Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, + Root* camera, Root* axes, plot::Camera_Descriptor (*camera_reader)(const Root*), + std::array (*axes_reader)(const Root*)) { + camera_component = camera; + axes_component = axes; + read_camera = camera_reader; + read_axes = axes_reader; + const auto initial = parameters(object); + backend = std::make_shared(gpu_index, validation_enabled, + visual_family, initial); + paint_context = std::make_shared>( + detail::Scene_Paint_Context{backend, object, nullptr, initial}); +} inline void Render_Scene_3D::Private::dispatch_events(Extent viewport) { this->consume_events([&](const std::shared_ptr& event) { const auto result = backend->dispatch_event(event, viewport); return result != detail::Async_Render_Backend::Dispatch_Event_Result::queue_full && result != detail::Async_Render_Backend::Dispatch_Event_Result::backend_unavailable; }); } template void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requires std::invocable { Frame_3D* frame = active_frame; if (!frame) throw std::logic_error("3D Scene process has no external frame"); + camera_component->advance_object(); + axes_component->advance_object(); const auto& prop = object->template read_prop(); if (!prop.view_active || prop.viewport.empty() || !backend || !backend->available()) return; frame->mark(Frame_Trace_Marker::scene_render_started); @@ -57,6 +152,7 @@ void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requ dispatch_events(prop.viewport); frame->mark(Frame_Trace_Marker::event_dispatch_finished); auto context = std::static_pointer_cast>(paint_context); + context->parameters = parameters(object); struct Frame_Context_Scope { Frame_3D*& target; /* Visual Submit 回调读取的当前外部帧槽位。 */ Frame_3D* previous{}; /* 嵌套调用前的帧;析构时恢复。 */ diff --git a/render_3D/todolist.md b/render_3D/todolist.md new file mode 100644 index 0000000..09174f2 --- /dev/null +++ b/render_3D/todolist.md @@ -0,0 +1,237 @@ +明白,只定义 **用户看到的交互能力**,不定义数据、不定义内部实现。下面按图类型分类。 + +# 3D Plot Interaction Requirement + +## 1. 3D Waterfall Plot(三维瀑布图) + +### 基础交互 + +* 旋转视角 +* 缩放视图 +* 平移视图 +* 重置视角 + +### 时间交互 + +* 时间窗口调整 +* 时间范围拖动 +* 实时滚动 +* 暂停刷新 +* 继续刷新 +* 查看历史区域 + +### 数据查看 + +* 鼠标悬停显示当前位置数据 +* 点击显示详细信息 +* 添加标记点 +* 删除标记点 +* 显示峰值位置 + +### 分析交互 + +* 查看指定时间截面 +* 查看指定频率截面 +* 切换俯视图 +* 切换三维视图 +* 调整高度显示比例 + +--- + +# 2. 3D Surface Plot(三维曲面图) + +### 基础交互 + +* 旋转曲面 +* 缩放曲面 +* 平移曲面 +* 重置视角 + +### 显示控制 + +* 显示/隐藏网格 +* 显示/隐藏坐标轴 +* 显示/隐藏刻度 +* 切换曲面显示模式 +* 调整透明度 + +### 数据观察 + +* 鼠标悬停查看点信息 +* 点击选择点 +* 显示局部峰值 +* 显示局部最低值 + +### 分析交互 + +* 显示等高线 +* 显示投影平面 +* 调整高度比例 +* 调整颜色范围 + +--- + +# 3. 3D Scatter Plot(三维散点图) + +### 基础交互 + +* 旋转观察 +* 缩放 +* 平移 +* 重置视角 + +### 点操作 + +* 悬停显示点信息 +* 点击选择点 +* 多选点 +* 框选区域 +* 取消选择 + +### 显示控制 + +* 调整点大小 +* 调整点透明度 +* 显示/隐藏点分类 +* 显示/隐藏辅助线 + +### 分析交互 + +* 点过滤 +* 区域过滤 +* 聚类显示 +* 高亮指定点集合 + +--- + +# 4. 3D Point Cloud(三维点云图) + +### 基础交互 + +* 旋转点云 +* 缩放点云 +* 平移点云 +* 重置视角 + +### 点云浏览 + +* 自动调整观察距离 +* 点密度调整 +* 显示级别调整 +* 局部放大 + +### 选择操作 + +* 单点选择 +* 区域选择 +* 框选 +* 删除选择 +* 隐藏选择 + +### 显示控制 + +* 点大小调整 +* 颜色模式切换 +* 透明度调整 + +--- + +# 5. 3D Volume Plot(三维体数据图) + +### 基础交互 + +* 旋转体数据 +* 缩放 +* 平移 +* 重置视角 + +### 切割查看 + +* X方向切片 +* Y方向切片 +* Z方向切片 +* 移动切片位置 +* 多切片显示 + +### 显示控制 + +* 调整透明度 +* 调整显示范围 +* 调整颜色映射 +* 隐藏低强度区域 + +### 分析交互 + +* 区域选择 +* 局部放大 +* 数据点查看 + +--- + +# 6. 3D Polar Plot(三维极坐标图) + +### 基础交互 + +* 旋转方向 +* 缩放 +* 平移 +* 重置视角 + +### 方向查看 + +* 查看指定角度 +* 查看指定方向数据 +* 显示方向标记 + +### 显示控制 + +* 切换极坐标/直角坐标 +* 显示辅助圆环 +* 显示角度刻度 + +--- + +# 7. 通用 3D Plot 交互 + +所有 3D 图统一支持: + +## Camera + +* Rotate +* Zoom +* Pan +* Reset View + +## Selection + +* Hover +* Select +* Multi Select +* Clear Selection + +## Marker + +* Add Marker +* Move Marker +* Delete Marker + +## Axis + +* Axis Visibility +* Axis Range +* Scale Adjustment + +## Display + +* Show/Hide Grid +* Show/Hide Label +* Transparency Control + +## Navigation + +* Fit View +* Full Screen +* Save View +* Restore View + +--- diff --git a/web_server/src/Gallery_Plots_3D.cpp b/web_server/src/Gallery_Plots_3D.cpp index a598946..1973bbe 100644 --- a/web_server/src/Gallery_Plots_3D.cpp +++ b/web_server/src/Gallery_Plots_3D.cpp @@ -2,6 +2,7 @@ #include "Renderable_Adapter.hpp" #include #include +#include #include #include #include @@ -192,11 +193,19 @@ private: std::uniform_real_distribution second{}; }; -template +struct Random_Data_Generator {}; + +template class Visual_Scene_View final : public Plot::Scene_View { public: - Visual_Scene_View(Scene_3D& scene, std::unique_ptr visual, std::string label) - : visual_(std::move(visual)) { + using Camera_Object = Impl; + using Axes_Object = Impl; + Visual_Scene_View(Scene_3D& scene, std::unique_ptr camera, + std::unique_ptr axes, + std::unique_ptr visual, std::string label, + Data_Generator data_generator = {}) + : data_generator_(std::move(data_generator)), camera_(std::move(camera)), + axes_(std::move(axes)), visual_(std::move(visual)) { using Definition = typename Visual_Object::Attached_Object; using Prop = typename Definition::Prop; using State = typename Definition::State; @@ -213,7 +222,20 @@ public: detail::State_Field, detail::State_Field, detail::State_Field>; + using Camera_Adapter = detail::Renderable_Adapter, + detail::Prop_Field<&Camera_3D::Prop::projection, "projection", "Perspective or orthographic camera projection.">, + detail::Prop_Field<&Camera_3D::Prop::control, "control", "Turntable rotate, zoom and pan capabilities, speed and limits.">, + detail::Prop_Field<&Camera_3D::Prop::vertical_field_of_view_degrees, "vertical_field_of_view_degrees", "Vertical field of view in degrees.">, + detail::Prop_Field<&Camera_3D::Prop::near_plane, "near_plane", "Nearest visible camera distance.">, + detail::Prop_Field<&Camera_3D::Prop::far_plane, "far_plane", "Farthest visible camera distance.">>; + using Axes_Adapter = detail::Renderable_Adapter, + detail::Prop_Field<&Axes_3D::Prop::y_axis, "y_axis", "Y axis range, scale, ticks, label and unit.">, + detail::Prop_Field<&Axes_3D::Prop::z_axis, "z_axis", "Z axis range, scale, ticks, label and unit.">>; descriptors_.push_back(detail::make_renderable_descriptor("scene", "3D 场景", "scene", Scene_Adapter{scene})); + descriptors_.push_back(detail::make_renderable_descriptor("camera", "相机控制", "camera", Camera_Adapter{*camera_})); + descriptors_.push_back(detail::make_renderable_descriptor("axes", "三维坐标轴", "axes", Axes_Adapter{*axes_})); descriptors_.push_back(detail::make_renderable_descriptor("visual", std::move(label), "visual", Visual_Adapter{*visual_})); } @@ -232,11 +254,18 @@ public: } [[nodiscard]] nlohmann::json data_generator_schema() const override { - using Definition = typename Visual_Object::Attached_Object; - return generator_schema(); + if constexpr (std::same_as) { + using Definition = typename Visual_Object::Attached_Object; + return generator_schema(); + } else { + return data_generator_.schema(); + } } [[nodiscard]] nlohmann::json generate_data(const nlohmann::json& input) override { + if constexpr (!std::same_as) { + return data_generator_.generate(*visual_, *axes_, input); + } else { using Definition = typename Visual_Object::Attached_Object; using Prop = typename Definition::Prop; using Items = std::remove_cvref_t().items)>; @@ -280,34 +309,249 @@ public: } catch (const std::exception& error) { return {{"success", false}, {"error", error.what()}}; } + } } void update(const Plot_Frame_Request&) override {} private: + [[no_unique_address]] Data_Generator data_generator_; /* Plot 业务数据生成策略。 */ + std::unique_ptr camera_; /* Scene 引用的 Camera 唯一所有者。 */ + std::unique_ptr axes_; /* Scene 引用的 Axes 唯一所有者。 */ std::unique_ptr visual_; /* Scene 引用的 Visual 唯一所有者。 */ std::vector> descriptors_; /* Prop/State 协议描述。 */ }; -template -std::shared_ptr make_visual_plot(asio::any_io_executor executor, std::string label, Build_Result build_result) { +struct Scene_Components_3D { + plot::Camera_Descriptor camera{}; + std::array axes{ + plot::Axis_Descriptor{{-1.0, 1.0}, plot::Axis_Scale::linear, "X", "", 5, 2, true, true, true}, + plot::Axis_Descriptor{{-1.0, 1.0}, plot::Axis_Scale::linear, "Y", "", 5, 2, true, true, true}, + plot::Axis_Descriptor{{-1.0, 1.0}, plot::Axis_Scale::linear, "Z", "", 5, 2, true, true, true}}; +}; + +template +std::shared_ptr make_visual_plot(asio::any_io_executor executor, std::string label, + Build_Result build_result, + Scene_Components_3D components = {}, + Data_Generator data_generator = {}) { using Visual_Object = Impl; + using Camera_Object = Impl; + using Axes_Object = Impl; if (!build_result) throw std::logic_error("3D Gallery visual dependency graph is invalid"); auto visual = std::move(build_result).value(); - auto scene_result = Scene_3D::Builder(visual.get()) - .set(&Render_Scene_3D::Prop::viewport, Extent{720, 420}) - .set(&Render_Scene_3D::Prop::clear_color, Linear_Color{0.018F, 0.027F, 0.047F, 1.0F}) - .set(&Render_Scene_3D::Prop::view_active, true) - .build(); + auto camera_result = Camera_Object::Builder{} + .set(&Camera_3D::Prop::initial_view, components.camera.initial_view) + .set(&Camera_3D::Prop::projection, components.camera.projection) + .set(&Camera_3D::Prop::control, components.camera.control) + .set(&Camera_3D::Prop::vertical_field_of_view_degrees, + components.camera.vertical_field_of_view_degrees) + .set(&Camera_3D::Prop::near_plane, components.camera.near_plane) + .set(&Camera_3D::Prop::far_plane, components.camera.far_plane) + .build(); + auto axes_result = Axes_Object::Builder{} + .set(&Axes_3D::Prop::x_axis, components.axes[0]) + .set(&Axes_3D::Prop::y_axis, components.axes[1]) + .set(&Axes_3D::Prop::z_axis, components.axes[2]) + .build(); + if (!camera_result || !axes_result) + throw std::logic_error("3D Gallery component construction failed"); + auto camera = std::move(camera_result).value(); + auto axes = std::move(axes_result).value(); + auto scene_builder = Scene_3D::Builder{}; + scene_builder.add_camera(camera.get()) + .add_axes(axes.get()) + .add_renderable(visual.get()) + .set(&Render_Scene_3D::Prop::viewport, Extent{720, 420}) + .set(&Render_Scene_3D::Prop::clear_color, Linear_Color{0.018F, 0.027F, 0.047F, 1.0F}) + .set(&Render_Scene_3D::Prop::view_active, true); + auto scene_result = scene_builder.build(); if (!scene_result) throw std::logic_error("3D Gallery scene dependency graph is invalid"); auto scene = std::move(scene_result).value(); - auto view = std::make_unique>(*scene, std::move(visual), std::move(label)); + auto view = std::make_unique>( + *scene, std::move(camera), std::move(axes), std::move(visual), + std::move(label), std::move(data_generator)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } Color color(std::uint8_t red, std::uint8_t green, std::uint8_t blue, std::uint8_t alpha = 255) { return {red, green, blue, alpha}; } + +struct Spectrogram_Parameters { + std::size_t time_sample_count{80}; + std::size_t frequency_bin_count{96}; + std::size_t ridge_count{5}; + double time_span_seconds{4.0}; + double minimum_frequency_hz{10.0}; + double maximum_frequency_hz{20'000.0}; + double minimum_level_db{18.0}; + double maximum_level_db{78.0}; +}; + +Color spectrogram_color(float value) { + struct Stop { float position; std::array rgb; }; + static constexpr std::array stops{ + Stop{0.0F, {22, 10, 54}}, Stop{0.22F, {76, 18, 112}}, + Stop{0.45F, {151, 40, 103}}, Stop{0.68F, {226, 83, 61}}, + Stop{0.86F, {252, 169, 52}}, Stop{1.0F, {252, 246, 164}}}; + value = std::clamp(value, 0.0F, 1.0F); + for (std::size_t index = 1; index < stops.size(); ++index) { + if (value > stops[index].position) continue; + const auto& lower = stops[index - 1]; + const auto& upper = stops[index]; + const auto ratio = (value - lower.position) / + (upper.position - lower.position); + const auto channel = [&](std::size_t component) { + return static_cast(std::lround( + std::lerp(lower.rgb[component], upper.rgb[component], ratio))); + }; + return color(channel(0), channel(1), channel(2)); + } + return color(252, 246, 164); +} + +Vec3 face_normal(Vec3 first, Vec3 second, Vec3 third) { + const Vec3 a{second.x - first.x, second.y - first.y, second.z - first.z}; + const Vec3 b{third.x - first.x, third.y - first.y, third.z - first.z}; + Vec3 result{a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x}; + const auto length = std::sqrt(result.x * result.x + result.y * result.y + + result.z * result.z); + if (!(length > 0.0F)) return {0, 0, 1}; + result.x /= length; + result.y /= length; + result.z /= length; + return result; +} + +std::vector spectrogram_mesh(const Spectrogram_Parameters& parameters) { + struct Ridge { float center; float width; float phase; float speed; float strength; }; + std::mt19937_64 engine{std::random_device{}()}; + std::uniform_real_distribution center_distribution{0.08F, 0.92F}; + std::uniform_real_distribution width_distribution{0.025F, 0.12F}; + std::uniform_real_distribution phase_distribution{0.0F, 2.0F * std::numbers::pi_v}; + std::uniform_real_distribution speed_distribution{0.35F, 1.8F}; + std::uniform_real_distribution strength_distribution{0.38F, 0.9F}; + std::normal_distribution noise{0.0F, 0.035F}; + std::vector ridges; + ridges.reserve(parameters.ridge_count); + for (std::size_t index = 0; index < parameters.ridge_count; ++index) + ridges.push_back({center_distribution(engine), width_distribution(engine), + phase_distribution(engine), speed_distribution(engine), + strength_distribution(engine)}); + + struct Sample { Vec3 position; Color color; }; + std::vector samples(parameters.time_sample_count * + parameters.frequency_bin_count); + for (std::size_t time_index = 0; time_index < parameters.time_sample_count; + ++time_index) { + const auto time = static_cast(time_index) / + static_cast(parameters.time_sample_count - 1); + for (std::size_t frequency_index = 0; + frequency_index < parameters.frequency_bin_count; ++frequency_index) { + const auto frequency = static_cast(frequency_index) / + static_cast(parameters.frequency_bin_count - 1); + float level = 0.08F + 0.08F * std::sin( + 2.0F * std::numbers::pi_v * (0.7F * time + 0.3F * frequency)); + for (const auto& ridge : ridges) { + const auto moving_center = std::clamp( + ridge.center + 0.055F * std::sin(ridge.phase + + ridge.speed * 2.0F * std::numbers::pi_v * time), + 0.02F, 0.98F); + const auto distance = (frequency - moving_center) / ridge.width; + const auto envelope = 0.55F + 0.45F * std::sin( + ridge.phase * 0.63F + (ridge.speed + 0.25F) * + 2.0F * std::numbers::pi_v * time); + level += ridge.strength * envelope * std::exp(-0.5F * distance * distance); + } + level = std::clamp(level + noise(engine), 0.0F, 1.0F); + samples[time_index * parameters.frequency_bin_count + frequency_index] = { + {-1.0F + 2.0F * time, -1.0F + 2.0F * frequency, + -1.0F + 2.0F * level}, spectrogram_color(level)}; + } + } + + std::vector mesh; + mesh.reserve((parameters.time_sample_count - 1) * + (parameters.frequency_bin_count - 1) * 6); + const auto append_triangle = [&](const Sample& first, const Sample& second, + const Sample& third) { + const auto normal = face_normal(first.position, second.position, third.position); + mesh.push_back({first.position, first.color, normal, {0, 0}}); + mesh.push_back({second.position, second.color, normal, {0, 0}}); + mesh.push_back({third.position, third.color, normal, {0, 0}}); + }; + for (std::size_t time_index = 0; time_index + 1 < parameters.time_sample_count; + ++time_index) { + for (std::size_t frequency_index = 0; + frequency_index + 1 < parameters.frequency_bin_count; ++frequency_index) { + const auto current = time_index * parameters.frequency_bin_count + frequency_index; + const auto next_time = current + parameters.frequency_bin_count; + append_triangle(samples[current], samples[next_time], samples[next_time + 1]); + append_triangle(samples[current], samples[next_time + 1], samples[current + 1]); + } + } + return mesh; +} + +struct Spectrogram_Data_Generator { + [[nodiscard]] Json schema() const { + Json fields = Json::array(); + fields.push_back(integer_field("time_sample_count", "时间采样数", "时间方向的网格采样数量;增大后表面沿时间方向更细密。", 80, 16, 256)); + fields.push_back(integer_field("frequency_bin_count", "频率分箱数", "对数频率方向的网格分箱数量。", 96, 16, 256)); + fields.push_back(integer_field("ridge_count", "谱峰轨迹数", "生成随时间漂移的窄带谱峰数量。", 5, 1, 12)); + fields.push_back(number_field("time_span_seconds", "时间跨度", "X 轴覆盖的时间长度,单位秒。", 4.0, 0.1, 120.0, 0.1)); + fields.push_back(number_field("minimum_frequency_hz", "最低频率", "对数频率轴的下界,必须大于零。", 10.0, 1.0, 1.0e9, 1.0)); + fields.push_back(number_field("maximum_frequency_hz", "最高频率", "对数频率轴的上界,必须大于最低频率。", 20'000.0, 2.0, 1.0e9, 10.0)); + fields.push_back(number_field("minimum_level_db", "最低声压级", "Z 轴色阶和高度的下界,单位 dB。", 18.0, -300.0, 300.0, 1.0)); + fields.push_back(number_field("maximum_level_db", "最高声压级", "Z 轴色阶和高度的上界,必须大于下界。", 78.0, -300.0, 300.0, 1.0)); + return {{"label", "生成三维频谱瀑布"}, + {"description", "按时间采样、对数频率分箱和声压级范围生成连续 GPU Mesh 表面。"}, + {"fields", std::move(fields)}}; + } + + [[nodiscard]] Json generate(Impl& visual, Impl& axes, + const Json& input) const { + try { + Spectrogram_Parameters parameters; + parameters.time_sample_count = input_count(input, "time_sample_count", 256); + parameters.frequency_bin_count = input_count(input, "frequency_bin_count", 256); + parameters.ridge_count = input_count(input, "ridge_count", 12); + parameters.time_span_seconds = input_number(input, "time_span_seconds"); + parameters.minimum_frequency_hz = input_number(input, "minimum_frequency_hz"); + parameters.maximum_frequency_hz = input_number(input, "maximum_frequency_hz"); + parameters.minimum_level_db = input_number(input, "minimum_level_db"); + parameters.maximum_level_db = input_number(input, "maximum_level_db"); + if (!(parameters.time_span_seconds > 0.0) || + !(parameters.minimum_frequency_hz > 0.0) || + !(parameters.maximum_frequency_hz > parameters.minimum_frequency_hz) || + !(parameters.maximum_level_db > parameters.minimum_level_db)) + throw std::invalid_argument("spectrogram ranges are invalid"); + auto mesh = spectrogram_mesh(parameters); + const auto vertex_count = mesh.size(); + if (visual.update_items(std::move(mesh)) != Mesh_Visual::Update_Items_Result::updated) + throw std::invalid_argument("generated spectrogram mesh was rejected"); + plot::Axis_Descriptor time_axis{{0.0, parameters.time_span_seconds}, + plot::Axis_Scale::time, "Time", "s", 6, 1, true, true, true}; + plot::Axis_Descriptor frequency_axis{{parameters.minimum_frequency_hz, + parameters.maximum_frequency_hz}, plot::Axis_Scale::logarithmic, + "Frequency", "Hz", 5, 0, true, true, true}; + plot::Axis_Descriptor level_axis{{parameters.minimum_level_db, + parameters.maximum_level_db}, plot::Axis_Scale::linear, + "SPL", "dB", 7, 0, true, true, true}; + axes.set<&Axes_3D::Prop::x_axis>(std::move(time_axis)); + axes.set<&Axes_3D::Prop::y_axis>(std::move(frequency_axis)); + axes.set<&Axes_3D::Prop::z_axis>(std::move(level_axis)); + return {{"success", true}, {"generated_count", vertex_count}, + {"triangle_count", vertex_count / 3}}; + } catch (const std::exception& error) { + return {{"success", false}, {"error", error.what()}}; + } + } +}; } std::shared_ptr make_datoviz_point_plot(asio::any_io_executor executor) { @@ -358,6 +602,31 @@ std::shared_ptr make_datoviz_mesh_plot(asio::any_io_executor executor) { return make_visual_plot(std::move(executor), "Mesh Visual", Impl::Builder{}.set(&Mesh_Visual::Prop::items, mesh).build()); } +std::shared_ptr make_datoviz_spectrogram_plot(asio::any_io_executor executor) { + const Spectrogram_Parameters parameters; + Scene_Components_3D components; + components.camera.initial_view = {{3.35, -3.55, 2.45}, {0.0, 0.0, -0.05}, + {0.0, 0.0, 1.0}}; + components.camera.control = {0.15, 0.15, 0.10, 0.0015, -1.35, 1.35, + 1.25, 12.0, true, true, true}; + components.camera.vertical_field_of_view_degrees = 41.0; + components.axes = { + plot::Axis_Descriptor{{0.0, parameters.time_span_seconds}, + plot::Axis_Scale::time, "Time", "s", 6, 1, true, true, true}, + plot::Axis_Descriptor{{parameters.minimum_frequency_hz, + parameters.maximum_frequency_hz}, plot::Axis_Scale::logarithmic, + "Frequency", "Hz", 5, 0, true, true, true}, + plot::Axis_Descriptor{{parameters.minimum_level_db, + parameters.maximum_level_db}, plot::Axis_Scale::linear, + "SPL", "dB", 7, 0, true, true, true}}; + return make_visual_plot( + std::move(executor), "3D Spectrogram", + Impl::Builder{} + .set(&Mesh_Visual::Prop::items, spectrogram_mesh(parameters)) + .build(), + std::move(components), Spectrogram_Data_Generator{}); +} + std::shared_ptr make_datoviz_path_plot(asio::any_io_executor executor) { std::vector path; for (int index = 0; index < 64; ++index) { const float t = static_cast(index) / 63.0F; path.push_back({{-0.9F + 1.8F * t, 0.48F * std::sin(t * 4.0F * std::numbers::pi_v), 0.25F * std::cos(t * 2.0F * std::numbers::pi_v)}, color(static_cast(70 + 170 * t), static_cast(220 - 80 * t), 245), 5.0F}); } diff --git a/web_server/src/Gallery_Plots_3D.hpp b/web_server/src/Gallery_Plots_3D.hpp index fde9c3a..6469bb9 100644 --- a/web_server/src/Gallery_Plots_3D.hpp +++ b/web_server/src/Gallery_Plots_3D.hpp @@ -11,6 +11,7 @@ namespace aethera::web { [[nodiscard]] std::shared_ptr make_datoviz_vector_plot(asio::any_io_executor executor); [[nodiscard]] std::shared_ptr make_datoviz_primitive_plot(asio::any_io_executor executor); [[nodiscard]] std::shared_ptr make_datoviz_mesh_plot(asio::any_io_executor executor); +[[nodiscard]] std::shared_ptr make_datoviz_spectrogram_plot(asio::any_io_executor executor); [[nodiscard]] std::shared_ptr make_datoviz_path_plot(asio::any_io_executor executor); [[nodiscard]] std::shared_ptr make_datoviz_image_plot(asio::any_io_executor executor); [[nodiscard]] std::shared_ptr make_datoviz_labels_plot(asio::any_io_executor executor); diff --git a/web_server/src/Renderable_Adapter.ipp b/web_server/src/Renderable_Adapter.ipp index a0f0b76..b3a7d42 100644 --- a/web_server/src/Renderable_Adapter.ipp +++ b/web_server/src/Renderable_Adapter.ipp @@ -228,6 +228,10 @@ inline std::string_view protocol_field_label(std::string_view key) { static constexpr std::pair labels[] = { {"position", "位置"}, {"viewport", "视口尺寸"}, {"background", "背景颜色"}, {"clear_color", "清屏颜色"}, {"view_active", "启用视图"}, + {"initial_view", "初始相机视图"}, {"projection", "投影方式"}, + {"control", "相机交互控制"}, {"vertical_field_of_view_degrees", "垂直视场角"}, + {"near_plane", "近裁剪面"}, {"far_plane", "远裁剪面"}, + {"x_axis", "X 坐标轴"}, {"y_axis", "Y 坐标轴"}, {"z_axis", "Z 坐标轴"}, {"pixel_length", "轴线长度"}, {"orientation", "轴线方向"}, {"tick_length", "主刻度长度"}, {"sub_tick_length", "次刻度长度"}, {"axis_pen", "轴线画笔"}, {"unit_text", "单位文字"}, {"unit_text_font", "单位字体"}, {"unit_text_pen", "单位文字画笔"}, diff --git a/web_server/src/Web_Server.cpp b/web_server/src/Web_Server.cpp index 97fc472..e049215 100644 --- a/web_server/src/Web_Server.cpp +++ b/web_server/src/Web_Server.cpp @@ -57,6 +57,7 @@ int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root) plots->emplace("datoviz_vector", make_datoviz_vector_plot(executor)); plots->emplace("datoviz_primitive", make_datoviz_primitive_plot(executor)); plots->emplace("datoviz_mesh", make_datoviz_mesh_plot(executor)); + plots->emplace("datoviz_spectrogram", make_datoviz_spectrogram_plot(executor)); plots->emplace("datoviz_path", make_datoviz_path_plot(executor)); plots->emplace("datoviz_image", make_datoviz_image_plot(executor)); plots->emplace("datoviz_labels", make_datoviz_labels_plot(executor)); diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index 68328e9..91c4064 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -23,7 +23,7 @@ type Data_Generator = {label: string; description: string; fields: Field[]}; type Frame_Analysis = Omit & {data_generator?: Data_Generator}; type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]; frame_analysis: Frame_Analysis}; type State_Histories = Record; -type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE"; +type Stream_Status = "IDLE" | "CONNECTING" | "LIVE" | "OFFLINE"; type Frame_Pacing_Mode = "manual" | "fixed_rate" | "minimum_latency" | "maximum_rate"; type Frame_Delivery = "pixels" | "diagnostics"; type Frame_Metadata = {kind: "frame_metadata"; protocol: "aethera.frame"; version: 5; sequence: number; correlation_id: number; @@ -228,8 +228,9 @@ function build_frame_diagnostics(metadata: Frame_Metadata, samples: Frame_Sample }; } -function use_plot_stream(plot: Plot, canvas_ref: React.RefObject, stream_pixels: boolean) { - const [status, set_status] = useState("CONNECTING"); +function use_plot_stream(plot: Plot, canvas_ref: React.RefObject, stream_pixels: boolean, + enabled: boolean) { + const [status, set_status] = useState(enabled ? "CONNECTING" : "IDLE"); const [metrics, set_metrics] = useState(null); const socket_ref = useRef(null); const pending_pointer_move = useRef | null>(null); @@ -261,6 +262,11 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject { + if (!enabled) { + socket_ref.current = null; + set_status("IDLE"); + return; + } let stopped = false; let timer = 0; let diagnostics_timer = 0; @@ -493,7 +499,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject { const canvas = canvas_ref.current; @@ -506,8 +512,9 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject (event.ctrlKey ? 1 : 0) | (event.shiftKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); const button = (value: number) => value === 0 ? "left" : value === 1 ? "middle" : value === 2 ? "right" : "none"; + const active_button = (event: PointerEvent) => event.buttons & 1 ? "left" : event.buttons & 4 ? "middle" : event.buttons & 2 ? "right" : button(event.button); const pointer_payload = (type: string, event: PointerEvent) => ({type, position: point(event), global_position: global_point(event), - button: button(event.button), buttons: event.buttons, modifiers: modifiers(event)}); + button: type === "pointer_move" ? active_button(event) : button(event.button), buttons: event.buttons, modifiers: modifiers(event)}); let drag_move_started = false; const on_pointer_move = (event: PointerEvent) => { event.stopPropagation(); @@ -588,7 +595,7 @@ const plot_labels: Record = {axes: "坐标轴", spectrum: "频 datoviz_pixel: "三维像素", datoviz_marker: "三维标记", datoviz_sphere: "三维球体", datoviz_segment: "三维线段", datoviz_vector: "三维向量", datoviz_primitive: "三维图元", datoviz_mesh: "三维网格", datoviz_path: "三维路径", datoviz_image: "三维图像", datoviz_labels: "三维标签", datoviz_glyph: "三维字形", datoviz_text: "三维文本", - datoviz_volume: "三维体数据"}; + datoviz_volume: "三维体数据", datoviz_spectrogram: "三维频谱瀑布"}; const field_label = (field: Field) => field.label || `字段(${field.key})`; const field_tooltip = (field: Field) => field.description; const enum_label = (value: string) => enum_labels[value] ?? value; @@ -990,42 +997,54 @@ function Frame_Statistics_Pane({plot, diagnostics, busy, on_refresh, on_reset}: } const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) { - const card_ref = useRef(null); const canvas_ref = useRef(null); - const [in_view, set_in_view] = useState(false); - useEffect(() => { - const card = card_ref.current; - if (!card) return; - const observer = new IntersectionObserver(entries => set_in_view(entries.some(entry => entry.isIntersecting)), {threshold: 0.05}); - observer.observe(card); - return () => observer.disconnect(); - }, []); - const {status, metrics} = use_plot_stream(plot, canvas_ref, selected || in_view); + const {status, metrics} = use_plot_stream(plot, canvas_ref, true, selected); const generated_time = metrics ? new Date(metrics.generated_time_unix_ms).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}) : "尚未生成帧"; - return
on_select(plot)} onFocusCapture={() => on_select(plot)}>
绘图组件 · {plot.dimension}

{plot_labels[plot.id] ?? plot.title}

{{CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}{metrics?.delivery === "diagnostics" ? "后台诊断" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}
{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPSE2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} msP95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} msP99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms
+ return
on_select(plot)} onFocusCapture={() => on_select(plot)}>
绘图组件 · {plot.dimension}

{plot_labels[plot.id] ?? plot.title}

{{IDLE: "待选中", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}{metrics?.delivery === "diagnostics" ? "后台诊断" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}
{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPSE2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} msP95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} msP99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms
{plot.description ?

{plot.description}

: null}
; }); type Gallery_Breakpoint = "lg" | "md" | "sm" | "xs"; -const gallery_layout_key = "aethera-gallery-grid-v1"; +const gallery_layout_key = "aethera-gallery-grid-v4"; const gallery_breakpoints: Record = {lg: 1280, md: 860, sm: 560, xs: 0}; const gallery_columns: Record = {lg: 12, md: 12, sm: 12, xs: 12}; const gallery_item_width: Record = {lg: 4, md: 6, sm: 12, xs: 12}; function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint): LayoutItem[] { - const width = gallery_item_width[breakpoint]; - const columns = Math.max(1, Math.floor(gallery_columns[breakpoint] / width)); - return plots.map((plot, index) => ({ - i: plot.id, - x: index % columns * width, - y: Math.floor(index / columns) * 6, - w: width, - h: 6, - minW: breakpoint === "lg" ? 3 : breakpoint === "md" ? 4 : 8, - minH: 4, - resizeHandles: ["s", "e", "se", "sw", "w"] - })); + const column_count = gallery_columns[breakpoint]; + const ordinary_width = gallery_item_width[breakpoint]; + let x = 0; + let y = 0; + let row_height = 0; + return plots.map(plot => { + const showcase = plot.id === "datoviz_spectrogram"; + const width = showcase ? column_count : ordinary_width; + const height = 6; + if (x + width > column_count) { + x = 0; + y += row_height; + row_height = 0; + } + const item: LayoutItem = { + i: plot.id, + x, + y, + w: width, + h: height, + minW: breakpoint === "lg" ? 3 : breakpoint === "md" ? 4 : 8, + minH: 4, + resizeHandles: ["s", "e", "se", "sw", "w"] + }; + x += width; + row_height = Math.max(row_height, height); + if (x >= column_count) { + x = 0; + y += row_height; + row_height = 0; + } + return item; + }); } function load_gallery_layouts(): ResponsiveLayouts {