三维频谱marker

This commit is contained in:
2026-08-22 21:36:21 +08:00
parent cd028e8d4f
commit 7c3f27474b
15 changed files with 594 additions and 207 deletions
+46 -3
View File
@@ -16,6 +16,18 @@ enum class Camera_Projection : std::uint8_t {
orthographic
};
enum class Camera_Controller : std::uint8_t {
turntable,
arcball,
fly,
panzoom
};
enum class Camera_Fly_Mode : std::uint8_t {
free,
plane
};
struct Camera_View {
Spatial_Point eye{2.8, -3.2, 2.4};
Spatial_Point target{};
@@ -23,7 +35,7 @@ struct Camera_View {
bool operator==(const Camera_View&) const = default;
};
struct Camera_Control {
struct Camera_Turntable_Control {
double yaw_speed{0.20};
double pitch_speed{0.20};
double zoom_speed{0.10};
@@ -35,13 +47,44 @@ struct Camera_Control {
bool rotate_enabled{true};
bool zoom_enabled{true};
bool pan_enabled{true};
bool operator==(const Camera_Control&) const = default;
bool invert_y{};
bool operator==(const Camera_Turntable_Control&) const = default;
};
struct Camera_Arcball_Control {
Spatial_Point constraint_axis{0.0, 0.0, 1.0};
bool constrain_rotation{};
bool operator==(const Camera_Arcball_Control&) const = default;
};
struct Camera_Fly_Control {
Camera_Fly_Mode mode{Camera_Fly_Mode::free};
double movement_speed{1.0};
double fast_multiplier{4.0};
double slow_multiplier{0.25};
double look_speed{0.0025};
double wheel_speed{0.5};
bool invert_y{};
bool fixed_up{true};
bool disable_roll{true};
bool operator==(const Camera_Fly_Control&) const = default;
};
struct Camera_Panzoom_Control {
bool fixed_x{};
bool fixed_y{};
bool keep_aspect{true};
bool operator==(const Camera_Panzoom_Control&) const = default;
};
struct Camera_Descriptor {
Camera_View initial_view{};
Camera_Projection projection{Camera_Projection::perspective};
Camera_Control control{};
Camera_Controller controller{Camera_Controller::turntable};
Camera_Turntable_Control turntable_control{};
Camera_Arcball_Control arcball_control{};
Camera_Fly_Control fly_control{};
Camera_Panzoom_Control panzoom_control{};
double vertical_field_of_view_degrees{45.0};
double near_plane{0.01};
double far_plane{100.0};
+5 -1
View File
@@ -9,7 +9,11 @@ struct Camera_3D : Def<Camera_3D, Root> {
struct Prop : Prev_Prop {
plot::Camera_View initial_view{};
plot::Camera_Projection projection{plot::Camera_Projection::perspective};
plot::Camera_Control control{};
plot::Camera_Controller controller{plot::Camera_Controller::turntable};
plot::Camera_Turntable_Control turntable_control{};
plot::Camera_Arcball_Control arcball_control{};
plot::Camera_Fly_Control fly_control{};
plot::Camera_Panzoom_Control panzoom_control{};
double vertical_field_of_view_degrees{45.0};
double near_plane{0.01};
double far_plane{100.0};
@@ -34,12 +34,15 @@ struct Async_Render_Backend::Implementation : std::enable_shared_from_this<Imple
Frame_3D* output{}; /* 调用方拥有且保证存活到完成回调的输出帧。 */
};
struct Submission {
Prepared_Visual visual{}; /* 共享 Prepare 发布的不可变 GPU 字段快照。 */
Prepared_Visual_Batch visuals{}; /* 同一 Scene 帧的全部不可变 Visual 快照。 */
Scene_3D_Parameters parameters{}; /* 本帧 viewport 与清屏参数。 */
Frame_3D* output{}; /* 调用方拥有的最终输出帧。 */
};
std::shared_ptr<Render_Domain> render_domain; /* GPU 索引对应的单线程 Datoviz 域。 */
std::unique_ptr<Datoviz_Visual_Backend> backend; /* 只允许 render_domain 线程访问。 */
std::uint32_t gpu_index{}; /* 首次使用时创建 Datoviz 后端所需 GPU。 */
bool validation_enabled{}; /* 首次使用时采用的 Vulkan 验证配置。 */
std::vector<Visual_Registration> visual_registrations{}; /* Scene Builder 发布的稳定 Visual 注册。 */
mutable std::mutex render_mutex; /* 保护合并帧意图、在途标记和完成回调。 */
std::deque<Submission> pending_submissions{}; /* 唯一 Frame Target 忙碌时等待的外部帧 FIFO。 */
Frame_Callback frame_callback{}; /* 完成帧的唯一发布出口。 */
@@ -47,38 +50,61 @@ struct Async_Render_Backend::Implementation : std::enable_shared_from_this<Imple
mutable std::mutex failure_mutex; /* 保护最后一次 Unknown Failure。 */
std::exception_ptr failure{}; /* 后端隔离边界捕获的最后一次 Unknown Failure。 */
std::atomic_bool available{}; /* 后端是否可接受事件和绘制。 */
Implementation(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters initial) : render_domain(Render_Domain::acquire(gpu_index)) { const auto initialized = render_domain->invoke([this, gpu_index, validation_enabled, visual_family, initial] { backend = std::make_unique<Datoviz_Visual_Backend>(gpu_index, validation_enabled, visual_family, initial); }); if (!initialized) throw std::runtime_error("render domain stopped during 3D backend initialization"); available.store(true, std::memory_order_release); }
Implementation(std::uint32_t gpu_index, bool validation_enabled,
std::vector<Visual_Registration> visuals)
: render_domain(Render_Domain::acquire(gpu_index)), gpu_index(gpu_index),
validation_enabled(validation_enabled), visual_registrations(std::move(visuals)) {
available.store(true, std::memory_order_release);
}
~Implementation() { available.store(false, std::memory_order_release); if (!backend) return; const auto destroyed = render_domain->invoke([this] { backend.reset(); }); if (!destroyed) backend.release(); }
void fail(std::exception_ptr value) noexcept;
void render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame);
void initialize(const Scene_3D_Parameters& parameters);
void render(Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters, Frame_3D* frame);
void submit(Submission submission);
void finish(std::shared_ptr<Pending> pending, Gpu_Completion_Service::Result completion) noexcept;
void complete(Frame_3D* frame);
};
Async_Render_Backend::Async_Render_Backend(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters parameters) : implementation_(std::make_shared<Implementation>(gpu_index, validation_enabled, visual_family, parameters)) {}
Async_Render_Backend::Async_Render_Backend(
std::uint32_t gpu_index, bool validation_enabled,
std::vector<Visual_Registration> visuals)
: implementation_(std::make_shared<Implementation>(
gpu_index, validation_enabled, std::move(visuals))) {}
Async_Render_Backend::~Async_Render_Backend() = default;
void Async_Render_Backend::Implementation::fail(std::exception_ptr value) noexcept { { std::lock_guard lock(failure_mutex); failure = std::move(value); } available.store(false, std::memory_order_release); std::lock_guard lock(render_mutex); frame_in_flight = false; pending_submissions.clear(); }
void Async_Render_Backend::Implementation::render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame) {
void Async_Render_Backend::Implementation::initialize(
const Scene_3D_Parameters& parameters) {
if (backend) return;
backend = std::make_unique<Datoviz_Visual_Backend>(
gpu_index, validation_enabled, visual_registrations, parameters);
visual_registrations.clear();
visual_registrations.shrink_to_fit();
}
void Async_Render_Backend::Implementation::render(
Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters, Frame_3D* frame) {
if (!frame) throw std::invalid_argument("3D backend requires a non-null external frame");
if (!available.load(std::memory_order_acquire)) return;
frame->mark(Frame_Trace_Marker::backend_queue_entered);
std::optional<Submission> submission;
{
std::lock_guard lock(render_mutex);
if (frame_in_flight) { pending_submissions.push_back(Submission{visual, parameters, frame}); return; }
if (frame_in_flight) {
pending_submissions.push_back(Submission{std::move(visuals), parameters, frame});
return;
}
frame_in_flight = true;
submission = Submission{visual, parameters, frame};
submission = Submission{std::move(visuals), parameters, frame};
}
submit(std::move(*submission));
}
void Async_Render_Backend::Implementation::submit(Submission submission) {
auto self = shared_from_this();
auto prepared = std::move(submission.visual);
auto prepared = std::move(submission.visuals);
auto pending = std::make_shared<Pending>(Pending{std::nullopt, submission.output});
const auto parameters = submission.parameters;
const auto sequence = submission.output->identity().sequence;
const auto queued = render_domain->try_post([self, parameters, sequence, prepared, pending] {
pending->output->mark(Frame_Trace_Marker::backend_queue_left);
self->initialize(parameters);
auto reservation = Gpu_Completion_Service::instance().prepare([self, pending](Gpu_Completion_Service::Result result) { self->finish(pending, std::move(result)); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }, true);
if (!reservation) { self->complete(pending->output); return; }
pending->backend_frame = self->backend->submit(parameters, prepared, sequence, true, pending->output->output() == Frame_3D_Output::pixels);
@@ -129,7 +155,10 @@ void Async_Render_Backend::Implementation::complete(Frame_3D* frame) {
if (frame && callback) { frame->mark(Frame_Trace_Marker::callback_started); callback(frame); frame->mark(Frame_Trace_Marker::callback_finished); }
if (next) submit(std::move(*next));
}
void Async_Render_Backend::render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame) { implementation_->render(visual, parameters, frame); }
void Async_Render_Backend::render(
Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters, Frame_3D* frame) {
implementation_->render(std::move(visuals), parameters, frame);
}
void Async_Render_Backend::set_frame_callback(Frame_Callback callback) { std::lock_guard lock(implementation_->render_mutex); implementation_->frame_callback = std::move(callback); }
bool Async_Render_Backend::available() const noexcept { return implementation_->available.load(std::memory_order_acquire); }
Async_Render_Backend::Dispatch_Event_Result Async_Render_Backend::dispatch_event(std::shared_ptr<const Event> event, Extent viewport) {
@@ -146,6 +175,7 @@ Async_Render_Backend::Dispatch_Event_Result Async_Render_Backend::dispatch_event
}
else if (!key) return Dispatch_Event_Result::ignored;
const auto queued = self->render_domain->try_post([self, event = std::move(event), viewport] {
if (!self->backend) return;
if (const auto* event_wheel = dynamic_cast<const Wheel_Event_Capability*>(event.get())) {
const auto* event_pointer = dynamic_cast<const Pointer_Event_Capability*>(event.get());
self->backend->dispatch_wheel(static_cast<float>(event_pointer->position_x()), static_cast<float>(event_pointer->position_y()), wheel_step(event_wheel->pixel_delta_x_value(), event_wheel->angle_delta_x_value()), wheel_step(event_wheel->pixel_delta_y_value(), event_wheel->angle_delta_y_value()), event_pointer->keyboard_modifiers(), viewport);
@@ -7,12 +7,13 @@ namespace aethera::render_3d::detail {
class Async_Render_Backend final {
public:
using Frame_Callback = std::function<void(Frame_3D*)>;
Async_Render_Backend(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters parameters);
Async_Render_Backend(std::uint32_t gpu_index, bool validation_enabled,
std::vector<Visual_Registration> visuals);
~Async_Render_Backend();
Async_Render_Backend(const Async_Render_Backend&) = delete;
Async_Render_Backend& operator=(const Async_Render_Backend&) = delete;
/* 将绘制意图无等待提交到调用方拥有的帧;frame 必须存活到完成回调返回。 */
void render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame);
void render(Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters, Frame_3D* frame);
/* 设置完成帧出口;回调在 Datoviz Render Domain 线程执行。 */
void set_frame_callback(Frame_Callback callback);
[[nodiscard]] bool available() const noexcept;
@@ -3,7 +3,20 @@
#include <event.hpp>
#include <plot/Axis.hpp>
#include <plot/Camera.hpp>
#include <cstdint>
#include <vector>
namespace aethera::render_3d::detail {
using Visual_Identity = std::uintptr_t;
struct Visual_Registration {
Visual_Identity identity{}; /* Scene 内稳定对象身份,仅供后端匹配原生 Visual。 */
Visual_Family family{Visual_Family::point}; /* 创建原生 Visual 所需的编译期 family。 */
bool operator==(const Visual_Registration&) const = default;
};
struct Prepared_Visual_Instance {
Visual_Identity identity{}; /* 与 Builder 注册身份一一对应。 */
Prepared_Visual visual{}; /* Prepare 发布的不可变字段快照。 */
};
using Prepared_Visual_Batch = std::vector<Prepared_Visual_Instance>;
struct Scene_3D_Parameters {
Extent viewport{560, 320}; /* 离屏渲染目标尺寸,单位为像素。 */
Linear_Color clear_color{}; /* 每帧开始时写入的线性背景色。 */
@@ -604,7 +604,8 @@ private:
};
Datoviz_Visual_Backend::Datoviz_Visual_Backend(
std::uint32_t gpu_index, bool validation_enabled,
Visual_Family visual_family, const Scene_3D_Parameters& initial_scene) : domain_thread_(std::this_thread::get_id()) {
const std::vector<Visual_Registration>& visuals,
const Scene_3D_Parameters& initial_scene) : domain_thread_(std::this_thread::get_id()) {
try {
render_context_ = Datoviz_Render_Context::acquire(gpu_index, validation_enabled);
auto* gpu_context = render_context_->gpu_context();
@@ -612,7 +613,7 @@ Datoviz_Visual_Backend::Datoviz_Visual_Backend(
dvz_gpu_ctx_device(gpu_context), dvz_gpu_ctx_alloc(gpu_context));
runtime_ = dvz_drp2_runtime_vklite(&runtime_configuration);
if (runtime_ == nullptr) throw std::runtime_error("failed to create Datoviz DRP2 runtime");
create_scene(visual_family, initial_scene);
create_scene(visuals, initial_scene);
}
catch (...) {
destroy();
@@ -625,7 +626,11 @@ Datoviz_Visual_Backend::~Datoviz_Visual_Backend() noexcept(false) {
void Datoviz_Visual_Backend::require_domain() const {
if (std::this_thread::get_id() != domain_thread_) throw std::logic_error("Datoviz objects may only be used on the render domain");
}
void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_Parameters& initial_scene) {
void Datoviz_Visual_Backend::create_scene(
const std::vector<Visual_Registration>& registrations,
const Scene_3D_Parameters& initial_scene) {
if (registrations.empty())
throw std::invalid_argument("Datoviz backend requires at least one Visual registration");
scene_ = dvz_scene();
if (scene_ == nullptr) throw std::runtime_error("failed to create Datoviz scene");
const auto capabilities = offscreen_capabilities();
@@ -633,46 +638,56 @@ void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_P
figure_ = dvz_figure(scene_, initial_scene.viewport.width,
initial_scene.viewport.height, 0);
panel_ = figure_ != nullptr ? dvz_panel_full(figure_) : nullptr;
if (panel_ != nullptr) {
if (figure_ == nullptr || panel_ == nullptr)
throw std::runtime_error("failed to create Datoviz figure and panel");
bool wants_item_interaction{};
visuals_.reserve(registrations.size());
for (const auto& registration : registrations) {
if (registration.identity == 0 ||
std::ranges::any_of(visuals_, [&](const Visual_Instance& value) {
return value.identity == registration.identity;
}))
throw std::invalid_argument("Datoviz backend received duplicate Visual identity");
const auto family = registration.family;
DvzVisual* visual{};
switch (family) {
case Visual_Family::point: visual_ = dvz_point(scene_, 0);
case Visual_Family::point: visual = dvz_point(scene_, 0);
break;
case Visual_Family::splat: visual_ = dvz_splat(scene_, 0);
case Visual_Family::splat: visual = dvz_splat(scene_, 0);
break;
case Visual_Family::pixel: visual_ = dvz_pixel(scene_, 0);
case Visual_Family::pixel: visual = dvz_pixel(scene_, 0);
break;
case Visual_Family::marker: visual_ = dvz_marker(scene_, 0);
case Visual_Family::marker: visual = dvz_marker(scene_, 0);
break;
case Visual_Family::sphere: visual_ = dvz_sphere(scene_, 0);
case Visual_Family::sphere: visual = dvz_sphere(scene_, 0);
break;
case Visual_Family::segment: visual_ = dvz_segment(scene_, 0);
case Visual_Family::segment: visual = dvz_segment(scene_, 0);
break;
case Visual_Family::vector: visual_ = dvz_vector(scene_, 0);
case Visual_Family::vector: visual = dvz_vector(scene_, 0);
break;
case Visual_Family::primitive: visual_ = dvz_primitive(
case Visual_Family::primitive: visual = dvz_primitive(
scene_, DVZ_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, 0);
break;
case Visual_Family::mesh: visual_ = dvz_mesh(scene_, 0);
case Visual_Family::mesh: visual = dvz_mesh(scene_, 0);
break;
case Visual_Family::path: visual_ = dvz_path(scene_, 0);
case Visual_Family::path: visual = dvz_path(scene_, 0);
break;
case Visual_Family::image: visual_ = dvz_image(scene_, 0);
case Visual_Family::image: visual = dvz_image(scene_, 0);
break;
case Visual_Family::labels: visual_ = dvz_labels(scene_, 0);
case Visual_Family::labels: visual = dvz_labels(scene_, 0);
break;
case Visual_Family::glyph:
case Visual_Family::text: visual_ = dvz_glyph(scene_, 0);
case Visual_Family::text: visual = dvz_glyph(scene_, 0);
break;
case Visual_Family::volume: visual_ = dvz_volume(scene_, 0);
case Visual_Family::volume: visual = dvz_volume(scene_, 0);
break;
}
}
if (visual_ != nullptr &&
dvz_visual_set_alpha_mode(visual_, DVZ_ALPHA_OPAQUE) != DVZ_OK)
if (visual != nullptr &&
dvz_visual_set_alpha_mode(visual, DVZ_ALPHA_OPAQUE) != DVZ_OK)
throw std::runtime_error("failed to configure Datoviz visual alpha mode");
if (visual_ != nullptr && family == Visual_Family::glyph) configure_glyph_text(scene_, visual_, "GLYPH");
if (visual_ != nullptr && family == Visual_Family::text) configure_glyph_text(scene_, visual_, "TEXT");
if (visual_ != nullptr &&
if (visual != nullptr && family == Visual_Family::glyph) configure_glyph_text(scene_, visual, "GLYPH");
if (visual != nullptr && family == Visual_Family::text) configure_glyph_text(scene_, visual, "TEXT");
if (visual != nullptr &&
(family == Visual_Family::image || family == Visual_Family::glyph ||
family == Visual_Family::text)) {
constexpr std::uint32_t width = 32;
@@ -702,10 +717,10 @@ void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_P
view.bytes_per_row = width * sizeof(pixels.front());
view.rows_per_image = height;
if (field == nullptr || dvz_sampled_field_set_data(field, &view) != DVZ_OK ||
dvz_visual_set_field(visual_, "field", field) != DVZ_OK)
dvz_visual_set_field(visual, "field", field) != DVZ_OK)
throw std::runtime_error("failed to create Datoviz 2D sampled field");
}
if (visual_ != nullptr && family == Visual_Family::labels) {
if (visual != nullptr && family == Visual_Family::labels) {
constexpr std::uint32_t width = 8;
constexpr std::uint32_t height = 8;
std::array<std::int32_t, width * height> labels{};
@@ -737,13 +752,13 @@ void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_P
};
if (field == nullptr || scale == nullptr ||
dvz_sampled_field_set_data(field, &view) != DVZ_OK ||
dvz_visual_set_field(visual_, "field", field) != DVZ_OK ||
dvz_visual_set_field(visual, "field", field) != DVZ_OK ||
dvz_scale_set_categories(scale, categories.data(),
static_cast<std::uint32_t>(categories.size())) != DVZ_OK ||
dvz_visual_set_scale(visual_, "labels", scale) != DVZ_OK)
dvz_visual_set_scale(visual, "labels", scale) != DVZ_OK)
throw std::runtime_error("failed to create Datoviz label field");
}
if (visual_ != nullptr && family == Visual_Family::volume) {
if (visual != nullptr && family == Visual_Family::volume) {
constexpr std::uint32_t side = 16;
std::vector<std::uint8_t> voxels(side * side * side);
for (std::uint32_t z = 0; z < side; ++z) {
@@ -772,18 +787,22 @@ void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_P
view.bytes_per_row = side;
view.rows_per_image = side;
if (field == nullptr || dvz_sampled_field_set_data(field, &view) != DVZ_OK ||
dvz_visual_set_field(visual_, "field", field) != DVZ_OK ||
dvz_volume_set_render_mode(visual_, DVZ_VOLUME_RENDER_MIP) != DVZ_OK ||
dvz_volume_set_step_count(visual_, 48) != DVZ_OK)
dvz_visual_set_field(visual, "field", field) != DVZ_OK ||
dvz_volume_set_render_mode(visual, DVZ_VOLUME_RENDER_MIP) != DVZ_OK ||
dvz_volume_set_step_count(visual, 48) != DVZ_OK)
throw std::runtime_error("failed to create Datoviz volume field");
}
if (figure_ == nullptr || panel_ == nullptr || visual_ == nullptr ||
dvz_panel_add_visual(panel_, visual_, nullptr) != DVZ_OK)
if (visual == nullptr || dvz_panel_add_visual(panel_, visual, nullptr) != DVZ_OK)
throw std::runtime_error("failed to create Datoviz visual family");
if (supports_item_interaction(family)) {
if (dvz_visual_set_query_capabilities(
visual_, DVZ_QUERY_CAPABILITY_ITEM) != DVZ_OK)
visual, DVZ_QUERY_CAPABILITY_ITEM) != DVZ_OK)
throw std::runtime_error("failed to enable Datoviz item queries");
wants_item_interaction = true;
}
visuals_.push_back({registration.identity, family, visual, 0});
}
if (wants_item_interaction) {
item_interaction_ = dvz_item_interaction(panel_, nullptr);
if (item_interaction_ == nullptr)
throw std::runtime_error("failed to create Datoviz item interaction");
@@ -944,11 +963,9 @@ void Datoviz_Visual_Backend::apply_axes(const Scene_3D_Parameters& scene) {
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;
if (camera_controller_ != nullptr) {
dvz_controller_destroy(camera_controller_);
camera_controller_ = nullptr;
}
DvzCameraDesc camera = dvz_camera_desc();
const auto assign = [](vec3 target, plot::Spatial_Point value) {
@@ -975,31 +992,79 @@ void Datoviz_Visual_Backend::apply_camera(const plot::Camera_Descriptor& source)
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<float>(source.control.yaw_speed);
turntable.pitch_speed = static_cast<float>(source.control.pitch_speed);
turntable.zoom_speed = static_cast<float>(source.control.zoom_speed);
turntable.pan_speed = static_cast<float>(source.control.pan_speed);
turntable.min_pitch = static_cast<float>(source.control.minimum_pitch);
turntable.max_pitch = static_cast<float>(source.control.maximum_pitch);
turntable.min_distance = static_cast<float>(source.control.minimum_distance);
turntable.max_distance = static_cast<float>(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");
DvzDimMask dimensions = DVZ_DIM_MASK_XYZ;
switch (source.controller) {
case plot::Camera_Controller::turntable: {
DvzTurntableDesc descriptor = dvz_turntable_desc();
descriptor.initial_view = camera.view;
descriptor.yaw_speed = static_cast<float>(source.turntable_control.yaw_speed);
descriptor.pitch_speed = static_cast<float>(source.turntable_control.pitch_speed);
descriptor.zoom_speed = static_cast<float>(source.turntable_control.zoom_speed);
descriptor.pan_speed = static_cast<float>(source.turntable_control.pan_speed);
descriptor.min_pitch = static_cast<float>(source.turntable_control.minimum_pitch);
descriptor.max_pitch = static_cast<float>(source.turntable_control.maximum_pitch);
descriptor.min_distance = static_cast<float>(source.turntable_control.minimum_distance);
descriptor.max_distance = static_cast<float>(source.turntable_control.maximum_distance);
descriptor.controller_flags = DVZ_TURNTABLE_FLAGS_WRAP_YAW |
DVZ_TURNTABLE_FLAGS_CLAMP_DISTANCE;
if (source.turntable_control.pan_enabled)
descriptor.controller_flags |= DVZ_TURNTABLE_FLAGS_ALLOW_PAN;
if (source.turntable_control.invert_y)
descriptor.controller_flags |= DVZ_TURNTABLE_FLAGS_INVERT_Y;
camera_controller_ = dvz_turntable(scene_, &descriptor);
break;
}
case plot::Camera_Controller::arcball: {
DvzArcballDesc descriptor = dvz_arcball_desc();
if (source.arcball_control.constrain_rotation)
descriptor.controller_flags |= DVZ_ARCBALL_FLAGS_CONSTRAIN;
camera_controller_ = dvz_arcball(scene_, &descriptor);
if (camera_controller_ != nullptr && source.arcball_control.constrain_rotation) {
auto* arcball = dvz_controller_arcball(camera_controller_);
vec3 axis{
static_cast<float>(source.arcball_control.constraint_axis.x),
static_cast<float>(source.arcball_control.constraint_axis.y),
static_cast<float>(source.arcball_control.constraint_axis.z)};
if (arcball == nullptr || dvz_arcball_constrain(arcball, axis) != DVZ_OK)
throw std::runtime_error("failed to configure Datoviz Arcball constraint");
}
break;
}
case plot::Camera_Controller::fly: {
DvzFlyDesc descriptor = dvz_fly_desc();
descriptor.initial_view = camera.view;
descriptor.mode = source.fly_control.mode == plot::Camera_Fly_Mode::plane
? DVZ_FLY_MODE_PLANE
: DVZ_FLY_MODE_FREE;
descriptor.speed = static_cast<float>(source.fly_control.movement_speed);
descriptor.fast_multiplier = static_cast<float>(source.fly_control.fast_multiplier);
descriptor.slow_multiplier = static_cast<float>(source.fly_control.slow_multiplier);
descriptor.look_speed = static_cast<float>(source.fly_control.look_speed);
descriptor.wheel_speed = static_cast<float>(source.fly_control.wheel_speed);
if (source.fly_control.invert_y) descriptor.controller_flags |= DVZ_FLY_FLAGS_INVERT_Y;
if (source.fly_control.fixed_up) descriptor.controller_flags |= DVZ_FLY_FLAGS_FIXED_UP;
if (source.fly_control.disable_roll) descriptor.controller_flags |= DVZ_FLY_FLAGS_DISABLE_ROLL;
camera_controller_ = dvz_fly(scene_, &descriptor);
break;
}
case plot::Camera_Controller::panzoom: {
DvzPanzoomDesc descriptor = dvz_panzoom_desc();
if (source.panzoom_control.fixed_x) descriptor.controller_flags |= DVZ_PANZOOM_FLAGS_FIXED_X;
if (source.panzoom_control.fixed_y) descriptor.controller_flags |= DVZ_PANZOOM_FLAGS_FIXED_Y;
if (source.panzoom_control.keep_aspect) descriptor.controller_flags |= DVZ_PANZOOM_FLAGS_KEEP_ASPECT;
camera_controller_ = dvz_panzoom(scene_, &descriptor);
dimensions = DVZ_DIM_MASK_XY;
break;
}
}
if (camera_controller_ == nullptr ||
dvz_panel_bind_controller(panel_, camera_controller_, dimensions) != DVZ_OK)
throw std::runtime_error("failed to bind Datoviz camera controller");
applied_camera_ = source;
}
void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepared_Visual& point) {
void Datoviz_Visual_Backend::apply(
const Scene_3D_Parameters& scene, const Prepared_Visual_Batch& prepared) {
require_domain();
if (!point.data) throw std::logic_error("3D prepared visual has no immutable payload");
const auto& data = *point.data;
if (target_extent_ != scene.viewport) {
if (dvz_figure_resize(figure_, scene.viewport.width,
scene.viewport.height) != DVZ_OK)
@@ -1012,7 +1077,25 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
}
apply_camera(scene.camera);
apply_axes(scene);
if (point.revision == applied_visual_revision_) return;
if (prepared.size() != visuals_.size())
throw std::logic_error("3D frame did not publish every registered Visual");
for (auto& target : visuals_) {
const auto found = std::ranges::find_if(prepared, [&](const Prepared_Visual_Instance& value) {
return value.identity == target.identity;
});
if (found == prepared.end())
throw std::logic_error("3D frame contains an unknown or missing Visual identity");
apply_visual(target, found->visual);
}
}
void Datoviz_Visual_Backend::apply_visual(
Visual_Instance& target, const Prepared_Visual& point) {
if (point.family != target.family)
throw std::logic_error("3D Visual family changed after Scene construction");
if (!point.data) throw std::logic_error("3D prepared visual has no immutable payload");
if (point.revision == target.applied_revision) return;
const auto& data = *point.data;
auto* visual = target.visual;
{
mat4 transform{};
for (std::size_t row = 0; row < 4; ++row)
@@ -1025,17 +1108,17 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
style.edge_color.a = point.point_style.edge_color.alpha;
style.stroke_width_px = point.point_style.stroke_width_px;
style.aspect = aspect(point.point_style.aspect);
if (dvz_point_set_style(visual_, &style) != DVZ_OK) throw std::runtime_error("failed to apply Datoviz point style");
if (dvz_point_set_style(visual, &style) != DVZ_OK) throw std::runtime_error("failed to apply Datoviz point style");
}
if (dvz_visual_set_transform(visual_, transform) != DVZ_OK ||
dvz_visual_set_depth_test(visual_, point.depth_test) != DVZ_OK ||
if (dvz_visual_set_transform(visual, transform) != DVZ_OK ||
dvz_visual_set_depth_test(visual, point.depth_test) != DVZ_OK ||
dvz_visual_set_visible(
visual_, point.visible && !data.positions.empty()) != DVZ_OK)
visual, point.visible && !data.positions.empty()) != DVZ_OK)
throw std::runtime_error("failed to apply Datoviz visual state");
}
if (data.positions.empty()) {
if (dvz_visual_set_visible(visual_, false) != DVZ_OK) throw std::runtime_error("failed to hide empty Datoviz point visual");
applied_visual_revision_ = point.revision;
if (dvz_visual_set_visible(visual, false) != DVZ_OK) throw std::runtime_error("failed to hide empty Datoviz point visual");
target.applied_revision = point.revision;
return;
}
const auto count = static_cast<std::uint32_t>(data.positions.size());
@@ -1049,7 +1132,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"diameter_px", data.sizes.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 3);
result = dvz_visual_set_data_many(visual, updates.data(), 3);
break;
}
case Visual_Family::splat: {
@@ -1061,7 +1144,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"angle", data.angles.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 4);
result = dvz_visual_set_data_many(visual, updates.data(), 4);
break;
}
case Visual_Family::pixel: {
@@ -1072,7 +1155,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"pixel_size_px", data.sizes.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 3);
result = dvz_visual_set_data_many(visual, updates.data(), 3);
break;
}
case Visual_Family::marker: {
@@ -1085,7 +1168,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"shape", data.shapes.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 5);
result = dvz_visual_set_data_many(visual, updates.data(), 5);
break;
}
case Visual_Family::sphere: {
@@ -1096,7 +1179,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"radius", data.sizes.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 3);
result = dvz_visual_set_data_many(visual, updates.data(), 3);
break;
}
case Visual_Family::segment: {
@@ -1108,7 +1191,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"stroke_width_px", data.sizes.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 4);
result = dvz_visual_set_data_many(visual, updates.data(), 4);
break;
}
case Visual_Family::vector: {
@@ -1120,7 +1203,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"stroke_width_px", data.sizes.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 4);
result = dvz_visual_set_data_many(visual, updates.data(), 4);
break;
}
case Visual_Family::primitive:
@@ -1132,7 +1215,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"normal", data.normals.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 3);
result = dvz_visual_set_data_many(visual, updates.data(), 3);
break;
}
case Visual_Family::path: {
@@ -1143,7 +1226,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"stroke_width_px", data.sizes.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 3);
result = dvz_visual_set_data_many(visual, updates.data(), 3);
break;
}
case Visual_Family::image:
@@ -1154,7 +1237,7 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
{"extent", data.extents.data(), count}
}
};
result = dvz_visual_set_data_many(visual_, updates.data(), 2);
result = dvz_visual_set_data_many(visual, updates.data(), 2);
break;
}
case Visual_Family::glyph:
@@ -1162,21 +1245,21 @@ void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepa
case Visual_Family::volume: break;
}
if (result != DVZ_OK ||
dvz_visual_set_visible(visual_, point.visible) != DVZ_OK)
dvz_visual_set_visible(visual, point.visible) != DVZ_OK)
throw std::runtime_error("failed to upload Datoviz visual payload");
applied_visual_revision_ = point.revision;
target.applied_revision = point.revision;
}
void Datoviz_Visual_Backend::dispatch_pointer(
::aethera::Event_Type event, float x, float y,
::aethera::Mouse_Button mouse_button,
::aethera::Keyboard_Modifier keyboard_modifiers, Extent viewport) {
require_domain();
if (applied_camera_) {
if (applied_camera_ && applied_camera_->controller == plot::Camera_Controller::turntable) {
if (mouse_button == ::aethera::Mouse_Button::left &&
!applied_camera_->control.rotate_enabled) return;
!applied_camera_->turntable_control.rotate_enabled) return;
if ((mouse_button == ::aethera::Mouse_Button::middle ||
mouse_button == ::aethera::Mouse_Button::right) &&
!applied_camera_->control.pan_enabled) return;
!applied_camera_->turntable_control.pan_enabled) return;
}
const float width = static_cast<float>(viewport.width);
const float height = static_cast<float>(viewport.height);
@@ -1195,7 +1278,9 @@ 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;
if (applied_camera_ &&
applied_camera_->controller == plot::Camera_Controller::turntable &&
!applied_camera_->turntable_control.zoom_enabled) return;
dvz_pointer_emit_wheel(
input_router_, x, y, static_cast<float>(viewport.width),
static_cast<float>(viewport.height), delta_x, delta_y,
@@ -1205,8 +1290,24 @@ 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 (turntable_ == nullptr || dvz_turntable_reset(turntable_) != DVZ_OK)
throw std::runtime_error("failed to reset Datoviz Turntable Camera");
DvzResult reset = DVZ_ERROR;
switch (dvz_controller_type(camera_controller_)) {
case DVZ_CONTROLLER_TYPE_TURNTABLE:
reset = dvz_turntable_reset(dvz_controller_turntable(camera_controller_));
break;
case DVZ_CONTROLLER_TYPE_ARCBALL:
reset = dvz_arcball_reset(dvz_controller_arcball(camera_controller_));
break;
case DVZ_CONTROLLER_TYPE_FLY:
reset = dvz_fly_reset(dvz_controller_fly(camera_controller_));
break;
case DVZ_CONTROLLER_TYPE_PANZOOM:
reset = dvz_panzoom_reset(dvz_controller_panzoom(camera_controller_));
break;
default: break;
}
if (reset != DVZ_OK)
throw std::runtime_error("failed to reset Datoviz camera controller");
return;
}
const DvzKeyboardEventType type =
@@ -1248,7 +1349,7 @@ DvzSceneFrameArtifact* Datoviz_Visual_Backend::emit(
return artifact;
}
std::optional<Datoviz_Visual_Backend::Pending_Frame> Datoviz_Visual_Backend::submit(
const Scene_3D_Parameters& scene, const Prepared_Visual& point,
const Scene_3D_Parameters& scene, const Prepared_Visual_Batch& visuals,
std::uint64_t frame_sequence, bool observe, bool readback) {
require_domain();
if (scene.viewport.empty()) return std::nullopt;
@@ -1256,7 +1357,7 @@ std::optional<Datoviz_Visual_Backend::Pending_Frame> Datoviz_Visual_Backend::sub
trace.render_sequence = frame_sequence;
trace.observed = observe;
std::uint64_t phase_started = observe ? trace_now_ns() : 0;
apply(scene, point);
apply(scene, visuals);
if (item_interaction_ != nullptr) {
static_cast<void>(dvz_figure_process_queries(figure_, runtime_, nullptr));
DvzQueryResult query{};
@@ -1392,11 +1493,9 @@ 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 (camera_controller_ != nullptr) {
dvz_controller_destroy(camera_controller_);
camera_controller_ = nullptr;
}
if (item_interaction_ != nullptr) {
dvz_item_interaction_destroy(item_interaction_);
@@ -1415,7 +1514,7 @@ void Datoviz_Visual_Backend::destroy() {
dvz_input_router_destroy(input_router_);
input_router_ = nullptr;
}
visual_ = nullptr;
visuals_.clear();
axes_visual_ = nullptr;
axes_text_ = nullptr;
applied_camera_.reset();
@@ -32,12 +32,13 @@ public:
Datoviz_Frame_Trace trace; /* 已补全 GPU 和读回阶段的原始诊断数据。 */
};
Datoviz_Visual_Backend(std::uint32_t gpu_index, bool validation_enabled,
Visual_Family visual_family, const Scene_3D_Parameters& initial_scene);
const std::vector<Visual_Registration>& visuals,
const Scene_3D_Parameters& initial_scene);
~Datoviz_Visual_Backend() noexcept(false);
Datoviz_Visual_Backend(const Datoviz_Visual_Backend&) = delete;
Datoviz_Visual_Backend& operator=(const Datoviz_Visual_Backend&) = delete;
[[nodiscard]] std::optional<Pending_Frame> submit(
const Scene_3D_Parameters& scene, const Prepared_Visual& visual,
const Scene_3D_Parameters& scene, const Prepared_Visual_Batch& visuals,
std::uint64_t frame_sequence, bool observe, bool readback);
[[nodiscard]] Completed_Frame collect(Pending_Frame pending);
void discard(Pending_Frame pending);
@@ -51,11 +52,20 @@ public:
void dispatch_key(const Key_Event& event);
private:
class Frame_Target;
struct Visual_Instance {
Visual_Identity identity{}; /* Scene 注册的稳定身份。 */
Visual_Family family{Visual_Family::point}; /* 原生 Visual 的确定 family。 */
DvzVisual* visual{}; /* 由 Datoviz Scene 拥有。 */
std::uint64_t applied_revision{}; /* 此 Visual 已上传的 Prepare 版本。 */
};
void require_domain() const;
void create_scene(Visual_Family visual_family, const Scene_3D_Parameters& initial_scene);
void create_scene(const std::vector<Visual_Registration>& visuals,
const Scene_3D_Parameters& initial_scene);
[[nodiscard]] DvzVisual* create_visual(Visual_Family family);
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);
void apply(const Scene_3D_Parameters& scene, const Prepared_Visual_Batch& visuals);
void apply_visual(Visual_Instance& target, const Prepared_Visual& visual);
[[nodiscard]] DvzSceneFrameArtifact* emit(const Scene_3D_Parameters& scene);
void destroy();
std::thread::id domain_thread_; /* 唯一允许访问 Datoviz 对象的线程。 */
@@ -63,19 +73,18 @@ private:
DvzDrp2Runtime* runtime_{}; /* DRP2 Vulkan 运行时;由本类拥有。 */
DvzScene* scene_{}; /* Datoviz Scene;由本类拥有。 */
DvzFigure* figure_{}; /* 当前离屏 Figure。 */
DvzPanel* panel_{}; /* 承载唯一 Visual 的全屏 Panel。 */
DvzVisual* visual_{}; /* Builder 指定 family 的唯一 Visual。 */
DvzPanel* panel_{}; /* 承载全部业务 Visual 的全屏 Panel。 */
std::vector<Visual_Instance> visuals_{}; /* 按 Scene 稳定身份管理的原生 Visual。 */
DvzVisual* axes_visual_{}; /* 三维主轴、刻度和网格 Segment Visual。 */
DvzText* axes_text_{}; /* 随相机变换的三维刻度与轴标题。 */
DvzItemInteraction* item_interaction_{}; /* Datoviz 原生图元悬停与选择控制器。 */
DvzPinnedReadout* hover_readout_{}; /* 最近一次命中的 Datoviz 数据提示卡。 */
DvzTurntable* turntable_{}; /* 直接驱动 Panel Camera 的独立 Turntable。 */
DvzController* camera_controller_{}; /* 当前绑定到 Panel 的 Datoviz 原生相机控制器。 */
DvzInputRouter* input_router_{}; /* Scene 输入事件路由器。 */
DvzPointerGestureHandler* gesture_handler_{}; /* 指针手势解析器。 */
std::unique_ptr<Frame_Target> target_; /* 当前尺寸对应的离屏提交和读回目标。 */
Extent target_extent_{}; /* target_ 当前适配的像素尺寸。 */
std::uint64_t target_generation_{}; /* 每次重建 target_ 时递增的资源代次。 */
std::uint64_t applied_visual_revision_{}; /* 已上传到 Datoviz 的 Prepared 版本。 */
std::optional<plot::Camera_Descriptor> applied_camera_{}; /* 已应用到 Panel 的 Camera 配置。 */
std::optional<std::array<plot::Axis_Descriptor, 3>> applied_axes_{}; /* 已生成 Visual 的轴描述快照。 */
};
@@ -9,6 +9,7 @@
#include <expected>
#include <functional>
#include <memory>
#include <vector>
namespace aethera::render_3d {
/* 三维 Prepare 完成后的异步后端提交阶段标签。 */
struct Submit_Tag {};
@@ -40,9 +41,12 @@ struct Render_Scene_3D : Def<Render_Scene_3D, Scene, Dependency_Graph_Type<Submi
Final_Builder& use_gpu(std::uint32_t gpu_index, bool validation_enabled = false);
[[nodiscard]] std::expected<std::unique_ptr<Object>, 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<void>){}; /* 把 Scene 拥有的弱提交上下文绑定到最终 Visual Private。 */
struct Visual_Binding {
Root* object{}; /* 不拥有;生命周期必须覆盖 Scene。 */
Visual_Family family{Visual_Family::point}; /* 创建对应 Datoviz Visual 的 family。 */
void (*bind)(Root*, std::shared_ptr<void>){}; /* 绑定 Scene 拥有的发布上下文。 */
};
std::vector<Visual_Binding> visuals{}; /* 本 Scene 的全部 Visual,注册顺序保持稳定。 */
Root* camera{}; /* 不拥有的 Camera 组件;生命周期必须覆盖 Scene。 */
Root* axes{}; /* 不拥有的三轴组件;生命周期必须覆盖 Scene。 */
using Camera_Read = plot::Camera_Descriptor (*)(const Root*);
+33 -14
View File
@@ -1,5 +1,6 @@
#pragma once
#include "../detail/Async_Render_Backend.hpp"
#include <algorithm>
#include <stdexcept>
namespace aethera::render_3d {
namespace detail {
@@ -9,6 +10,7 @@ struct Scene_Paint_Context {
Object* scene{}; /* 仅在 Scene 拥有本上下文期间读取当前 Prop。 */
Frame_3D* frame{}; /* 当前 Submit 图对应的外部帧;process 返回后清空。 */
Scene_3D_Parameters parameters{}; /* 本帧从 Scene 组件读取的一致快照。 */
Prepared_Visual_Batch visuals{}; /* Submit 图发布的本帧 Visual 快照集合。 */
};
}
struct Render_Scene_3D::Private : Prev_Private {
@@ -33,7 +35,7 @@ struct Render_Scene_3D::Private : Prev_Private {
/* Builder 内部初始化后端;必须在绑定 Visual Paint 目标之前调用一次。 */
template <Attached Object>
void initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled,
Visual_Family visual_family, Root* camera, Root* axes,
std::vector<detail::Visual_Registration> visuals, Root* camera, Root* axes,
plot::Camera_Descriptor (*camera_reader)(const Root*),
std::array<plot::Axis_Descriptor, 3> (*axes_reader)(const Root*));
template <Attached Object> [[nodiscard]] detail::Scene_3D_Parameters parameters(Object* object) const;
@@ -52,17 +54,23 @@ template <typename Object> template <Attached Visual_Object>
typename Render_Scene_3D::Builder<Object>::Final_Builder&
Render_Scene_3D::Builder<Object>::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<void> context) {
if (std::ranges::any_of(visuals, [visual_value](const Visual_Binding& value) {
return value.object == visual_value;
}))
throw std::logic_error("Render_Scene_3D cannot attach the same Visual twice");
Visual_Binding binding{};
binding.object = visual_value;
binding.family = Visual_Object::Attached_Object::Specification::family;
binding.bind = [](Root* root, std::shared_ptr<void> context) {
auto* object = static_cast<Visual_Object*>(root);
Base::private_access(object).template get<typename Visual_Object::Attached_Object::Base_Tag>()
.bind_paint_target(std::move(context), [](void* raw_context, const detail::Prepared_Visual& prepared) {
.bind_paint_target(std::move(context), [](void* raw_context, const Root* identity,
const detail::Prepared_Visual& prepared) {
auto& submission = *static_cast<detail::Scene_Paint_Context<Object>*>(raw_context);
submission.backend->render(prepared, submission.parameters, submission.frame);
submission.visuals.push_back({reinterpret_cast<detail::Visual_Identity>(identity), prepared});
});
};
visuals.push_back(binding);
this->template add_dependency_node<Prepare_Data_Tag>(visual_value);
this->template add_dependency_node<Submit_Tag>(visual_value);
return static_cast<Final_Builder&>(*this);
@@ -76,7 +84,9 @@ Render_Scene_3D::Builder<Object>::add_camera(Camera_Object* camera_value) {
camera = camera_value;
read_camera = [](const Root* root) {
const auto& prop = static_cast<const Camera_Object*>(root)->template read_prop<Camera_3D::Base_Tag>();
return plot::Camera_Descriptor{prop.initial_view, prop.projection, prop.control,
return plot::Camera_Descriptor{prop.initial_view, prop.projection, prop.controller,
prop.turntable_control, prop.arcball_control,
prop.fly_control, prop.panzoom_control,
prop.vertical_field_of_view_degrees,
prop.near_plane, prop.far_plane};
};
@@ -105,16 +115,20 @@ Render_Scene_3D::Builder<Object>::use_gpu(std::uint32_t gpu_index_value,
}
template <typename Object>
std::expected<std::unique_ptr<Object>, Dependency_Graph_Error> Render_Scene_3D::Builder<Object>::build() {
if (!visual || !bind_visual) throw std::invalid_argument("Render_Scene_3D requires one Visual");
if (visuals.empty()) throw std::invalid_argument("Render_Scene_3D requires at least 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<Render_Scene_3D::Base_Tag>();
private_data.initialize_backend(scene.get(), gpu_index, validation_enabled, visual_family,
std::vector<detail::Visual_Registration> registrations;
registrations.reserve(visuals.size());
for (const auto& visual : visuals)
registrations.push_back({reinterpret_cast<detail::Visual_Identity>(visual.object), visual.family});
private_data.initialize_backend(scene.get(), gpu_index, validation_enabled, std::move(registrations),
camera, axes, read_camera, read_axes);
bind_visual(visual, private_data.paint_context);
for (const auto& visual : visuals) visual.bind(visual.object, private_data.paint_context);
return scene;
}
template <Attached Object>
@@ -125,7 +139,8 @@ detail::Scene_3D_Parameters Render_Scene_3D::Private::parameters(Object* object)
}
template <Attached Object>
void Render_Scene_3D::Private::initialize_backend(
Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family,
Object* object, std::uint32_t gpu_index, bool validation_enabled,
std::vector<detail::Visual_Registration> visuals,
Root* camera, Root* axes, plot::Camera_Descriptor (*camera_reader)(const Root*),
std::array<plot::Axis_Descriptor, 3> (*axes_reader)(const Root*)) {
camera_component = camera;
@@ -134,9 +149,9 @@ void Render_Scene_3D::Private::initialize_backend(
read_axes = axes_reader;
const auto initial = parameters(object);
backend = std::make_shared<detail::Async_Render_Backend>(gpu_index, validation_enabled,
visual_family, initial);
std::move(visuals));
paint_context = std::make_shared<detail::Scene_Paint_Context<Object>>(
detail::Scene_Paint_Context<Object>{backend, object, nullptr, initial});
detail::Scene_Paint_Context<Object>{backend, object, nullptr, initial, {}});
}
inline void Render_Scene_3D::Private::dispatch_events(Extent viewport) { this->consume_events([&](const std::shared_ptr<Event>& 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 <Attached Object, typename Callback>
@@ -153,6 +168,7 @@ void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requ
frame->mark(Frame_Trace_Marker::event_dispatch_finished);
auto context = std::static_pointer_cast<detail::Scene_Paint_Context<Object>>(paint_context);
context->parameters = parameters(object);
context->visuals.clear();
struct Frame_Context_Scope {
Frame_3D*& target; /* Visual Submit 回调读取的当前外部帧槽位。 */
Frame_3D* previous{}; /* 嵌套调用前的帧;析构时恢复。 */
@@ -188,6 +204,9 @@ void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requ
dispatch->state.notify(root);
});
if (!result) throw std::logic_error("render scene submit graph became invalid during submission");
if (context->visuals.empty())
throw std::logic_error("render scene submit graph published no Visual snapshots");
context->backend->render(context->visuals, context->parameters, context->frame);
frame->mark(Frame_Trace_Marker::paint_finished);
});
std::invoke(std::forward<Callback>(callback));
+2 -2
View File
@@ -4,7 +4,7 @@
namespace aethera::render_3d {
template <typename Spec>
struct Basic_Visual<Spec>::Private : Basic_Visual::Prev_Private {
using Enqueue_Paint = void (*)(void*, const detail::Prepared_Visual&);
using Enqueue_Paint = void (*)(void*, const Root*, const detail::Prepared_Visual&);
struct Paint_Target {
std::weak_ptr<void> context{}; /* Scene 拥有的异步提交上下文;Scene 销毁后自动失效。 */
Enqueue_Paint enqueue{}; /* 只入队不等待 GPU 完成的 Paint 入口。 */
@@ -42,7 +42,7 @@ template <typename Spec> bool Basic_Visual<Spec>::State::operator==(const State&
template <typename Spec> bool Basic_Visual<Spec>::Private::valid_items(const std::vector<Item>& items) { return std::ranges::all_of(items, [](const Item& item) { return Spec::valid(item); }); }
template <typename Spec> void Basic_Visual<Spec>::Private::bind_paint_target(std::shared_ptr<void> context, Enqueue_Paint enqueue) { paint_target = {std::move(context), enqueue}; }
template <typename Spec> template <Attached Object> void Basic_Visual<Spec>::Private::prepare_data(Object* object) { using Visual_Tag = typename Basic_Visual::Base_Tag; const auto& prop = object->template read_prop<Visual_Tag>(); if (!valid_items(prop.items) || !detail::finite(prop.transform)) throw std::invalid_argument("3D visual property contains invalid data"); auto data = std::make_shared<detail::Prepared_Visual_Data>(); Spec::prepare(prop.items, *data); auto& output = object->template pending_buffer<detail::Prepared_Visual_Tag>(); output = {}; output.family = Spec::family; output.transform = prop.transform; output.visible = prop.visible; output.depth_test = prop.depth_test; output.revision = next_revision++; output.data = std::move(data); if constexpr (requires { prop.style; }) output.point_style = prop.style; object->template update_state<&State::prepared_item_count, &State::prepared_revision>([&](State_Access<typename Object::State> states) { auto& state = states.template get<Visual_Tag>(); state.prepared_item_count = prop.items.size(); state.prepared_revision = output.revision; }); }
template <typename Spec> template <Attached Object> tf::Taskflow Basic_Visual<Spec>::Private::build_paint_graph(Object* object, const Prop&) { tf::Taskflow graph; graph.emplace([this, object] { if (auto context = paint_target.context.lock(); context && paint_target.enqueue) { const auto& pending = object->template pending_buffer<detail::Prepared_Visual_Tag>(); const auto& current = object->template current_buffer<detail::Prepared_Visual_Tag>(); paint_target.enqueue(context.get(), pending.revision >= current.revision ? pending : current); } }).name("render_3d.paint.enqueue"); return graph; }
template <typename Spec> template <Attached Object> tf::Taskflow Basic_Visual<Spec>::Private::build_paint_graph(Object* object, const Prop&) { tf::Taskflow graph; graph.emplace([this, object] { if (auto context = paint_target.context.lock(); context && paint_target.enqueue) { const auto& pending = object->template pending_buffer<detail::Prepared_Visual_Tag>(); const auto& current = object->template current_buffer<detail::Prepared_Visual_Tag>(); paint_target.enqueue(context.get(), object, pending.revision >= current.revision ? pending : current); } }).name("render_3d.paint.publish"); return graph; }
template <typename Spec> template <Attached Object> bool Basic_Visual<Spec>::Private::should_paint(Object*, const State&, bool) { return paint_target.enqueue != nullptr && !paint_target.context.expired(); }
template <typename Spec> template <typename Object, typename Owner, typename Member, typename Prop_Type> void Basic_Visual<Spec>::Private::after_prop_set(Object* object, Member Owner::*, Prop_Access<Prop_Type>) { if constexpr (std::same_as<Owner, Prop>) object->template mark_dirty<Prepare_Data_Tag>(); }
template <typename Spec> template <typename Object, typename Prop_Type, typename State_Type> void Basic_Visual<Spec>::Private::before_advance(Object*, Prop_Type*, State_Access<State_Type> pending_states, const Prop_Type* current_prop, State_Access<const State_Type>) { using Visual_Tag = typename Basic_Visual::Base_Tag; pending_states.template get<Visual_Tag>().item_count = static_cast<const Prop&>(*current_prop).items.size(); }
+153 -53
View File
@@ -200,12 +200,14 @@ class Visual_Scene_View final : public Plot::Scene_View {
public:
using Camera_Object = Impl<Camera_3D>;
using Axes_Object = Impl<Axes_3D>;
using Marker_Object = Impl<Marker_Visual>;
Visual_Scene_View(Scene_3D& scene, std::unique_ptr<Camera_Object> camera,
std::unique_ptr<Axes_Object> axes,
std::unique_ptr<Visual_Object> visual, std::string label,
Data_Generator data_generator = {})
Data_Generator data_generator = {},
std::unique_ptr<Marker_Object> markers = {})
: data_generator_(std::move(data_generator)), camera_(std::move(camera)),
axes_(std::move(axes)), visual_(std::move(visual)) {
axes_(std::move(axes)), visual_(std::move(visual)), markers_(std::move(markers)) {
using Definition = typename Visual_Object::Attached_Object;
using Prop = typename Definition::Prop;
using State = typename Definition::State;
@@ -225,7 +227,11 @@ public:
using Camera_Adapter = detail::Renderable_Adapter<Camera_Object,
detail::Prop_Field<&Camera_3D::Prop::initial_view, "initial_view", "Initial eye, target and world-up vectors used by reset view.">,
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::controller, "controller", "Datoviz native camera controller: turntable, arcball, fly or panzoom.">,
detail::Prop_Field<&Camera_3D::Prop::turntable_control, "turntable_control", "Turntable orbit, zoom and pan speeds and limits.">,
detail::Prop_Field<&Camera_3D::Prop::arcball_control, "arcball_control", "Arcball free rotation and optional constraint axis.">,
detail::Prop_Field<&Camera_3D::Prop::fly_control, "fly_control", "Fly camera movement mode, keyboard speed and pointer look settings.">,
detail::Prop_Field<&Camera_3D::Prop::panzoom_control, "panzoom_control", "Planar panzoom axis locks and aspect-ratio policy.">,
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.">>;
@@ -236,7 +242,34 @@ public:
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_}));
if constexpr (std::same_as<Definition, Marker_Visual>) {
using Marker_Adapter = detail::Renderable_Adapter<Visual_Object,
detail::Prop_Field<&Prop::items, "items", "Marker collection; editing the collection adds, moves or removes actual Scene markers.">,
detail::Prop_Field<&Prop::transform, "transform", "World transform applied to the complete marker collection.">,
detail::Prop_Field<&Prop::visible, "visible", "Whether markers participate in rendering.">,
detail::Prop_Field<&Prop::depth_test, "depth_test", "Whether marker fragments use depth testing.">,
detail::State_Field<Definition::Base_Tag, &State::item_count, "item_count", "Number of markers in the Scene.">,
detail::State_Field<Definition::Base_Tag, &State::prepared_item_count, "prepared_item_count", "Number of prepared marker items.">,
detail::State_Field<Definition::Base_Tag, &State::prepared_revision, "prepared_revision", "Marker GPU data revision.">>;
descriptors_.push_back(detail::make_renderable_descriptor(
"visual", std::move(label), "marker", Marker_Adapter{*visual_}));
} else {
descriptors_.push_back(detail::make_renderable_descriptor(
"visual", std::move(label), "visual", Visual_Adapter{*visual_}));
}
if (markers_) {
using Marker_Prop = Marker_Visual::Prop;
using Marker_State = Marker_Visual::State;
using Marker_Adapter = detail::Renderable_Adapter<Marker_Object,
detail::Prop_Field<&Marker_Prop::items, "items", "Surface marker anchors; X and Y are editable while Z is derived from the current spectrogram surface.", "surface-marker-list">,
detail::Prop_Field<&Marker_Prop::visible, "visible", "Whether the spectrogram marker layer is visible.">,
detail::Prop_Field<&Marker_Prop::depth_test, "depth_test", "Whether markers may be occluded by the surface.">,
detail::State_Field<Marker_Visual::Base_Tag, &Marker_State::item_count, "item_count", "Number of spectrogram markers.">,
detail::State_Field<Marker_Visual::Base_Tag, &Marker_State::prepared_item_count, "prepared_item_count", "Number of markers prepared for Datoviz.">,
detail::State_Field<Marker_Visual::Base_Tag, &Marker_State::prepared_revision, "prepared_revision", "Marker GPU data revision.">>;
descriptors_.push_back(detail::make_renderable_descriptor(
"markers", "频谱标记", "marker", Marker_Adapter{*markers_}));
}
}
[[nodiscard]] nlohmann::json schema() const override {
@@ -312,13 +345,22 @@ public:
}
}
void update(const Plot_Frame_Request&) override {}
void update(const Plot_Frame_Request& request) override {
if constexpr (requires(Data_Generator& generator, Visual_Object& visual,
Axes_Object& axes, Marker_Object* markers,
const Plot_Frame_Request& frame) {
generator.update(visual, axes, markers, frame);
}) {
data_generator_.update(*visual_, *axes_, markers_.get(), request);
}
}
private:
[[no_unique_address]] Data_Generator data_generator_; /* Plot 业务数据生成策略。 */
std::unique_ptr<Camera_Object> camera_; /* Scene 引用的 Camera 唯一所有者。 */
std::unique_ptr<Axes_Object> axes_; /* Scene 引用的 Axes 唯一所有者。 */
std::unique_ptr<Visual_Object> visual_; /* Scene 引用的 Visual 唯一所有者。 */
std::unique_ptr<Marker_Object> markers_; /* 可选的独立 Marker Visual 所有者。 */
std::vector<std::unique_ptr<detail::Renderable_Descriptor>> descriptors_; /* Prop/State 协议描述。 */
};
@@ -335,7 +377,8 @@ template <typename Definition, typename Build_Result,
std::shared_ptr<Plot> make_visual_plot(asio::any_io_executor executor, std::string label,
Build_Result build_result,
Scene_Components_3D components = {},
Data_Generator data_generator = {}) {
Data_Generator data_generator = {},
std::unique_ptr<Impl<Marker_Visual>> markers = {}) {
using Visual_Object = Impl<Definition>;
using Camera_Object = Impl<Camera_3D>;
using Axes_Object = Impl<Axes_3D>;
@@ -344,7 +387,11 @@ std::shared_ptr<Plot> make_visual_plot(asio::any_io_executor executor, std::stri
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::controller, components.camera.controller)
.set(&Camera_3D::Prop::turntable_control, components.camera.turntable_control)
.set(&Camera_3D::Prop::arcball_control, components.camera.arcball_control)
.set(&Camera_3D::Prop::fly_control, components.camera.fly_control)
.set(&Camera_3D::Prop::panzoom_control, components.camera.panzoom_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)
@@ -366,12 +413,13 @@ std::shared_ptr<Plot> make_visual_plot(asio::any_io_executor executor, std::stri
.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);
if (markers) scene_builder.add_renderable(markers.get());
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<Visual_Scene_View<Visual_Object, Data_Generator>>(
*scene, std::move(camera), std::move(axes), std::move(visual),
std::move(label), std::move(data_generator));
std::move(label), std::move(data_generator), std::move(markers));
return std::make_shared<Plot>(std::move(executor), std::move(scene), std::move(view));
}
@@ -427,21 +475,48 @@ Vec3 face_normal(Vec3 first, Vec3 second, Vec3 third) {
return result;
}
std::vector<Mesh_Vertex> spectrogram_mesh(const Spectrogram_Parameters& parameters) {
float spectrogram_level(float time, float frequency, double animation_seconds,
std::size_t ridge_count) {
struct Ridge { float center; float width; float phase; float speed; float strength; };
std::mt19937_64 engine{std::random_device{}()};
std::uniform_real_distribution<float> center_distribution{0.08F, 0.92F};
std::uniform_real_distribution<float> width_distribution{0.025F, 0.12F};
std::uniform_real_distribution<float> phase_distribution{0.0F, 2.0F * std::numbers::pi_v<float>};
std::uniform_real_distribution<float> speed_distribution{0.35F, 1.8F};
std::uniform_real_distribution<float> strength_distribution{0.38F, 0.9F};
std::normal_distribution<float> noise{0.0F, 0.035F};
std::vector<Ridge> 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)});
static constexpr std::array ridges{
Ridge{0.12F, 0.035F, 0.20F, 0.42F, 0.72F},
Ridge{0.28F, 0.060F, 1.45F, 0.67F, 0.58F},
Ridge{0.47F, 0.045F, 2.70F, 0.53F, 0.82F},
Ridge{0.68F, 0.075F, 4.10F, 0.31F, 0.52F},
Ridge{0.84F, 0.030F, 5.20F, 0.78F, 0.68F},
Ridge{0.57F, 0.022F, 0.90F, 1.05F, 0.44F},
Ridge{0.36F, 0.028F, 3.60F, 0.91F, 0.40F},
Ridge{0.76F, 0.050F, 2.10F, 0.58F, 0.46F},
Ridge{0.20F, 0.024F, 4.80F, 0.72F, 0.38F},
Ridge{0.92F, 0.020F, 1.90F, 0.84F, 0.36F},
Ridge{0.52F, 0.090F, 5.70F, 0.27F, 0.32F},
Ridge{0.08F, 0.018F, 3.00F, 1.12F, 0.30F}};
const float animation = static_cast<float>(animation_seconds);
float level = 0.075F + 0.055F * std::sin(
2.0F * std::numbers::pi_v<float> *
(0.78F * time + 0.24F * frequency + 0.11F * animation));
const auto active_ridges = std::min(ridge_count, ridges.size());
for (std::size_t index = 0; index < active_ridges; ++index) {
const auto& ridge = ridges[index];
const auto moving_center = std::clamp(
ridge.center + 0.065F * std::sin(
ridge.phase + ridge.speed * animation + 1.3F * time),
0.015F, 0.985F);
const auto distance = (frequency - moving_center) / ridge.width;
const auto envelope = 0.52F + 0.48F * std::sin(
ridge.phase * 0.61F + 2.2F * time +
(ridge.speed + 0.18F) * animation);
level += ridge.strength * envelope * std::exp(-0.5F * distance * distance);
}
const float detail = 0.025F * std::sin(
31.0F * frequency + 8.0F * time + 1.7F * animation) +
0.018F * std::cos(
19.0F * frequency - 13.0F * time + animation);
return std::clamp(level + detail, 0.0F, 1.0F);
}
std::vector<Mesh_Vertex> spectrogram_mesh(const Spectrogram_Parameters& parameters,
double animation_seconds = 0.0) {
struct Sample { Vec3 position; Color color; };
std::vector<Sample> samples(parameters.time_sample_count *
@@ -454,20 +529,8 @@ std::vector<Mesh_Vertex> spectrogram_mesh(const Spectrogram_Parameters& paramete
frequency_index < parameters.frequency_bin_count; ++frequency_index) {
const auto frequency = static_cast<float>(frequency_index) /
static_cast<float>(parameters.frequency_bin_count - 1);
float level = 0.08F + 0.08F * std::sin(
2.0F * std::numbers::pi_v<float> * (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<float> * 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<float> * time);
level += ridge.strength * envelope * std::exp(-0.5F * distance * distance);
}
level = std::clamp(level + noise(engine), 0.0F, 1.0F);
const float level = spectrogram_level(
time, frequency, animation_seconds, parameters.ridge_count);
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)};
@@ -498,6 +561,11 @@ std::vector<Mesh_Vertex> spectrogram_mesh(const Spectrogram_Parameters& paramete
}
struct Spectrogram_Data_Generator {
Spectrogram_Parameters parameters{};
explicit Spectrogram_Data_Generator(Spectrogram_Parameters value = {})
: parameters(std::move(value)) {}
[[nodiscard]] Json schema() const {
Json fields = Json::array();
fields.push_back(integer_field("time_sample_count", "时间采样数", "时间方向的网格采样数量;增大后表面沿时间方向更细密。", 80, 16, 256));
@@ -514,22 +582,23 @@ struct Spectrogram_Data_Generator {
}
[[nodiscard]] Json generate(Impl<Mesh_Visual>& visual, Impl<Axes_3D>& axes,
const Json& input) const {
const Json& input) {
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))
Spectrogram_Parameters next;
next.time_sample_count = input_count(input, "time_sample_count", 256);
next.frequency_bin_count = input_count(input, "frequency_bin_count", 256);
next.ridge_count = input_count(input, "ridge_count", 12);
next.time_span_seconds = input_number(input, "time_span_seconds");
next.minimum_frequency_hz = input_number(input, "minimum_frequency_hz");
next.maximum_frequency_hz = input_number(input, "maximum_frequency_hz");
next.minimum_level_db = input_number(input, "minimum_level_db");
next.maximum_level_db = input_number(input, "maximum_level_db");
if (!(next.time_span_seconds > 0.0) ||
!(next.minimum_frequency_hz > 0.0) ||
!(next.maximum_frequency_hz > next.minimum_frequency_hz) ||
!(next.maximum_level_db > next.minimum_level_db))
throw std::invalid_argument("spectrogram ranges are invalid");
parameters = next;
auto mesh = spectrogram_mesh(parameters);
const auto vertex_count = mesh.size();
if (visual.update_items(std::move(mesh)) != Mesh_Visual::Update_Items_Result::updated)
@@ -551,6 +620,25 @@ struct Spectrogram_Data_Generator {
return {{"success", false}, {"error", error.what()}};
}
}
void update(Impl<Mesh_Visual>& visual, Impl<Axes_3D>&,
Impl<Marker_Visual>* markers,
const Plot_Frame_Request& request) const {
const double animation_seconds = request.time_milliseconds / 1000.0;
auto mesh = spectrogram_mesh(parameters, animation_seconds);
if (visual.update_items(std::move(mesh)) != Mesh_Visual::Update_Items_Result::updated)
throw std::logic_error("animated spectrogram mesh was rejected");
if (markers == nullptr) return;
auto items = markers->template read_prop<Marker_Visual::Base_Tag>().items;
for (auto& marker : items) {
const float time = std::clamp((marker.position.x + 1.0F) * 0.5F, 0.0F, 1.0F);
const float frequency = std::clamp((marker.position.y + 1.0F) * 0.5F, 0.0F, 1.0F);
marker.position.z = -1.0F + 2.0F * spectrogram_level(
time, frequency, animation_seconds, parameters.ridge_count);
}
if (markers->update_items(std::move(items)) != Marker_Visual::Update_Items_Result::updated)
throw std::logic_error("surface-attached spectrogram markers were rejected");
}
};
}
@@ -604,11 +692,23 @@ std::shared_ptr<Plot> make_datoviz_mesh_plot(asio::any_io_executor executor) {
std::shared_ptr<Plot> make_datoviz_spectrogram_plot(asio::any_io_executor executor) {
const Spectrogram_Parameters parameters;
auto marker_result = Impl<Marker_Visual>::Builder{}
.set(&Marker_Visual::Prop::items, std::vector<Marker>{
{{-0.35F, -0.18F, 0.72F}, color(255, 244, 170), 23.0F, 0.0F,
Marker_Shape::diamond},
{{0.28F, 0.34F, 0.86F}, color(88, 236, 211), 21.0F, 0.0F,
Marker_Shape::cross}})
.set(&Marker_Visual::Prop::depth_test, false)
.build();
if (!marker_result)
throw std::logic_error("3D Spectrogram marker construction failed");
auto markers = std::move(marker_result).value();
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.turntable_control = {0.15, 0.15, 0.10, 0.0015,
-1.35, 1.35, 1.25, 12.0,
true, true, true, false};
components.camera.vertical_field_of_view_degrees = 41.0;
components.axes = {
plot::Axis_Descriptor{{0.0, parameters.time_span_seconds},
@@ -624,7 +724,7 @@ std::shared_ptr<Plot> make_datoviz_spectrogram_plot(asio::any_io_executor execut
Impl<Mesh_Visual>::Builder{}
.set(&Mesh_Visual::Prop::items, spectrogram_mesh(parameters))
.build(),
std::move(components), Spectrogram_Data_Generator{});
std::move(components), Spectrogram_Data_Generator{parameters}, std::move(markers));
}
std::shared_ptr<Plot> make_datoviz_path_plot(asio::any_io_executor executor) {
+2 -1
View File
@@ -14,7 +14,8 @@ enum class Renderable_Field_Role {
template <Renderable_Field_Role Role>
struct Renderable_Field_Role_Attribute;
template <auto Member, structive::Fixed_String Key, structive::Fixed_String Description>
template <auto Member, structive::Fixed_String Key, structive::Fixed_String Description,
structive::Fixed_String Editor = "">
struct Prop_Field;
template <typename Tag, auto Member, structive::Fixed_String Key, structive::Fixed_String Description>
+17 -5
View File
@@ -26,11 +26,13 @@ struct Renderable_Field_Role_Attribute {
static constexpr auto value = Role;
};
template <auto Member, structive::Fixed_String Key, structive::Fixed_String Description>
template <auto Member, structive::Fixed_String Key, structive::Fixed_String Description,
structive::Fixed_String Editor>
struct Prop_Field {
static constexpr auto member = Member;
static constexpr auto key = Key;
static constexpr auto description = Description;
static constexpr auto editor = Editor;
static constexpr auto role = Renderable_Field_Role::prop;
};
@@ -55,13 +57,15 @@ private:
template <typename Adapter, typename Field>
struct Renderable_Field_Accessor;
template <typename Object, typename... Fields, auto Member, structive::Fixed_String Key, structive::Fixed_String Description>
struct Renderable_Field_Accessor<Renderable_Adapter<Object, Fields...>, Prop_Field<Member, Key, Description>> {
template <typename Object, typename... Fields, auto Member, structive::Fixed_String Key,
structive::Fixed_String Description, structive::Fixed_String Editor>
struct Renderable_Field_Accessor<Renderable_Adapter<Object, Fields...>, Prop_Field<Member, Key, Description, Editor>> {
using object_type = Renderable_Adapter<Object, Fields...>;
using value_type = typename structive::Member_Pointer_Traits<decltype(Member)>::value_type;
using storage_identity = void;
using dependency_spec = structive::No_Property_Dependencies;
static constexpr auto description = Description;
static constexpr auto editor = Editor;
static constexpr bool readable = true;
static constexpr bool writable = true;
static constexpr bool synchronized_view_read = false;
@@ -221,6 +225,7 @@ constexpr std::string_view protocol_editor() {
if constexpr (std::same_as<Type, render_2d::Color_Map>) return "color-map";
if constexpr (std::same_as<Type, render_3d::Vec3>) return "vector3";
if constexpr (std::same_as<Type, render_3d::Matrix4>) return "matrix4";
if constexpr (std::same_as<Type, std::vector<render_3d::Marker>>) return "marker-list";
return "json";
}
@@ -229,7 +234,9 @@ inline std::string_view protocol_field_label(std::string_view key) {
{"position", "位置"}, {"viewport", "视口尺寸"}, {"background", "背景颜色"},
{"clear_color", "清屏颜色"}, {"view_active", "启用视图"},
{"initial_view", "初始相机视图"}, {"projection", "投影方式"},
{"control", "相机交互控制"}, {"vertical_field_of_view_degrees", "垂直视场角"},
{"controller", "Datoviz 相机模式"}, {"turntable_control", "Turntable 控制"},
{"arcball_control", "Arcball 控制"}, {"fly_control", "Fly 控制"},
{"panzoom_control", "Panzoom 控制"}, {"vertical_field_of_view_degrees", "垂直视场角"},
{"near_plane", "近裁剪面"}, {"far_plane", "远裁剪面"},
{"x_axis", "X 坐标轴"}, {"y_axis", "Y 坐标轴"}, {"z_axis", "Z 坐标轴"},
{"pixel_length", "轴线长度"}, {"orientation", "轴线方向"}, {"tick_length", "主刻度长度"},
@@ -298,7 +305,12 @@ nlohmann::json adapter_schema(const Adapter& adapter, std::string_view id, std::
const auto value = field.accessor.read(adapter);
const bool editable = field.template attribute<Renderable_Field_Role_Category>().value == Renderable_Field_Role::prop;
const auto field_label = protocol_field_label(field.key());
nlohmann::json item{{"key", field.key()}, {"label", field_label}, {"editor", protocol_editor<Value>()},
std::string_view editor = protocol_editor<Value>();
if constexpr (requires { Field::accessor_type::editor; }) {
if (!Field::accessor_type::editor.view().empty())
editor = Field::accessor_type::editor.view();
}
nlohmann::json item{{"key", field.key()}, {"label", field_label}, {"editor", editor},
{"editable", editable},
{"description", std::string(field_label) + "。协议字段:" + std::string(field.key()) + "。"},
{"technical_description", std::string(Field::accessor_type::description.view())},
+48 -11
View File
@@ -15,7 +15,7 @@ echarts.use([LineChart, DataZoomComponent, GridComponent, LegendComponent, Toolt
type Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; schema: string};
type Option = {value: string; label: string};
type Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "point2" | "size" | "rect" | "range" | "pen" | "brush" | "font" | "color-map" | "vector3" | "matrix4" | "json";
type Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "point2" | "size" | "rect" | "range" | "pen" | "brush" | "font" | "color-map" | "vector3" | "matrix4" | "marker-list" | "surface-marker-list" | "json";
type Color_Channel_Scale = "normalized" | "byte";
type Field = {key: string; label: string; description: string; technical_description?: string; editor: Editor; editable: boolean; value: unknown; options?: Option[]; color_channel_scale?: Color_Channel_Scale; minimum?: number; maximum?: number; step?: number};
type Component = {id: string; label: string; kind: string; fields: Field[]; state: Record<string, unknown>};
@@ -443,11 +443,9 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
const detail = (event as CustomEvent<{plot_id: string}>).detail;
const canvas = canvas_ref.current;
if (detail?.plot_id !== plot.id || !canvas) return;
const bounds = canvas.getBoundingClientRect();
const position = {x: bounds.width * devicePixelRatio * 0.5, y: bounds.height * devicePixelRatio * 0.5};
const global_position = {x: (bounds.left + bounds.width * 0.5) * devicePixelRatio, y: (bounds.top + bounds.height * 0.5) * devicePixelRatio};
const pointer = (type: "pointer_press" | "pointer_release", buttons: number) => transmit("input", {type, position, global_position, button: "left", buttons, modifiers: 0});
pointer("pointer_press", 1); pointer("pointer_release", 0); pointer("pointer_press", 1); pointer("pointer_release", 0);
canvas.focus({preventScroll: true});
transmit("input", {type: "key_press", key: "home", native_key: 36, modifiers: 0, auto_repeat: false});
transmit("input", {type: "key_release", key: "home", native_key: 36, modifiers: 0, auto_repeat: false});
};
const on_diagnostics_reset = (event: Event) => {
const detail = (event as CustomEvent<{plot_id: string}>).detail;
@@ -657,9 +655,30 @@ function Structured_Control({field, on_change}: {field: Field; on_change: (value
</div></fieldset>;
}
type Marker_Value = {position: {x: number; y: number; z: number}; color: {red: number; green: number; blue: number; alpha: number}; diameter_px: number; angle: number; shape: string};
const marker_shapes = ["disc", "square", "triangle", "diamond", "cross"];
function Marker_List_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
const markers = Array.isArray(field.value) ? field.value as Marker_Value[] : [];
const surface_attached = field.editor === "surface-marker-list";
const replace = (index: number, marker: Marker_Value) => on_change(markers.map((value, item_index) => item_index === index ? marker : value));
const add = () => on_change([...markers, {position: {x: 0, y: 0, z: 0}, color: {red: 255, green: 218, blue: 112, alpha: 255}, diameter_px: 22, angle: 0, shape: "diamond"}]);
return <fieldset className="control markerListControl" title={field_tooltip(field)}><legend>{field_label(field)} <code>{field.key}</code></legend>
<div className="markerToolbar"><span>{markers.length} Marker</span><button type="button" onClick={add}> Marker</button></div>
<div className="markerRows">{markers.map((marker, index) => <section className="markerRow" key={index}>
<header><strong>Marker {index + 1}</strong><button type="button" onClick={() => on_change(markers.filter((_, item_index) => item_index !== index))}></button></header>
<div className="markerCoordinates">{(["x", "y", "z"] as const).map(axis => <label key={axis}><span>{axis === "z" && surface_attached ? "Z(谱面自动值)" : axis.toUpperCase()}</span><input type="number" min="-1" max="1" step="0.01" disabled={axis === "z" && surface_attached} value={marker.position?.[axis] ?? 0} onChange={event => replace(index, {...marker, position: {...marker.position, [axis]: Number(event.target.value)}})}/></label>)}</div>
<div className="markerAppearance"><label><span></span><select value={marker.shape ?? "diamond"} onChange={event => replace(index, {...marker, shape: event.target.value})}>{marker_shapes.map(shape => <option key={shape} value={shape}>{enum_label(shape)}</option>)}</select></label>
<label><span> px</span><input type="number" min="1" max="256" step="1" value={marker.diameter_px ?? 22} onChange={event => replace(index, {...marker, diameter_px: Number(event.target.value)})}/></label>
<label><span></span><input type="color" value={rgba_hex(marker.color ?? {red: 255, green: 218, blue: 112, alpha: 255})} onChange={event => replace(index, {...marker, color: update_rgb(marker.color, event.target.value) as Marker_Value["color"]})}/></label></div>
</section>)}</div>
</fieldset>;
}
function Field_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
if (["color", "point2", "size", "rect", "range", "pen", "brush", "font", "vector3"].includes(field.editor))
return <Structured_Control field={field} on_change={on_change}/>;
if (field.editor === "marker-list" || field.editor === "surface-marker-list") return <Marker_List_Control field={field} on_change={on_change}/>;
if (["json", "color-map", "matrix4"].includes(field.editor)) return <Json_Control field={field} on_change={on_change}/>;
if (field.editor === "boolean") return <label className="control controlBoolean" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span>
<input type="checkbox" checked={Boolean(field.value)} onChange={event => on_change(event.target.checked)}/></label>;
@@ -925,6 +944,22 @@ function State_Component({component, histories}: {component: Component; historie
<details className="rawState"><summary> JSON</summary><pre className="stateJson">{JSON.stringify(component.state, null, 2)}</pre></details></section>;
}
const camera_mode_descriptions: Record<string, {label: string; usage: string}> = {
turntable: {label: "Turntable 环绕", usage: "左键环绕目标,右键或中键平移,滚轮改变观察距离。"},
arcball: {label: "Arcball 自由旋转", usage: "拖动虚拟轨迹球进行无固定水平面的自由旋转,滚轮缩放。"},
fly: {label: "Fly 第一人称", usage: "拖动改变观察方向,W/A/S/D 和方向键移动,Shift 加速,滚轮前后推进。"},
panzoom: {label: "Panzoom 平面导航", usage: "拖动二维平面,滚轮缩放;适合固定观察方向下检查截面。"}
};
function Camera_Mode_Guide({fields}: {fields: Field[]}) {
const active = String(fields.find(field => field.key === "controller")?.value ?? "turntable");
return <div className="cameraModeGuide" aria-label="Datoviz 原生相机模式">
{Object.entries(camera_mode_descriptions).map(([key, mode]) => <div key={key} className={key === active ? "active" : ""}>
<strong>{mode.label}</strong><span>{mode.usage}</span>
</div>)}
</div>;
}
function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; on_update: (component: Component, field: Field, value: unknown) => Promise<void>}) {
const {components, selected, set_active} = use_active_component(schema);
const component = components.find(item => item.id === selected);
@@ -933,7 +968,9 @@ function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot;
return <section className="workspacePane"><Workspace_Header plot={plot} label="属性编辑" count={count} busy={busy} on_refresh={on_refresh}/>
<Component_Tabs components={components} selected={selected} on_select={set_active}/><div className="workspaceBody">
{busy && !schema ? <p className="muted"></p> : component ? <section className="componentContent"><div className="sectionIntro">
<strong>{component.label}</strong><span>{fields.length} </span></div>
<strong>{component.label}</strong><span>{fields.length} </span>
{component.kind === "camera" ? <button className="componentAction" onClick={() => window.dispatchEvent(new CustomEvent("aethera-reset-camera", {detail: {plot_id: plot.id}}))}></button> : null}</div>
{component.kind === "camera" ? <Camera_Mode_Guide fields={fields}/> : null}
<div className="propGrid">
{fields.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(component, field, value)}/>)}</div></section> : null}</div></section>;
}
@@ -973,14 +1010,14 @@ function Data_Generator_View({plot, generator, on_generated}: {plot: Plot; gener
{status ? <p className="analysisStatus">{status}</p> : null}</section>;
}
function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manual_frame, on_camera_reset, on_reset}: {
function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manual_frame, on_reset}: {
plot: Plot; analysis: Frame_Analysis | null; busy: boolean; on_refresh: () => void; on_update: (component: Frame_Analysis, field: Field, value: unknown) => Promise<void>;
on_manual_frame: () => void; on_camera_reset: () => void; on_reset: () => void;
on_manual_frame: () => void; on_reset: () => void;
}) {
const fields = analysis?.fields.filter(field => field.editable) ?? [];
return <section className="workspacePane"><Workspace_Header plot={plot} label="采样与帧策略" count={fields.length} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody"><section className="analysisSection"><header><div><strong>{plot.dimension} </strong><span>2D 3D 使</span></div>
<div className="framePolicyActions"><button onClick={on_manual_frame}></button>{plot.dimension === "3D" ? <button onClick={on_camera_reset}></button> : null}<button onClick={on_reset}></button></div></header>
<div className="framePolicyActions"><button onClick={on_manual_frame}></button><button onClick={on_reset}></button></div></header>
{analysis ? <div className="propGrid">{fields.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(analysis, field, value)}/>)}</div> : <p className="muted"></p>}
</section></div></section>;
}
@@ -1216,7 +1253,7 @@ export function App() {
if (node.getComponent() === "state") return <aside className="inspector" aria-label="状态查看面板"><State_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema(true)} histories={state_histories}/></aside>;
if (node.getComponent() === "frame-policy") return <aside className="inspector" aria-label="采样与帧策略"><Frame_Policy_Pane plot={selected} analysis={schema?.frame_analysis ?? null} busy={schema_busy} on_refresh={() => void load_schema(true)} on_update={update}
on_manual_frame={() => window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}}))}
on_camera_reset={() => window.dispatchEvent(new CustomEvent("aethera-reset-camera", {detail: {plot_id: selected.id}}))} on_reset={reset_frame_diagnostics}/></aside>;
on_reset={reset_frame_diagnostics}/></aside>;
if (node.getComponent() === "data-generation") return <aside className="inspector" aria-label="原始数据生成"><Data_Generation_Pane plot={selected} analysis={schema?.frame_analysis ?? null} busy={schema_busy} on_refresh={() => void load_schema(true)} on_generated={() => { reset_frame_diagnostics(); window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}})); }}/></aside>;
if (node.getComponent() === "frame-statistics") return <aside className="inspector" aria-label="帧流水线统计"><Frame_Statistics_Pane plot={selected} diagnostics={frame_diagnostics} busy={schema_busy} on_refresh={() => void load_schema(true)} on_reset={reset_frame_diagnostics}/></aside>;
return <div className="emptyPane"></div>;
+15
View File
@@ -109,6 +109,21 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.propertyActions button { padding: 6px 10px; color: #b9c9dd; border: 1px solid #304664; border-radius: 6px; background: #101c2d; cursor: pointer; }
.propertyActions button:first-child:not(:disabled) { color: #062019; border-color: #5ce4c2; background: #5ce4c2; }
.propertyActions button:disabled { opacity: .4; cursor: default; }
.componentAction { margin-left: auto; padding: 6px 10px; color: #062019; border: 1px solid #5ce4c2; border-radius: 6px; background: #5ce4c2; cursor: pointer; }
.cameraModeGuide { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0 0 14px; }
.cameraModeGuide > div { display: grid; gap: 4px; padding: 9px 10px; border: 1px solid #243850; border-radius: 7px; background: #0a1422; color: #7890ae; }
.cameraModeGuide > div.active { border-color: #52dfbd; background: #0c211f; box-shadow: inset 3px 0 #52dfbd; }
.cameraModeGuide strong { color: #dce8f8; font-size: 12px; }
.cameraModeGuide span { font-size: 11px; line-height: 1.45; }
.markerListControl { grid-column: 1 / -1; min-width: 0; }
.markerToolbar, .markerRow header, .markerAppearance, .markerCoordinates { display: flex; align-items: center; gap: 8px; }
.markerToolbar, .markerRow header { justify-content: space-between; }
.markerToolbar button, .markerRow button { padding: 5px 9px; color: #b9c9dd; border: 1px solid #304664; border-radius: 6px; background: #101c2d; cursor: pointer; }
.markerRows { display: grid; gap: 9px; margin-top: 10px; }
.markerRow { padding: 10px; border: 1px solid #263d59; border-radius: 8px; background: #0b1625; }
.markerCoordinates, .markerAppearance { flex-wrap: wrap; margin-top: 8px; }
.markerCoordinates label, .markerAppearance label { display: grid; grid-template-columns: auto minmax(64px, 1fr); align-items: center; gap: 6px; flex: 1 1 110px; }
.markerCoordinates input, .markerAppearance input, .markerAppearance select { min-width: 0; width: 100%; }
.savingHint { display: block; margin-top: 5px; color: #5ce4c2; text-align: right; }
.framePolicyActions { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 11px; border: 1px solid #27594f; border-radius: 9px; background: #0b1b1a; }
.framePolicyActions button { flex: 0 0 auto; padding: 8px 12px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; }